editorbrain 0.11.0 → 0.18.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 +1815 -83
- package/package.json +1 -1
- package/skill/editorbrain/AGENTS.md +24 -61
- package/skill/editorbrain/SKILL.md +3 -3
- package/skill/editorbrain/references/cli-mcp.md +16 -3
- package/skill/editorbrain/references/first-edit.md +2 -2
- package/skill/editorbrain/references/integrations.md +73 -0
- package/skill/editorbrain/references/local-production.md +72 -0
- package/skill/editorbrain/references/media-preferences.md +23 -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,866 @@ 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/editorial-delivery.ts
|
|
204
|
+
var editorialDeliveryOptions = {
|
|
205
|
+
narration: ["original", "voiceover", "none"],
|
|
206
|
+
onScreenText: ["minimal", "key-points", "detailed"],
|
|
207
|
+
broll: ["footage", "stills", "mixed", "none"],
|
|
208
|
+
captionLanguage: ["source", "brand"],
|
|
209
|
+
captionTreatment: ["clean", "word-highlight", "karaoke", "boxed"],
|
|
210
|
+
captionPosition: ["lower", "center", "adaptive"],
|
|
211
|
+
ctaGoal: ["adaptive", "follow", "comment", "visit", "contact", "none"],
|
|
212
|
+
ctaTreatment: ["adaptive", "end-card", "overlay", "spoken"]
|
|
213
|
+
};
|
|
214
|
+
function editorialDeliveryIssues(value) {
|
|
215
|
+
if (value === void 0) return [];
|
|
216
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return ["editorialDelivery must be an object."];
|
|
217
|
+
const input = value;
|
|
218
|
+
if (Object.keys(input).some((key) => !Object.prototype.hasOwnProperty.call(editorialDeliveryOptions, key))) return [`editorialDelivery supports ${Object.keys(editorialDeliveryOptions).join(", ")} only.`];
|
|
219
|
+
return Object.entries(editorialDeliveryOptions).flatMap(([key, options]) => input[key] === void 0 || typeof input[key] === "string" && options.includes(input[key]) ? [] : [`Invalid editorialDelivery ${key}. Omit undecided choices.`]);
|
|
220
|
+
}
|
|
221
|
+
function resolveEditorialDelivery(value = {}, captionsEnabled) {
|
|
222
|
+
const issues = editorialDeliveryIssues(value);
|
|
223
|
+
if (issues.length) throw new Error(issues.join("\n"));
|
|
224
|
+
const active = Object.fromEntries(Object.entries(value).filter(([, choice]) => choice !== void 0));
|
|
225
|
+
if (captionsEnabled === false) {
|
|
226
|
+
delete active.captionLanguage;
|
|
227
|
+
delete active.captionTreatment;
|
|
228
|
+
delete active.captionPosition;
|
|
229
|
+
}
|
|
230
|
+
if (active.ctaGoal === "none") delete active.ctaTreatment;
|
|
231
|
+
return active;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// packages/core/src/media-styles.ts
|
|
235
|
+
var sourceByStyle = {
|
|
236
|
+
"editorial-photo": { title: "Adobe: Editorial photography", url: "https://www.adobe.com/creativecloud/photography/type/editorial-photography.html" },
|
|
237
|
+
"studio-product": { title: "Adobe: Product photography", url: "https://www.adobe.com/creativecloud/photography/type/product-photography.html" },
|
|
238
|
+
"paper-collage": { title: "Adobe: Collage composition", url: "https://www.adobe.com/products/photoshop/collage.html" },
|
|
239
|
+
"ink-diagram": { title: "Adobe: Line art", url: "https://www.adobe.com/creativecloud/illustration/discover/line-art.html" },
|
|
240
|
+
"soft-3d": { title: "Blender: Rendering", url: "https://www.blender.org/features/rendering/" },
|
|
241
|
+
"flat-vector": { title: "Adobe: Illustration", url: "https://www.adobe.com/creativecloud/illustration.html" }
|
|
242
|
+
};
|
|
243
|
+
var brandAdaptation = "Los tokens de marca gobiernan colores y tipograf\xEDas de los gr\xE1ficos. Conserva los colores aut\xE9nticos del material, producto y logos; no heredes la paleta de la muestra.";
|
|
244
|
+
var expansionSources = {
|
|
245
|
+
"cinematic-scene": [
|
|
246
|
+
{ title: "Adobe: Cinematic lighting and depth", url: "https://helpx.adobe.com/ee/photoshop/how-to/standout-photos-lighting-vignette.html" },
|
|
247
|
+
{ title: "Referencia visual: luis_.hermida \xB7 5.0 s", url: "https://www.instagram.com/p/DY0ZmQevH2J/" }
|
|
248
|
+
],
|
|
249
|
+
"surreal-metaphor": [
|
|
250
|
+
{ title: "Adobe: Surreal photography", url: "https://www.adobe.com/uk/creativecloud/photography/discover/surreal-photography.html" },
|
|
251
|
+
{ title: "Referencia visual: russ.lockedin \xB7 19.0 y 28.5 s", url: "https://www.instagram.com/p/DbtWJOztrTO/" }
|
|
252
|
+
],
|
|
253
|
+
"painterly-story": [
|
|
254
|
+
{ title: "Adobe: Visual appearance of oil painting", url: "https://helpx.adobe.com/in/photoshop/using/oil-paint-filter.html" },
|
|
255
|
+
{ title: "Referencia visual: russ.lockedin \xB7 14.5 y 22.5 s", url: "https://www.instagram.com/p/DbWkY9ZSXtQ/" }
|
|
256
|
+
],
|
|
257
|
+
"vintage-engraving": [
|
|
258
|
+
{ title: "Adobe: Line, hatching and volume", url: "https://www.adobe.com/creativecloud/illustration/discover/line-art.html" },
|
|
259
|
+
{ title: "Referencia visual: russ.lockedin \xB7 17.5 s", url: "https://www.instagram.com/p/DbWkY9ZSXtQ/" }
|
|
260
|
+
],
|
|
261
|
+
"riso-playful": [
|
|
262
|
+
{ title: "RISO: Creative print, color and texture", url: "https://www.riso.co.uk/print-solutions/risograph-creative/" },
|
|
263
|
+
{ title: "Referencia de ilustraci\xF3n na\xEFf: ryanbailey.cb \xB7 11.5 s", url: "https://www.instagram.com/p/DbZDnSpyilk/" }
|
|
264
|
+
],
|
|
265
|
+
"sensory-macro": [
|
|
266
|
+
{ title: "Adobe: Macro photography", url: "https://www.adobe.com/creativecloud/photography/discover/macro-photography.html" },
|
|
267
|
+
{ title: "Referencia visual: ryanbailey.cb \xB7 15.5 y 24.5 s", url: "https://www.instagram.com/p/DbZDnSpyilk/" }
|
|
268
|
+
]
|
|
269
|
+
};
|
|
270
|
+
var image = (id, name, description, purpose, recipe, parameters, requirements, avoidWhen) => ({
|
|
271
|
+
id,
|
|
272
|
+
version: "1.0.0",
|
|
273
|
+
category: "image",
|
|
274
|
+
name,
|
|
275
|
+
description,
|
|
276
|
+
purpose,
|
|
277
|
+
recipe,
|
|
278
|
+
parameters,
|
|
279
|
+
adaptationRules: [brandAdaptation, "Adapta sujeto, encuadre y espacio para texto al contenido real y al formato. Usa recursos autorizados; una preferencia permite proponer, no autoriza generaci\xF3n ni compras."],
|
|
280
|
+
requirements,
|
|
281
|
+
avoidWhen,
|
|
282
|
+
status: "concept",
|
|
283
|
+
sources: [sourceByStyle[id]],
|
|
284
|
+
preview: { kind: "illustration", description: "Muestras originales generadas con IA del tratamiento visual; no acreditan exactitud factual, un render de cliente ni una transferencia probada. Vector limpio es una apariencia raster, no un archivo SVG editable.", examples: ["square", "portrait", "landscape"].map((format, index) => ({ ratio: ["1:1", "9:16", "16:9"][index], url: `https://editorbrain.com/media-styles/v1/${id}-${format}.webp`, provenance: "ai-generated" })) }
|
|
285
|
+
});
|
|
286
|
+
var expandedImage = (...args) => {
|
|
287
|
+
const style = image(...args);
|
|
288
|
+
return {
|
|
289
|
+
...style,
|
|
290
|
+
sources: expansionSources[style.id],
|
|
291
|
+
adaptationRules: [...style.adaptationRules, "Distingue hechos, reconstrucci\xF3n e imagen metaf\xF3rica: el tratamiento no acredita un evento, resultado o propiedad real. Los par\xE1metros son decisiones editoriales adaptables, no mediciones del referente."],
|
|
292
|
+
preview: {
|
|
293
|
+
kind: "illustration",
|
|
294
|
+
description: "Escenas originales generadas con IA, inspiradas en tratamientos observados. Son muestras de apariencia, no copias del referente, documentos hist\xF3ricos ni prueba de t\xE9cnica f\xEDsica, movimiento o sonido.",
|
|
295
|
+
examples: ["square", "portrait", "landscape"].map((format, index) => ({ ratio: ["1:1", "9:16", "16:9"][index], url: `https://editorbrain.com/media-styles/v2/${style.id}-${format}.webp`, provenance: "ai-generated" }))
|
|
296
|
+
}
|
|
297
|
+
};
|
|
298
|
+
};
|
|
299
|
+
var motion = (id, name, description, purpose, recipe, parameters, requirements, avoidWhen) => ({
|
|
300
|
+
id,
|
|
301
|
+
version: "1.0.0",
|
|
302
|
+
category: "motion",
|
|
303
|
+
name,
|
|
304
|
+
description,
|
|
305
|
+
purpose,
|
|
306
|
+
recipe,
|
|
307
|
+
parameters,
|
|
308
|
+
adaptationRules: [brandAdaptation, "Los tiempos son puntos de partida: ajusta a voz, acci\xF3n, lectura, resoluci\xF3n y fps reales. Con movimiento reducido, usa cortes o estados est\xE1ticos legibles. No deduzcas ni a\xF1adas SFX por esta animaci\xF3n."],
|
|
309
|
+
requirements,
|
|
310
|
+
avoidWhen,
|
|
311
|
+
status: "concept",
|
|
312
|
+
sources: [{ title: "Adobe: Six principles of animation in UX", url: "https://blog.adobe.com/en/publish/2019/06/19/designing-animation-six-principles-using-animation-ux" }],
|
|
313
|
+
preview: { kind: "animation", description: "Demostraci\xF3n del mecanismo temporal; no es una edici\xF3n de cliente ni prueba de calidad de transferencia." }
|
|
314
|
+
});
|
|
315
|
+
var composition = (args, source, formats) => {
|
|
316
|
+
const style = motion(...args);
|
|
317
|
+
return {
|
|
318
|
+
...style,
|
|
319
|
+
sources: [source],
|
|
320
|
+
adaptationRules: [
|
|
321
|
+
...style.adaptationRules,
|
|
322
|
+
"Los par\xE1metros propuestos son decisiones de adaptaci\xF3n, no tiempos ni movimientos medidos del referente. Conserva restricciones de recursos, formato de b-roll y permisos; elegir este dise\xF1o no autoriza generaci\xF3n ni gasto.",
|
|
323
|
+
...formats,
|
|
324
|
+
"Texto, cifras y anotaciones exactas permanecen editables en c\xF3digo. Una imagen generada no prueba un hecho; verifica continuidad, recortes y legibilidad con los recursos reales antes del render."
|
|
325
|
+
],
|
|
326
|
+
preview: { kind: "animation", description: "Composici\xF3n original con recursos ilustrativos y capas de c\xF3digo. La muestra demuestra una propuesta de organizaci\xF3n y secuencia; no reproduce al referente ni certifica hechos, sonido, herramientas disponibles o transferencia al material del cliente." }
|
|
327
|
+
};
|
|
328
|
+
};
|
|
329
|
+
var MEDIA_STYLE_CATALOG = {
|
|
330
|
+
schemaVersion: 1,
|
|
331
|
+
id: "editorbrain-media-styles",
|
|
332
|
+
version: "1.4.0",
|
|
333
|
+
styles: [
|
|
334
|
+
image(
|
|
335
|
+
"editorial-photo",
|
|
336
|
+
"Fotograf\xEDa editorial",
|
|
337
|
+
"Luz natural, un sujeto claro y contexto real.",
|
|
338
|
+
"Aportar contexto humano y credibilidad sin distraer del mensaje.",
|
|
339
|
+
["Define una acci\xF3n o sujeto concreto relacionado con el beat.", "Comp\xF3n un plano principal con profundidad suave y fondo contextual.", "Reserva espacio libre solo donde habr\xE1 texto; conserva textura y tonos naturales."],
|
|
340
|
+
[{ key: "subject_share", value: [35, 65], unit: "% del encuadre", adaptation: "Ajusta al objeto y al recorte final; no cortes manos o informaci\xF3n relevante." }, { key: "light", value: "Una fuente amplia lateral, sombras suaves", adaptation: "Respeta la luz existente si usas fotograf\xEDa real." }],
|
|
341
|
+
["Fotograf\xEDa propia/licenciada o herramienta de generaci\xF3n autorizada.", "Sujeto y contexto definidos por el proyecto."],
|
|
342
|
+
["Se requiere mostrar con exactitud un evento o persona y solo hay material sint\xE9tico.", "La escena necesita demostrar datos o relaciones abstractas."]
|
|
343
|
+
),
|
|
344
|
+
image(
|
|
345
|
+
"studio-product",
|
|
346
|
+
"Producto de estudio",
|
|
347
|
+
"Un producto protagonista con separaci\xF3n limpia del fondo.",
|
|
348
|
+
"Mostrar forma, material y detalles relevantes de un producto.",
|
|
349
|
+
["Parte del producto real y conserva envase, etiquetas y proporciones.", "Usa una base simple y luz suave que separe los contornos.", "A\xF1ade como m\xE1ximo un detalle contextual que ayude a explicar el beneficio."],
|
|
350
|
+
[{ key: "product_share", value: [45, 75], unit: "% de altura", adaptation: "Deja margen para el recorte y la informaci\xF3n del producto." }, { key: "background", value: "Superficie simple con sombra de contacto", adaptation: "Usa roles de marca; no inventes materiales o claims." }],
|
|
351
|
+
["Imagen precisa del producto y permisos de uso.", "Recorte o separaci\xF3n de sujeto si se cambia el fondo."],
|
|
352
|
+
["No hay referencia fiable del producto.", "El acabado estilizado oculta caracter\xEDsticas importantes."]
|
|
353
|
+
),
|
|
354
|
+
image(
|
|
355
|
+
"paper-collage",
|
|
356
|
+
"Collage de papel",
|
|
357
|
+
"Recortes, capas y textura discreta con jerarqu\xEDa editorial.",
|
|
358
|
+
"Relacionar ideas o fuentes distintas mediante una composici\xF3n tangible.",
|
|
359
|
+
["Elige un elemento principal y hasta dos apoyos.", "Recorta siluetas y combina bordes rectos con un detalle de papel.", "Separa capas con sombra sutil; mantiene el texto fuera de las zonas con textura."],
|
|
360
|
+
[{ key: "support_layers", value: [1, 3], unit: "capas", adaptation: "Reduce las capas si el formato o el texto pierden legibilidad." }, { key: "rotation", value: [-4, 4], unit: "grados", adaptation: "No gires documentos o etiquetas cuya lectura sea esencial." }],
|
|
361
|
+
["Im\xE1genes recortables con derechos y suficiente resoluci\xF3n.", "Capas editables para composici\xF3n."],
|
|
362
|
+
["Hay muchos datos densos que deben leerse.", "La marca necesita una presentaci\xF3n fotogr\xE1fica sin intervenci\xF3n."]
|
|
363
|
+
),
|
|
364
|
+
image(
|
|
365
|
+
"ink-diagram",
|
|
366
|
+
"Diagrama de tinta",
|
|
367
|
+
"Trazos claros, etiquetas breves y relaciones visibles.",
|
|
368
|
+
"Explicar un proceso, contraste o relaci\xF3n que la fotograf\xEDa no muestra.",
|
|
369
|
+
["Reduce el concepto a nodos y relaciones verificables.", "Dibuja trazos consistentes y flechas solo con direcci\xF3n significativa.", "Usa etiquetas breves; diferencia el acento sem\xE1ntico del trazo principal."],
|
|
370
|
+
[{ key: "nodes", value: [2, 5], unit: "nodos por estado", adaptation: "Divide un sistema mayor en varios estados." }, { key: "stroke", value: [2, 4], unit: "px a 1080 de ancho", adaptation: "Escala con la salida y comprueba visibilidad en m\xF3vil." }],
|
|
371
|
+
["Contenido y relaciones confirmados por el guion.", "Texto editable y motor de dibujo o vector."],
|
|
372
|
+
["La relaci\xF3n a\xFAn es una hip\xF3tesis no etiquetada.", "La simplificaci\xF3n elimina informaci\xF3n necesaria."]
|
|
373
|
+
),
|
|
374
|
+
image(
|
|
375
|
+
"soft-3d",
|
|
376
|
+
"Volumen suave",
|
|
377
|
+
"Objetos simples con profundidad, luz amplia y materiales mates.",
|
|
378
|
+
"Dar forma a conceptos abstractos mediante met\xE1foras visuales sencillas.",
|
|
379
|
+
["Elige una met\xE1fora concreta y comprensible para el concepto.", "Modela pocas formas con bordes suaves y materiales mates.", "Usa una c\xE1mara estable, sombra de contacto y contraste legible."],
|
|
380
|
+
[{ key: "primary_objects", value: [1, 3], unit: "objetos", adaptation: "Prioriza una relaci\xF3n clara frente a decoraci\xF3n." }, { key: "material", value: "Mate, rugosidad media-alta", adaptation: "Si el objeto representa un producto real, respeta su material." }],
|
|
381
|
+
["Motor 3D o herramienta de generaci\xF3n autorizada.", "Met\xE1fora que no confunda con evidencia de un producto real."],
|
|
382
|
+
["Se necesita exactitud documental.", "La met\xE1fora introduce una promesa o caracter\xEDstica no confirmada."]
|
|
383
|
+
),
|
|
384
|
+
image(
|
|
385
|
+
"flat-vector",
|
|
386
|
+
"Vector limpio",
|
|
387
|
+
"Formas planas, contornos precisos y contraste sem\xE1ntico.",
|
|
388
|
+
"Explicar categor\xEDas o pasos con gr\xE1ficos claros y reutilizables.",
|
|
389
|
+
["Construye siluetas simples y consistentes.", "Limita cada panel a una acci\xF3n y un foco visual.", "Usa rellenos sem\xE1nticos de marca y texto editable separado."],
|
|
390
|
+
[{ key: "visual_roles", value: [2, 4], unit: "roles por panel", adaptation: "Los roles se asignan a tokens de marca; no fijan colores nuevos." }, { key: "detail", value: "Sin textura ni microdetalle decorativo", adaptation: "Conserva detalles que distinguen funciones o categor\xEDas." }],
|
|
391
|
+
["Motor vectorial o composici\xF3n con formas.", "Etiquetas y conceptos definidos."],
|
|
392
|
+
["La fotograf\xEDa del objeto real es necesaria para reconocerlo.", "Las diferencias importantes dependen de textura o acabado."]
|
|
393
|
+
),
|
|
394
|
+
expandedImage(
|
|
395
|
+
"cinematic-scene",
|
|
396
|
+
"Cine atmosf\xE9rico",
|
|
397
|
+
"Luz dirigida, profundidad y una escena que concentra la emoci\xF3n.",
|
|
398
|
+
"Dar contexto emocional a una acci\xF3n o decisi\xF3n del guion.",
|
|
399
|
+
["Define una acci\xF3n legible y el estado emocional que debe acompa\xF1ar.", "Comp\xF3n sujeto y entorno con una fuente de luz coherente y separaci\xF3n de planos.", "Conserva detalle en las sombras relevantes; ajusta el encuadre a cada formato sin copiar personajes del referente."],
|
|
400
|
+
[{ key: "focal_actions", value: 1, unit: "acci\xF3n principal", adaptation: "Puede ser un gesto, una espera o un descubrimiento; debe corresponder al beat." }, { key: "light", value: "Una fuente dominante y relleno seg\xFAn legibilidad", adaptation: "El contraste responde a la escena; no impongas \xE1mbar ni una LUT a material aut\xE9ntico." }, { key: "depth", value: "Sujeto, contexto y fondo diferenciados", adaptation: "Mant\xE9n enfocada la informaci\xF3n necesaria para comprender la acci\xF3n." }],
|
|
401
|
+
["Acci\xF3n y contexto definidos; recursos propios o autorizados.", "Referencia fiel cuando se represente una persona o producto real."],
|
|
402
|
+
["El dramatismo cambia el sentido de un testimonio.", "Una escena inventada podr\xEDa confundirse con evidencia del evento descrito."]
|
|
403
|
+
),
|
|
404
|
+
expandedImage(
|
|
405
|
+
"surreal-metaphor",
|
|
406
|
+
"Met\xE1fora surrealista",
|
|
407
|
+
"Una relaci\xF3n imposible convierte una idea abstracta en una escena legible.",
|
|
408
|
+
"Representar tensi\xF3n, escala o cambio mediante una met\xE1fora vinculada al guion.",
|
|
409
|
+
["Reduce el concepto a una relaci\xF3n visual comprensible.", "Introduce una sola contradicci\xF3n de escala, material o espacio en una escena coherente.", "Mant\xE9n luz y perspectiva consistentes; distingue la met\xE1fora de una afirmaci\xF3n factual."],
|
|
410
|
+
[{ key: "visual_contradictions", value: 1, unit: "relaci\xF3n central", adaptation: "Omite s\xEDmbolos adicionales que necesiten explicaci\xF3n o cambien el sentido." }, { key: "scale_anchor", value: "Un objeto o sujeto de escala reconocible", adaptation: "El ancla debe ayudar a entender la relaci\xF3n imposible sin obligar a incluir una persona." }, { key: "scene_continuity", value: "Luz y perspectiva compartidas", adaptation: "La imposibilidad es conceptual; evita errores de composici\xF3n que parezcan accidentales." }],
|
|
411
|
+
["Concepto claro y met\xE1fora adecuada al p\xFAblico.", "Composici\xF3n o generaci\xF3n autorizada de una escena nueva."],
|
|
412
|
+
["La imagen puede sugerir una capacidad real o resultado no demostrado.", "El contenido requiere mostrar hechos con exactitud documental."]
|
|
413
|
+
),
|
|
414
|
+
expandedImage(
|
|
415
|
+
"painterly-story",
|
|
416
|
+
"Pintura narrativa",
|
|
417
|
+
"Pincelada visible, masas de color y una historia contenida en la escena.",
|
|
418
|
+
"Acompa\xF1ar relatos, recuerdos o ideas con una interpretaci\xF3n pict\xF3rica.",
|
|
419
|
+
["Elige un momento y las relaciones entre sujeto y entorno.", "Construye luz y volumen con masas de color y bordes de distinta definici\xF3n.", "Reserva el detalle para el foco narrativo y deja pincelada visible sin convertirla en ruido."],
|
|
420
|
+
[{ key: "focal_groups", value: [1, 3], unit: "grupos visuales", adaptation: "Reduce personajes y objetos si se pierde la historia en m\xF3vil." }, { key: "brushwork", value: "Visible en formas y transiciones de luz", adaptation: "La escala de pincel responde a la salida; no ocultes rasgos relevantes de un objeto real." }, { key: "edge_hierarchy", value: "Foco definido y bordes secundarios m\xE1s suaves", adaptation: "Mant\xE9n reconocible aquello que sostiene la acci\xF3n." }],
|
|
421
|
+
["Escena original o materiales cuyo uso est\xE9 autorizado.", "Una funci\xF3n narrativa que se beneficie de interpretaci\xF3n visual."],
|
|
422
|
+
["Se necesita probar un detalle hist\xF3rico, m\xE9dico o t\xE9cnico.", "La estilizaci\xF3n cambia identidad o caracter\xEDsticas esenciales."]
|
|
423
|
+
),
|
|
424
|
+
expandedImage(
|
|
425
|
+
"vintage-engraving",
|
|
426
|
+
"Grabado editorial",
|
|
427
|
+
"Rayado, contornos y papel aparente con volumen de estampa hist\xF3rica.",
|
|
428
|
+
"Dar car\xE1cter y profundidad a una escena u objeto ilustrado.",
|
|
429
|
+
["Comp\xF3n una escena u objeto con silueta reconocible.", "Construye luces y sombras mediante l\xEDneas y rayado que sigan el volumen.", "Aplica una paleta corta y textura discreta; mant\xE9n etiquetas y datos verificables separados."],
|
|
430
|
+
[{ key: "ink_roles", value: [1, 3], unit: "roles de tinta aparentes", adaptation: "Asigna los roles a la marca; el aspecto hist\xF3rico no obliga a usar sepia." }, { key: "hatching", value: "Densidad mayor en sombra y menor en luz", adaptation: "Ampl\xEDa separaci\xF3n de l\xEDneas para evitar empastes o moir\xE9 en la salida final." }, { key: "paper_texture", value: "Secundaria respecto a contorno y volumen", adaptation: "Reduce textura donde compita con informaci\xF3n necesaria." }],
|
|
431
|
+
["Ilustraci\xF3n original o recurso autorizado con resoluci\xF3n suficiente.", "Verificaci\xF3n independiente si se atribuye una identidad o hecho hist\xF3rico."],
|
|
432
|
+
["La imagen sint\xE9tica podr\xEDa presentarse como documento de archivo aut\xE9ntico.", "El rayado oculta detalles que deben leerse con precisi\xF3n."]
|
|
433
|
+
),
|
|
434
|
+
expandedImage(
|
|
435
|
+
"riso-playful",
|
|
436
|
+
"Estampa artesanal",
|
|
437
|
+
"Formas expresivas, pocas tintas aparentes y textura de impresi\xF3n.",
|
|
438
|
+
"Aportar calidez y personalidad a una ilustraci\xF3n clara.",
|
|
439
|
+
["Reduce la escena a siluetas expresivas con un foco principal.", "Asigna pocos roles de color y construye cada forma con bordes ligeramente imperfectos.", "A\xF1ade textura de tinta y, si ayuda, un desregistro sutil; comprueba que el contorno conserve lectura."],
|
|
440
|
+
[{ key: "ink_roles", value: [2, 4], unit: "roles de color", adaptation: "Usa tokens compatibles con la marca y suficiente contraste; no heredes la paleta de la muestra." }, { key: "registration", value: "Ligera irregularidad opcional en bordes secundarios", adaptation: "No desplazar letras, logos o detalles que necesitan precisi\xF3n." }, { key: "texture", value: "Grano irregular visible sin cubrir la forma", adaptation: "La textura debe sobrevivir la compresi\xF3n sin convertir \xE1reas planas en ruido." }],
|
|
441
|
+
["Ilustraci\xF3n propia o autorizada y jerarqu\xEDa visual definida.", "Texto y logos exactos en capas independientes si se necesitan."],
|
|
442
|
+
["Se requiere una reproducci\xF3n fiel del acabado de un producto.", "El tono o la lectura exigen geometr\xEDa y contornos completamente precisos."]
|
|
443
|
+
),
|
|
444
|
+
expandedImage(
|
|
445
|
+
"sensory-macro",
|
|
446
|
+
"Detalle sensorial",
|
|
447
|
+
"Materia, luz pr\xF3xima y texturas que ocupan el encuadre.",
|
|
448
|
+
"Hacer visible una cualidad material relevante mediante un detalle cercano.",
|
|
449
|
+
["Elige una textura real o una materia relacionada con el beat.", "Acerca el encuadre y dirige la luz para revelar relieve, gotas, fibras o superficies.", "Mant\xE9n suficiente profundidad de foco para reconocer el detalle y recomp\xF3n para cada proporci\xF3n."],
|
|
450
|
+
[{ key: "material_share", value: [65, 95], unit: "% del encuadre", adaptation: "Deja contexto cuando haga falta reconocer la materia; no recortes un rasgo esencial." }, { key: "focus", value: "Plano de textura principal legible", adaptation: "La profundidad debe revelar la cualidad buscada, no disimular errores." }, { key: "light", value: "Rasante o especular seg\xFAn la superficie", adaptation: "Conserva el color y acabado aut\xE9nticos si la imagen representa un producto real." }],
|
|
451
|
+
["Materia o detalle definido y recursos propios o autorizados.", "Referencia exacta cuando se anuncie una propiedad del producto."],
|
|
452
|
+
["El detalle exagera calidad, composici\xF3n o resultados no demostrados.", "El encuadre impide identificar el objeto que el guion necesita mostrar."]
|
|
453
|
+
),
|
|
454
|
+
motion(
|
|
455
|
+
"quiet-reveal",
|
|
456
|
+
"Entrada sutil",
|
|
457
|
+
"Aparici\xF3n breve con desplazamiento corto y lectura estable.",
|
|
458
|
+
"Introducir informaci\xF3n sin competir con la voz o el sujeto.",
|
|
459
|
+
["Alinea la aparici\xF3n con la frase que presenta la informaci\xF3n.", "Combina opacidad de 0 a 1 con un desplazamiento corto.", "Mant\xE9n el estado final el tiempo que requiere la lectura; retira cuando deja de ser \xFAtil."],
|
|
460
|
+
[{ key: "entry_duration", value: [180, 320], unit: "ms", adaptation: "Ajusta a fps y velocidad de lectura." }, { key: "travel", value: [1, 3], unit: "% de altura", adaptation: "Reduce a cero cuando el sujeto o el fondo ya se mueven." }, { key: "easing", value: "ease-out sin rebote", adaptation: "La llegada debe estabilizarse antes de leer." }],
|
|
461
|
+
["Texto o gr\xE1fico en capa independiente.", "Ancla temporal en voz o acci\xF3n."],
|
|
462
|
+
["La informaci\xF3n ya est\xE1 visible y no necesita \xE9nfasis.", "La entrada retrasa una instrucci\xF3n urgente."]
|
|
463
|
+
),
|
|
464
|
+
motion(
|
|
465
|
+
"kinetic-type",
|
|
466
|
+
"Tipograf\xEDa r\xEDtmica",
|
|
467
|
+
"\xC9nfasis de palabras con ritmo ligado al habla.",
|
|
468
|
+
"Destacar una frase clave sin convertir todas las palabras en est\xEDmulos.",
|
|
469
|
+
["Selecciona una frase y las pocas palabras que realmente cambian su sentido.", "Sincroniza sus estados con la voz verificada.", "Usa un \xFAnico mecanismo de \xE9nfasis y conserva contexto legible."],
|
|
470
|
+
[{ key: "emphasis_scale", value: [1.04, 1.12], unit: "factor", adaptation: "Mant\xE9n la palabra dentro del \xE1rea segura." }, { key: "emphasis_duration", value: [120, 240], unit: "ms", adaptation: "Ajusta al fonema/frase real; no inventes alineaci\xF3n desde un resumen ASR." }, { key: "emphasis_count", value: [1, 3], unit: "palabras por frase", adaptation: "Puede ser cero si el \xE9nfasis no aporta sentido." }],
|
|
471
|
+
["Texto confirmado y fuente disponible.", "Alineaci\xF3n temporal real de la frase o revisi\xF3n manual."],
|
|
472
|
+
["El texto contiene demasiadas palabras para leer.", "El \xE9nfasis cambia el sentido de una cita o declaraci\xF3n."]
|
|
473
|
+
),
|
|
474
|
+
motion(
|
|
475
|
+
"spring-callout",
|
|
476
|
+
"\xC9nfasis el\xE1stico",
|
|
477
|
+
"Una etiqueta llega con un rebote corto y controlado.",
|
|
478
|
+
"Se\xF1alar un detalle puntual de forma expresiva.",
|
|
479
|
+
["Ancla la etiqueta al detalle que se est\xE1 explicando.", "Lleva escala y posici\xF3n al destino con un \xFAnico sobrepaso leve.", "Estabiliza la etiqueta; no mantengas vibraci\xF3n durante la lectura."],
|
|
480
|
+
[{ key: "overshoot", value: [2, 5], unit: "%", adaptation: "Reduce el rebote en marcas sobrias o contenido sensible." }, { key: "settle_duration", value: [250, 450], unit: "ms", adaptation: "Verifica el movimiento con playback al fps final." }],
|
|
481
|
+
["Elemento o punto que justifica el \xE9nfasis.", "Motor con keyframes o resorte controlable."],
|
|
482
|
+
["Compite con una acci\xF3n importante.", "El tono requiere calma o la audiencia necesita movimiento reducido."]
|
|
483
|
+
),
|
|
484
|
+
motion(
|
|
485
|
+
"diagram-build",
|
|
486
|
+
"Diagrama progresivo",
|
|
487
|
+
"Las relaciones aparecen en el orden de la explicaci\xF3n.",
|
|
488
|
+
"Permitir entender un sistema una relaci\xF3n a la vez.",
|
|
489
|
+
["Establece el primer nodo como estado legible.", "Revela cada relaci\xF3n despu\xE9s de mencionar su causa o funci\xF3n.", "Mant\xE9n el conjunto al final para que pueda revisarse."],
|
|
490
|
+
[{ key: "segment_reveal", value: [250, 500], unit: "ms", adaptation: "Usa voz o lectura como ancla; evita un ritmo autom\xE1tico uniforme." }, { key: "active_relation", value: 1, unit: "relaci\xF3n a la vez", adaptation: "Agrupa solo relaciones que se explican conjuntamente." }],
|
|
491
|
+
["Diagrama v\xE1lido con nodos y trazos separados.", "Orden de explicaci\xF3n definido."],
|
|
492
|
+
["La progresi\xF3n implicar\xEDa causalidad no demostrada.", "No hay tiempo suficiente para leer el estado final."]
|
|
493
|
+
),
|
|
494
|
+
motion(
|
|
495
|
+
"match-cut",
|
|
496
|
+
"Corte por correspondencia",
|
|
497
|
+
"Dos planos se conectan por forma, posici\xF3n o acci\xF3n.",
|
|
498
|
+
"Dar continuidad a un cambio de contexto o reforzar una comparaci\xF3n.",
|
|
499
|
+
["Encuentra una correspondencia real entre dos planos autorizados.", "Alinea el foco y el momento de acci\xF3n sin deformar el sujeto.", "Prueba primero un corte directo; comprueba ambos lados a velocidad real."],
|
|
500
|
+
[{ key: "transition_overlap", value: 0, unit: "frames de partida", adaptation: "A\xF1ade solape solo si tiene funci\xF3n editorial; no hace falta fundido." }, { key: "alignment", value: "Punto de atenci\xF3n, forma o direcci\xF3n de acci\xF3n", adaptation: "Elige una correspondencia; no fuerces todas a la vez." }],
|
|
501
|
+
["Dos planos con correspondencia verificable.", "Material suficiente alrededor del punto de corte."],
|
|
502
|
+
["La comparaci\xF3n inventa continuidad factual.", "Los recortes necesarios da\xF1an el contenido."]
|
|
503
|
+
),
|
|
504
|
+
motion(
|
|
505
|
+
"parallax-depth",
|
|
506
|
+
"Profundidad por capas",
|
|
507
|
+
"Desplazamiento suave entre planos de una imagen.",
|
|
508
|
+
"A\xF1adir recorrido visual a una imagen est\xE1tica cuando aporta atenci\xF3n.",
|
|
509
|
+
["Separa sujeto y fondo con bordes limpios.", "Extiende el fondo solo donde sea necesario y est\xE9 autorizado.", "Mueve las capas con velocidades ligeramente distintas sin revelar huecos."],
|
|
510
|
+
[{ key: "layer_travel", value: [1, 3], unit: "% de ancho", adaptation: "Reduce desplazamiento seg\xFAn recorte, bordes y \xE1rea segura." }, { key: "shot_duration", value: [2, 5], unit: "s de partida", adaptation: "Depende del beat y la lectura; una imagen puede permanecer inm\xF3vil." }],
|
|
511
|
+
["Imagen con resoluci\xF3n y capas separables.", "Fondo disponible o reconstrucci\xF3n autorizada."],
|
|
512
|
+
["Se deforman rostros, logos o detalles del producto.", "El movimiento crea una perspectiva imposible o distrae."]
|
|
513
|
+
),
|
|
514
|
+
motion(
|
|
515
|
+
"workflow-scene",
|
|
516
|
+
"Flujo de trabajo",
|
|
517
|
+
"Una escena completa conecta solicitud, agente y resultado.",
|
|
518
|
+
"Explicar c\xF3mo avanza una tarea entre actores y estados mediante una secuencia visible.",
|
|
519
|
+
["Define entradas, responsables, decisiones y resultados que correspondan al proceso real.", "Comp\xF3n la escena en capas editables de JavaScript, HTML/SVG o el motor disponible; separa nodos, conexiones y etiquetas.", "Representa una solicitud recorriendo el flujo; cambia cada estado cuando la narraci\xF3n explica su funci\xF3n.", "Sost\xE9n el resultado y comprueba que el recorrido sea legible en el formato final; una simulaci\xF3n no demuestra una automatizaci\xF3n ejecutada."],
|
|
520
|
+
[{ key: "visible_stages", value: [3, 5], unit: "estados por escena", adaptation: "Divide procesos mayores y conserva bifurcaciones necesarias; no inventes que todo flujo termina en un correo." }, { key: "state_hold", value: [800, 1800], unit: "ms de partida", adaptation: "Ajusta al contenido, voz, lectura y fps; el ritmo de la demo no mide la duraci\xF3n real del proceso." }, { key: "active_transition", value: 1, unit: "paso de atenci\xF3n", adaptation: "Permite ramas paralelas solo cuando el guion las explica; mant\xE9n etiquetas y valores exactos." }],
|
|
521
|
+
["Proceso verificado y etiquetas editables.", "Herramienta de composici\xF3n y render disponible en la sesi\xF3n; el cat\xE1logo no instala ni acredita un motor."],
|
|
522
|
+
["La simplificaci\xF3n inventa capacidades o resultados de un sistema.", "El proceso exige detalles que no se pueden leer en la duraci\xF3n disponible."]
|
|
523
|
+
),
|
|
524
|
+
motion(
|
|
525
|
+
"product-assembly",
|
|
526
|
+
"Ensamble de producto",
|
|
527
|
+
"Un producto se separa en piezas y vuelve a formar una unidad.",
|
|
528
|
+
"Explicar construcci\xF3n, relaci\xF3n entre partes o un detalle de dise\xF1o en una escena continua.",
|
|
529
|
+
["Identifica las piezas visibles con material del producto real o define un objeto ficticio claramente ilustrativo.", "Prepara capas 2D o geometr\xEDa 3D seg\xFAn los recursos y el motor de JavaScript disponible.", "Separa piezas manteniendo sus relaciones espaciales y re\xFAne el conjunto con un orden comprensible.", "Revisa encajes, oclusiones, r\xF3tulos y estado final; no presentes un despiece imaginado como ingenier\xEDa real."],
|
|
530
|
+
[{ key: "visible_parts", value: [3, 6], unit: "piezas principales", adaptation: "Incluye solo piezas justificadas por la explicaci\xF3n y evidencia; agrupa el resto." }, { key: "separation", value: [5, 16], unit: "% del encuadre", adaptation: "Adapta distancias al producto y evita que las partes crucen \xE1reas seguras o pierdan su relaci\xF3n." }, { key: "settle_duration", value: [500, 900], unit: "ms de partida", adaptation: "Ajusta al tama\xF1o y al relato; comprueba la lectura del conjunto a fps final." }],
|
|
531
|
+
["Piezas autorizadas o modelo original y referencias del producto cuando se afirmen caracter\xEDsticas reales.", "Motor local verificado para las transformaciones 2D/3D elegidas."],
|
|
532
|
+
["No se conoce la construcci\xF3n real y el despiece podr\xEDa inducir a error.", "El movimiento de piezas oculta medidas, etiquetas o detalles necesarios."]
|
|
533
|
+
),
|
|
534
|
+
motion(
|
|
535
|
+
"interface-story",
|
|
536
|
+
"Interfaz en acci\xF3n",
|
|
537
|
+
"Una miniapp muestra una acci\xF3n y el cambio de estado que produce.",
|
|
538
|
+
"Explicar una interacci\xF3n mediante una interfaz breve con texto y estados controlables.",
|
|
539
|
+
["Define la tarea, el estado inicial y el resultado verificable; distingue demo conceptual de captura real.", "Construye una interfaz propia con texto, componentes y estados editables en JavaScript.", "Anima una acci\xF3n clara y su respuesta, manteniendo continuidad espacial entre ambos estados.", "Deja leer el resultado y verifica que la transici\xF3n no invente velocidad, m\xE9tricas o funciones del producto."],
|
|
540
|
+
[{ key: "primary_actions", value: 1, unit: "acci\xF3n por escena", adaptation: "Divide tareas m\xE1s largas en escenas; evita automatismos que el producto no tiene." }, { key: "state_transition", value: [250, 600], unit: "ms de partida", adaptation: "Ajusta a legibilidad y al ritmo de la explicaci\xF3n; no representa latencia real." }, { key: "result_hold", value: [1, 3], unit: "s de partida", adaptation: "Aumenta tiempo o reduce contenido cuando el estado final requiera lectura." }],
|
|
541
|
+
["Flujo de interacci\xF3n definido y contenido exacto revisado.", "Componentes y motor de composici\xF3n disponibles; uso autorizado de una marca si la UI la representa."],
|
|
542
|
+
["La simulaci\xF3n se puede confundir con prueba real de funcionamiento o rendimiento.", "La simplificaci\xF3n altera el significado de una acci\xF3n importante."]
|
|
543
|
+
),
|
|
544
|
+
composition(
|
|
545
|
+
[
|
|
546
|
+
"editorial-atlas",
|
|
547
|
+
"\xCDndice editorial",
|
|
548
|
+
"Una colecci\xF3n organizada se abre para descubrir un detalle.",
|
|
549
|
+
"Presentar una colecci\xF3n y mantener su \xEDndice visible mientras se destaca un elemento.",
|
|
550
|
+
["Define los elementos reales de la colecci\xF3n y una clasificaci\xF3n que ayude al relato.", "Comp\xF3n tarjetas en una ret\xEDcula editable de JavaScript con n\xFAmeros y t\xEDtulos separados de sus im\xE1genes.", "Revela grupos conservando posiciones; abre una tarjeta protagonista mientras el \xEDndice mantiene la orientaci\xF3n.", "Sost\xE9n el detalle y regresa al conjunto solo si ayuda a comparar; no repitas im\xE1genes para aparentar una colecci\xF3n mayor."],
|
|
551
|
+
[{ key: "visible_cards", value: [3, 9], unit: "tarjetas por estado", adaptation: "Limita el grupo a recursos distintos disponibles; pagina o agrupa si las etiquetas no se leen." }, { key: "active_card", value: 1, unit: "foco principal", adaptation: "Mant\xE9n las otras tarjetas como contexto, sin animar todos los focos a la vez." }, { key: "detail_hold", value: [1, 3], unit: "s de partida", adaptation: "Ajusta a voz y lectura; no representa un tiempo medido del referente." }],
|
|
552
|
+
["Colecci\xF3n de im\xE1genes o clips autorizados, t\xEDtulos confirmados y criterio de agrupaci\xF3n.", "Motor de composici\xF3n disponible para ret\xEDcula, m\xE1scaras y texto editable."],
|
|
553
|
+
["La ret\xEDcula vuelve ilegible la informaci\xF3n esencial.", "La colecci\xF3n o sus categor\xEDas sugieren variedad, ranking o resultados no acreditados."]
|
|
554
|
+
],
|
|
555
|
+
{ title: "Referencia de composici\xF3n: orate.x", url: "https://www.instagram.com/p/DaQCU16scia/" },
|
|
556
|
+
["1:1: organiza una ret\xEDcula equilibrada y reserva una franja estable para el \xEDndice.", "9:16: usa una o dos columnas, agrupa por estados y ampl\xEDa el foco sin miniaturizar toda la colecci\xF3n.", "16:9: separa \xEDndice y detalle en zonas laterales; redistribuye tarjetas sin estirar im\xE1genes."]
|
|
557
|
+
),
|
|
558
|
+
composition(
|
|
559
|
+
[
|
|
560
|
+
"object-headline",
|
|
561
|
+
"Objeto protagonista",
|
|
562
|
+
"Un objeto atraviesa un titular y organiza una portada editorial.",
|
|
563
|
+
"Dar protagonismo a un objeto manteniendo su identidad y una jerarqu\xEDa tipogr\xE1fica clara.",
|
|
564
|
+
["Parte de una imagen fiel del objeto o identifica claramente un objeto ficticio ilustrativo.", "Separa el sujeto con alfa limpio y comp\xF3n el titular detr\xE1s, conservando las letras necesarias para comprenderlo.", "A\xF1ade pocas fichas editables en JavaScript; cambia el \xE9nfasis entre titular, silueta y detalle sin inventar un despiece o giro 3D.", "Comprueba bordes, sombra y estado de lectura; si no hay recorte fiable, utiliza la imagen \xEDntegra dentro de un marco deliberado."],
|
|
565
|
+
[{ key: "subject_height", value: [45, 75], unit: "% del encuadre", adaptation: "Respeta proporciones, detalles y \xE1rea segura del producto; no lo estires para llenar la portada." }, { key: "headline_lines", value: [1, 3], unit: "l\xEDneas", adaptation: "Recomp\xF3n con la fuente de marca y evita oclusiones que cambien el significado." }, { key: "support_labels", value: [0, 3], unit: "fichas", adaptation: "Incluye \xFAnicamente datos confirmados; omite decoraci\xF3n que parezca una especificaci\xF3n." }],
|
|
566
|
+
["Imagen autorizada del objeto y recorte limpio o alternativa de encuadre completo.", "Titular confirmado, fuente disponible y capas editables."],
|
|
567
|
+
["La oclusi\xF3n oculta caracter\xEDsticas relevantes o impide leer el titular.", "La puesta en escena sugiere propiedades, fabricante o resultados no confirmados."]
|
|
568
|
+
],
|
|
569
|
+
{ title: "Referencia de composici\xF3n: francogalvanads", url: "https://www.instagram.com/p/Ddb0qB1ArrV/" },
|
|
570
|
+
["1:1: centra la relaci\xF3n entre silueta y titular; coloca fichas en m\xE1rgenes despejados.", "9:16: reparte el titular en altura y lleva las fichas debajo del objeto, conservando la silueta completa.", "16:9: ampl\xEDa la jerarqu\xEDa horizontal con columnas laterales; no agrandes el objeto a costa de cortar sus detalles."]
|
|
571
|
+
),
|
|
572
|
+
composition(
|
|
573
|
+
[
|
|
574
|
+
"visual-correspondence",
|
|
575
|
+
"De idea a imagen",
|
|
576
|
+
"Dos representaciones conservan su lugar para poder compararse.",
|
|
577
|
+
"Explicar una relaci\xF3n entre un sujeto y su interpretaci\xF3n sin perder la referencia original.",
|
|
578
|
+
["Selecciona dos representaciones del mismo sujeto y declara qu\xE9 relaci\xF3n est\xE1 realmente sustentada.", "Mant\xE9n visible la fuente mientras introduces la interpretaci\xF3n en un panel separado y de escala comparable.", "Comp\xF3n marcos, un detalle activo y anotaciones exactas en JavaScript; las conexiones describen lo visible, no una equivalencia t\xE9cnica imaginada.", "Sost\xE9n ambos paneles para comparar; distingue estudio visual, propuesta y documentaci\xF3n real."],
|
|
579
|
+
[{ key: "representations", value: 2, unit: "paneles", adaptation: "No sustituir la fuente durante la comparaci\xF3n; separa un tercer argumento en otro estado." }, { key: "active_detail", value: 1, unit: "correspondencia por estado", adaptation: "Apunta a un detalle comprobable en ambos recursos, sin convertir similitud en causalidad." }, { key: "comparison_hold", value: [2, 4], unit: "s de partida", adaptation: "Extiende la lectura o reduce anotaciones seg\xFAn voz y resoluci\xF3n." }],
|
|
580
|
+
["Dos recursos autorizados con correspondencia revisada y escala comprensible.", "Anotaciones verificables en capas editables; motor disponible para la comparaci\xF3n."],
|
|
581
|
+
["La interpretaci\xF3n se presenta como plano, diagn\xF3stico o mejora t\xE9cnicamente demostrada sin evidencia.", "Los recursos no corresponden al mismo sujeto y el montaje oculta esa diferencia."]
|
|
582
|
+
],
|
|
583
|
+
{ title: "Referencia de composici\xF3n: valen.bece", url: "https://www.instagram.com/p/DcuJnb6JUUK/" },
|
|
584
|
+
["1:1: usa paneles equilibrados y una franja de anotaciones que no tape la correspondencia.", "9:16: apila origen e interpretaci\xF3n manteniendo ambos visibles; acorta las notas antes de reducir el sujeto.", "16:9: coloca los paneles lado a lado y reserva el centro o margen inferior para una relaci\xF3n por estado."]
|
|
585
|
+
),
|
|
586
|
+
composition(
|
|
587
|
+
[
|
|
588
|
+
"context-triptych",
|
|
589
|
+
"Tres perspectivas",
|
|
590
|
+
"Tres ventanas conectan un tema desde contextos complementarios.",
|
|
591
|
+
"Mostrar perspectivas relacionadas de forma simult\xE1nea, con un foco que gu\xEDa la lectura.",
|
|
592
|
+
["Elige tres contextos complementarios y confirma qu\xE9 v\xEDnculo puede afirmar el guion.", "Comp\xF3n tres ventanas con m\xE1scaras y r\xF3tulos independientes; respeta el formato de apoyo elegido y los recursos autorizados.", "Si hay clips, revisa sus acciones y da protagonismo a una ventana sin deformar las otras; las entradas y cambios de foco son decisiones nuevas de edici\xF3n.", "Deja leer el conjunto sin afirmar continuidad de producci\xF3n, sincron\xEDa o causalidad que las fuentes no demuestren."],
|
|
593
|
+
[{ key: "windows", value: 3, unit: "perspectivas", adaptation: "Cada ventana debe aportar informaci\xF3n distinta; evita duplicar un clip para llenar la composici\xF3n." }, { key: "active_window", value: 1, unit: "foco de lectura", adaptation: "Los otros paneles pueden permanecer en reposo; no es necesario reproducir tres acciones intensas simult\xE1neamente." }, { key: "focus_hold", value: [1, 3], unit: "s de partida", adaptation: "Ancla el foco a voz o acci\xF3n revisada y preserva tiempo para comprender el conjunto." }],
|
|
594
|
+
["Tres recursos autorizados con relaci\xF3n editorial expl\xEDcita; clips reales disponibles cuando se requiera footage.", "Motor disponible para m\xE1scaras, recortes y control independiente de reproducci\xF3n."],
|
|
595
|
+
["La simultaneidad inventa una relaci\xF3n causal o un proceso real no documentado.", "Los tres paneles ocultan acciones, rostros o detalles necesarios por falta de espacio."]
|
|
596
|
+
],
|
|
597
|
+
{ title: "Referencia de composici\xF3n: metromedia.house", url: "https://www.instagram.com/p/Dasq8-6tbt9/" },
|
|
598
|
+
["1:1: distribuye tres ventanas con un foco mayor y dos apoyos; conserva lectura en cada recorte.", "9:16: apila tres franjas o un foco y dos apoyos; ajusta cada encuadre al sujeto sin estirar los clips.", "16:9: usa tres columnas legibles; ampliar la activa redistribuye las m\xE1scaras y conserva las proporciones del material."]
|
|
599
|
+
),
|
|
600
|
+
composition(
|
|
601
|
+
[
|
|
602
|
+
"process-filmstrip",
|
|
603
|
+
"Historia de un proceso",
|
|
604
|
+
"Una tarjeta de la secuencia se convierte en la escena principal.",
|
|
605
|
+
"Recorrer fases verificadas de una historia conservando una referencia de progreso.",
|
|
606
|
+
["Define las fases y su orden; utiliza nombres de etapa cuando no existan fechas confirmadas.", "Comp\xF3n un riel de tarjetas y una marca de fase en JavaScript, separados del material visual.", "Desplaza el foco hacia una tarjeta y ampl\xEDa su marco hasta la escena; conserva una ancla de progreso para orientar al espectador.", "Sost\xE9n la etapa y contin\xFAa solo cuando el relato lo justifique; identifica los resultados conceptuales y no los presentes como transformaci\xF3n documentada."],
|
|
607
|
+
[{ key: "stages", value: [3, 5], unit: "etapas por secuencia", adaptation: "El orden procede del guion y la evidencia, no de una cronolog\xEDa inventada." }, { key: "active_frame", value: 1, unit: "tarjeta protagonista", adaptation: "La expansi\xF3n cambia el encuadre, no las proporciones ni el contenido del recurso." }, { key: "stage_hold", value: [1, 3], unit: "s de partida", adaptation: "Ajusta a la explicaci\xF3n y deja leer el nombre de cada fase." }],
|
|
608
|
+
["Recursos autorizados por etapa, orden confirmado y distinci\xF3n entre estado real y propuesta.", "Motor de composici\xF3n disponible para riel, m\xE1scara y r\xF3tulos editables."],
|
|
609
|
+
["La secuencia inventa fechas o presenta im\xE1genes sint\xE9ticas como evidencia de un resultado real.", "El encuadre expandido pierde el detalle que explica la etapa."]
|
|
610
|
+
],
|
|
611
|
+
{ title: "Referencia de composici\xF3n: metromedia.house", url: "https://www.instagram.com/p/Dasq8-6tbt9/" },
|
|
612
|
+
["1:1: sit\xFAa el riel junto al borde inferior y reserva el centro para expandir la tarjeta activa.", "9:16: reorganiza el riel en columna o una franja breve; muestra las etapas pr\xF3ximas sin miniaturizar toda la historia.", "16:9: recorre las tarjetas horizontalmente y ampl\xEDa la activa dentro del \xE1rea segura, manteniendo un indicador de fase."]
|
|
613
|
+
),
|
|
614
|
+
composition(
|
|
615
|
+
[
|
|
616
|
+
"tactile-notes",
|
|
617
|
+
"Mesa de ideas",
|
|
618
|
+
"Una superficie estable re\xFAne notas y detalles por estados.",
|
|
619
|
+
"Construir una idea mediante un espacio de trabajo tangible con continuidad visual.",
|
|
620
|
+
["Define una superficie estable y los elementos que realmente forman parte de la explicaci\xF3n.", "Prepara el fondo y las capas independientes; si solo hay una foto plana, limita la animaci\xF3n a nuevas notas y trazos de c\xF3digo.", "Introduce fichas, anotaciones y marcas por estados con posiciones y escala consistentes, sin simular movimiento independiente de objetos incrustados.", "Sost\xE9n la idea completa; describe el tratamiento como composici\xF3n por estados salvo que una captura stop motion est\xE9 corroborada."],
|
|
621
|
+
[{ key: "note_layers", value: [2, 5], unit: "capas editables", adaptation: "Reduce las notas si compiten con el sujeto o requieren letra peque\xF1a." }, { key: "surface", value: "Encuadre y referencias espaciales estables", adaptation: "Conserva perspectiva, sombras y escala coherentes; no impongas madera o papel si contradicen el contenido." }, { key: "state_hold", value: [800, 1800], unit: "ms de partida", adaptation: "Decide la cadencia seg\xFAn lectura y voz; no es una medici\xF3n del referente ni prueba de captura cuadro a cuadro." }],
|
|
622
|
+
["Superficie y recursos propios o autorizados; capas separadas para cualquier elemento que cambie de posici\xF3n.", "Texto confirmado, fuente disponible y motor de composici\xF3n para notas y estados."],
|
|
623
|
+
["La animaci\xF3n fingir\xEDa que un objeto de una imagen plana puede moverse por separado.", "Las texturas, notas o capas ocultan informaci\xF3n esencial o simulan documentos aut\xE9nticos."]
|
|
624
|
+
],
|
|
625
|
+
{ title: "Referencia de composici\xF3n: anatta.ha", url: "https://www.instagram.com/p/DYV-3gEty9u/" },
|
|
626
|
+
["1:1: agrupa notas alrededor de un foco estable y deja reposar el estado completo.", "9:16: recorre la idea de arriba abajo con pocas notas por estado; recomp\xF3n la mesa sin encoger toda la escena.", "16:9: organiza zonas de material y anotaciones con una secuencia clara; conserva escala y perspectiva al redistribuir capas."]
|
|
627
|
+
),
|
|
628
|
+
composition(
|
|
629
|
+
[
|
|
630
|
+
"subject-depth",
|
|
631
|
+
"Sujeto en primer plano",
|
|
632
|
+
"Una persona conserva protagonismo mientras el t\xEDtulo ocupa un plano detr\xE1s.",
|
|
633
|
+
"Dar jerarqu\xEDa humana al mensaje mediante profundidad y oclusi\xF3n controladas.",
|
|
634
|
+
["Parte de un retrato o clip autorizado y un titular breve que corresponda al mensaje.", "Separa sujeto, t\xEDtulo y fondo; al usar v\xEDdeo, revisa la m\xE1scara durante toda la acci\xF3n, incluidos pelo, manos y desenfoque.", "Ubica el t\xEDtulo detr\xE1s del sujeto con suficiente texto libre para reconocerlo; anima las capas editables sin reconstruir rasgos ni inventar movimiento del cuerpo.", "Sost\xE9n una lectura clara y reduce la oclusi\xF3n o usa un panel adyacente si el recorte pierde calidad."],
|
|
635
|
+
[{ key: "depth_planes", value: 3, unit: "planos", adaptation: "Fondo, t\xEDtulo y sujeto; no a\xF1ade perspectiva o geometr\xEDa real que no existe en la fuente." }, { key: "headline_words", value: [2, 6], unit: "palabras", adaptation: "Adapta tama\xF1o y saltos a la marca y evita tapar las letras que distinguen el mensaje." }, { key: "subject_share", value: [40, 70], unit: "% de altura", adaptation: "Conserva rostro, gestos y zonas seguras; la separaci\xF3n depende del recurso real." }],
|
|
636
|
+
["Retrato propio o autorizado y m\xE1scara fiable por frame si hay movimiento.", "Motor disponible para capas, m\xE1scara y texto editable con las fuentes de marca."],
|
|
637
|
+
["La oclusi\xF3n impide leer el t\xEDtulo o una m\xE1scara deficiente altera la identidad.", "El recorte elimina una acci\xF3n esencial, atribuye palabras al sujeto o modifica un testimonio."]
|
|
638
|
+
],
|
|
639
|
+
{ title: "Referencia de oclusi\xF3n entre sujeto y titular", url: "https://www.instagram.com/p/DZJQm-MgeFL/" },
|
|
640
|
+
["1:1: centra el retrato y reparte el titular en dos l\xEDneas detr\xE1s de los hombros; conserva letras de anclaje visibles.", "9:16: apila titular y figura en profundidad, con rostro libre y m\xE1rgenes para subt\xEDtulos; no ampl\xEDes el cuerpo para llenar vac\xEDos.", "16:9: desplaza el sujeto a un tercio y prolonga el t\xEDtulo detr\xE1s con un bloque legible lateral; recomp\xF3n la m\xE1scara sin estirar el retrato."]
|
|
641
|
+
),
|
|
642
|
+
composition(
|
|
643
|
+
[
|
|
644
|
+
"route-journal",
|
|
645
|
+
"Diario de ruta",
|
|
646
|
+
"Un lugar y un recorrido esquem\xE1tico se conectan con notas breves.",
|
|
647
|
+
"Orientar una historia espacial sin confundir un mapa editorial con navegaci\xF3n precisa.",
|
|
648
|
+
["Confirma lugares, orden y relaci\xF3n del recorrido; distingue coordenadas verificadas de una ruta conceptual.", "Combina material autorizado del lugar con un trazado editable y puntos de parada; la referencia apoya el contexto de lugar, no demuestra un mapa animado.", "Revela el recorrido por etapas con un marcador y una nota activa, sin inventar calles, distancias, tiempos ni geolocalizaci\xF3n.", "Mant\xE9n el lugar visible al mostrar el esquema; rotula como ilustrativo cualquier trazado no geogr\xE1fico y revisa permisos de mapas utilizados."],
|
|
649
|
+
[{ key: "stops", value: [2, 4], unit: "paradas", adaptation: "El orden viene del relato; menos puntos favorecen orientaci\xF3n y lectura en m\xF3vil." }, { key: "active_note", value: 1, unit: "nota por estado", adaptation: "Solo incluye nombres y datos confirmados; no deduce ubicaci\xF3n a partir de una imagen sint\xE9tica." }, { key: "route_hold", value: [1, 3], unit: "s de partida", adaptation: "La duraci\xF3n permite leer, no representa tiempo de viaje ni velocidad real." }],
|
|
650
|
+
["Lugares y recorrido confirmados, o identificaci\xF3n expl\xEDcita del esquema como ilustrativo.", "Material de lugar autorizado y motor disponible para trazo, marcador y etiquetas; mapa licenciado si se requiere precisi\xF3n."],
|
|
651
|
+
["La escena necesita navegaci\xF3n o distancias exactas y no hay datos fiables.", "Revela ubicaciones privadas o presenta un entorno generado como registro de un viaje real."]
|
|
652
|
+
],
|
|
653
|
+
{ title: "Apoyo visual de lugar; la ruta es una adaptaci\xF3n original", url: "https://www.instagram.com/p/Dasq8-6tbt9/" },
|
|
654
|
+
["1:1: reserva un panel al lugar y otro al esquema con una nota activa; evita un mapa lleno de etiquetas.", "9:16: coloca el lugar arriba y el recorrido esquem\xE1tico debajo, conectados por la misma etiqueta; redistribuye los nodos verticalmente sin alterar una geograf\xEDa real.", "16:9: combina una ventana amplia del lugar con el esquema lateral y notas cortas; conserva la escala de los mapas reales y usa r\xF3tulos externos."]
|
|
655
|
+
),
|
|
656
|
+
composition(
|
|
657
|
+
[
|
|
658
|
+
"focus-reveal",
|
|
659
|
+
"Del detalle al conjunto",
|
|
660
|
+
"Un detalle abre el encuadre hasta mostrar su contexto.",
|
|
661
|
+
"Conectar una textura o funci\xF3n con el objeto al que realmente pertenece.",
|
|
662
|
+
["Elige un recurso autorizado de suficiente resoluci\xF3n que contenga tanto el detalle como el objeto completo.", "Define un recorte inicial y un encuadre final del mismo recurso; los planos macro y contextuales de la referencia apoyan la escala, no corroboran un revelado continuo del mismo objeto.", "Ampl\xEDa la ventana de encuadre o reduce el acercamiento sin generar partes ocultas; si son tomas distintas, usa un corte expl\xEDcito y verifica identidad y continuidad.", "Sost\xE9n el conjunto con una etiqueta editable que explique el detalle sin a\xF1adir propiedades no demostradas."],
|
|
663
|
+
[{ key: "start_crop", value: [25, 45], unit: "% del ancho de la fuente", adaptation: "Limita el acercamiento por resoluci\xF3n real y por la informaci\xF3n que se debe reconocer." }, { key: "reveal_duration", value: [800, 1600], unit: "ms de partida", adaptation: "Ajusta a narraci\xF3n y movimiento ya presentes; con movimiento reducido muestra detalle y conjunto en estados." }, { key: "end_context", value: "Objeto reconocible y margen contextual", adaptation: "No exige revelar una cara posterior o entorno inexistente en el material." }],
|
|
664
|
+
["Mismo objeto y detalle verificados, con resoluci\xF3n suficiente o clip continuo revisado.", "Motor disponible para recorte y escala; cualquier reconstrucci\xF3n exige autorizaci\xF3n y identificaci\xF3n aparte."],
|
|
665
|
+
["El detalle y el conjunto pertenecen a objetos distintos o la ampliaci\xF3n inventa partes.", "El reencuadre pierde nitidez, contradice el material real o oculta una advertencia del producto."]
|
|
666
|
+
],
|
|
667
|
+
{ title: "Apoyo de escala macro y contextual; revelado continuo propuesto", url: "https://www.instagram.com/p/DbZDnSpyilk/" },
|
|
668
|
+
["1:1: abre desde un detalle desplazado al centro hacia el objeto completo, conservando un \xE1rea breve para la etiqueta.", "9:16: usa un recorrido de encuadre vertical solo si la fuente lo permite; termina con objeto y explicaci\xF3n apilados sin recortar su funci\xF3n.", "16:9: expande el recorte hacia un plano contextual y sit\xFAa la etiqueta en el espacio libre lateral; no estires una foto vertical."]
|
|
669
|
+
),
|
|
670
|
+
composition(
|
|
671
|
+
[
|
|
672
|
+
"transformation-wipe",
|
|
673
|
+
"Cambio en paralelo",
|
|
674
|
+
"Dos estados alineados comparten un encuadre y un barrido de comparaci\xF3n.",
|
|
675
|
+
"Hacer visible una diferencia verificable manteniendo constantes las referencias espaciales.",
|
|
676
|
+
["Confirma qu\xE9 comparan los dos estados y alinea encuadre, escala y puntos reconocibles.", "Usa etiquetas antes/despu\xE9s solo si existe evidencia temporal; para variantes o demos utiliza propuesta A/B.", "Mueve una m\xE1scara sobre im\xE1genes alineadas o separa paneles si la alineaci\xF3n no es fiable; el barrido es una adaptaci\xF3n nueva de una comparaci\xF3n observada, no un efecto medido.", "Sost\xE9n ambos estados con etiquetas legibles y conserva diferencias aut\xE9nticas sin retocar selectivamente para exagerar un resultado."],
|
|
677
|
+
[{ key: "states", value: 2, unit: "estados comparables", adaptation: "La relaci\xF3n debe estar documentada o identificada como variante conceptual." }, { key: "wipe_range", value: [10, 90], unit: "% del encuadre", adaptation: "Deja contexto para identificar ambos estados; usa corte o paneles est\xE1ticos con movimiento reducido." }, { key: "comparison_hold", value: [1, 3], unit: "s de partida", adaptation: "Ajusta al detalle relevante y no deduzcas eficacia, duraci\xF3n del proceso ni causalidad." }],
|
|
678
|
+
["Dos recursos autorizados comparables con etiquetas y relaci\xF3n confirmadas.", "Motor disponible para m\xE1scaras y alineaci\xF3n; m\xE9todo de paneles cuando cambien punto de vista o geometr\xEDa."],
|
|
679
|
+
["La comparaci\xF3n mezcla sujetos, \xE1ngulos o condiciones que enga\xF1an sobre el cambio.", "Los recursos sint\xE9ticos se presentan como prueba de mejora, tratamiento o fabricaci\xF3n real."]
|
|
680
|
+
],
|
|
681
|
+
{ title: "Referencia de comparaci\xF3n alineada; barrido propuesto", url: "https://www.instagram.com/p/Dcggcv6xgAn/" },
|
|
682
|
+
["1:1: alinea ambos estados en un marco com\xFAn y ubica etiquetas en bordes opuestos sin tapar la diferencia.", "9:16: usa estados apilados o un barrido vertical cuando conserve puntos de referencia; elige paneles si el recorte oculta contexto.", "16:9: compara en un lienzo compartido con barrido horizontal o dos paneles equivalentes; no deformes una versi\xF3n para hacerla coincidir."]
|
|
683
|
+
),
|
|
684
|
+
composition(
|
|
685
|
+
[
|
|
686
|
+
"evidence-spotlight",
|
|
687
|
+
"Detalle con evidencia",
|
|
688
|
+
"Un recorte destacado conecta una fuente con una anotaci\xF3n verificable.",
|
|
689
|
+
"Dirigir la atenci\xF3n hacia el detalle que respalda una afirmaci\xF3n concreta.",
|
|
690
|
+
["Selecciona la fuente autorizada y confirma qu\xE9 pasaje o detalle sostiene la afirmaci\xF3n; registra procedencia sin exponer informaci\xF3n privada.", "Mant\xE9n una vista contextual y un recorte ampliado del mismo recurso, unidos por una llamada editable.", "Marca una regi\xF3n con flecha, subrayado o contorno sin alterar su contenido; los datos y etiquetas se componen en c\xF3digo y se verifican contra la fuente.", "Deja tiempo para leer y distingue evidencia documental de una muestra ficticia; la anotaci\xF3n no convierte una imagen generada en prueba."],
|
|
691
|
+
[{ key: "callouts", value: [1, 2], unit: "llamadas por estado", adaptation: "Cada llamada debe se\xF1alar un dato concreto; divide pasajes largos en estados sucesivos." }, { key: "detail_share", value: [35, 60], unit: "% del panel", adaptation: "La ampliaci\xF3n depende de resoluci\xF3n y legibilidad real, no de OCR supuesto." }, { key: "reading_hold", value: [2, 4], unit: "s de partida", adaptation: "Ampl\xEDa el tiempo para texto denso y mant\xE9n contexto suficiente para no tergiversar la fuente." }],
|
|
692
|
+
["Fuente autorizada, procedencia y afirmaci\xF3n verificadas; datos privados eliminados antes de composici\xF3n.", "Motor disponible para recortes, llamadas y texto editable con contraste suficiente."],
|
|
693
|
+
["La fuente no respalda la afirmaci\xF3n o el recorte cambia su sentido.", "La demo, el OCR no revisado o una imagen sint\xE9tica se presentar\xEDan como evidencia real."]
|
|
694
|
+
],
|
|
695
|
+
{ title: "Referencia de pasaje documental con flecha y contorno", url: "https://www.instagram.com/p/DZXtFEbKxdb/" },
|
|
696
|
+
["1:1: conserva la fuente en un panel y sit\xFAa la ampliaci\xF3n junto a una sola llamada; prioriza la lectura del detalle.", "9:16: apila contexto, detalle y conclusi\xF3n breve; evita convertir una p\xE1gina completa en texto diminuto.", "16:9: coloca fuente y ampliaci\xF3n lado a lado con una conexi\xF3n corta; preserva pie de fuente y escala legible."]
|
|
697
|
+
),
|
|
698
|
+
composition(
|
|
699
|
+
[
|
|
700
|
+
"interface-walkthrough",
|
|
701
|
+
"Recorrido de interfaz",
|
|
702
|
+
"Una interfaz recorre una acci\xF3n con cursor, foco y confirmaci\xF3n.",
|
|
703
|
+
"Explicar una tarea digital mediante estados claros y un resultado comprensible.",
|
|
704
|
+
["Define una sola tarea y estados reales verificados o una interfaz ficticia claramente identificada; nunca presenta la demo como integraci\xF3n disponible.", "Comp\xF3n paneles, textos y cursor en capas editables; elimina datos privados si el recurso es una captura real.", "Gu\xEDa hacia un control, ampl\xEDa la regi\xF3n \xFAtil y muestra el siguiente estado; el cursor y la magnificaci\xF3n son adaptaci\xF3n propuesta, no l\xF3gica de aplicaci\xF3n demostrada por los frames.", "Cierra con una confirmaci\xF3n solo cuando corresponda al comportamiento verificado o a la demo rotulada; comprueba texto, destino y l\xEDmites de la acci\xF3n."],
|
|
705
|
+
[{ key: "steps", value: [2, 4], unit: "estados por tarea", adaptation: "Divide flujos largos en escenas; no simules automatizaci\xF3n no disponible." }, { key: "focus_regions", value: 1, unit: "regi\xF3n activa", adaptation: "El resto conserva contexto estable; el cursor no debe competir con la informaci\xF3n que se explica." }, { key: "step_hold", value: [1, 3], unit: "s de partida", adaptation: "Ajusta a lectura y explicaci\xF3n; estados est\xE1ticos y foco por corte funcionan con movimiento reducido." }],
|
|
706
|
+
["Flujo real revisado o demo ficticia rotulada, sin credenciales ni datos de clientes.", "Motor disponible para estados de interfaz, cursor y magnificaci\xF3n; acceso y permisos confirmados por separado."],
|
|
707
|
+
["La secuencia inventa capacidades de un producto o promete una integraci\xF3n habilitada.", "Una ampliaci\xF3n vuelve ilegible el control, oculta un error real o filtra datos privados."]
|
|
708
|
+
],
|
|
709
|
+
{ title: "Referencia de estados de editor, men\xFA y confirmaci\xF3n", url: "https://www.instagram.com/p/DdocHKLq7i0/" },
|
|
710
|
+
["1:1: muestra un panel de tarea y ampl\xEDa la regi\xF3n activa dentro del mismo marco sin perder el estado anterior.", "9:16: recomp\xF3n la interfaz como paneles legibles o usa un recorte guiado con contexto; no reduzcas un escritorio completo a m\xF3vil.", "16:9: conserva la interfaz amplia y usa una lente o panel lateral de detalle; el cursor mantiene una ruta corta entre controles."]
|
|
711
|
+
),
|
|
712
|
+
composition(
|
|
713
|
+
[
|
|
714
|
+
"quote-portrait",
|
|
715
|
+
"Retrato y frase",
|
|
716
|
+
"Un retrato acompa\xF1a una frase breve con jerarqu\xEDa editorial.",
|
|
717
|
+
"Relacionar una idea con una presencia humana sin inventar su autor\xEDa.",
|
|
718
|
+
["Confirma derechos del retrato, texto exacto y atribuci\xF3n; si son recursos sint\xE9ticos o texto de muestra, identif\xEDcalos como ilustrativos.", "Comp\xF3n retrato y frase como elementos independientes; el referente aporta persona y palabras de anclaje, no una cita atribuida equivalente.", "Revela la frase en bloques legibles y conserva rostro y gesto libres; no generes movimiento labial ni voz para atribuirle palabras al sujeto.", "A\xF1ade nombre o fuente solo cuando est\xE9 verificado; sin atribuci\xF3n usa una idea editorial identificada, evitando comillas que simulen un testimonio."],
|
|
719
|
+
[{ key: "quote_words", value: [6, 18], unit: "palabras orientativas", adaptation: "Respeta el texto exacto autorizado; divide una cita larga sin cambiar su significado." }, { key: "text_blocks", value: [1, 3], unit: "bloques", adaptation: "La jerarqu\xEDa usa las fuentes de marca y no exige cambiar de familia o inventar una firma." }, { key: "reading_hold", value: [2, 5], unit: "s de partida", adaptation: "Ajusta al n\xFAmero de palabras y al p\xFAblico; permite un estado completo legible." }],
|
|
720
|
+
["Retrato autorizado y texto con atribuci\xF3n confirmada o identificaci\xF3n expl\xEDcita como idea editorial de muestra.", "Motor disponible para tipograf\xEDa editable y composici\xF3n; fuentes de marca instaladas."],
|
|
721
|
+
["La composici\xF3n har\xEDa parecer que una persona real o sint\xE9tica pronunci\xF3 una frase no verificada.", "El texto cambia el sentido del testimonio, oculta identidad relevante o necesita una voz no autorizada."]
|
|
722
|
+
],
|
|
723
|
+
{ title: "Apoyo de retrato y palabras de anclaje; cita editorial propuesta", url: "https://www.instagram.com/p/DdKae1xzRao/" },
|
|
724
|
+
["1:1: equilibra retrato y frase en dos zonas con una atribuci\xF3n secundaria legible cuando exista.", "9:16: coloca retrato arriba y frase debajo; conserva zona segura para subt\xEDtulos sin superponer dos lecturas densas.", "16:9: usa retrato en un tercio y un bloque de cita amplio en el resto; no expanda el texto sobre rasgos esenciales."]
|
|
725
|
+
),
|
|
726
|
+
composition(
|
|
727
|
+
[
|
|
728
|
+
"type-poster",
|
|
729
|
+
"Cartel en movimiento",
|
|
730
|
+
"Palabras grandes construyen un cartel mediante cambios de estado.",
|
|
731
|
+
"Convertir una idea breve en una jerarqu\xEDa tipogr\xE1fica memorable y legible.",
|
|
732
|
+
["Define un mensaje breve y sus palabras clave sin alterar su orden o sentido.", "Comp\xF3n el cartel con fuentes disponibles de marca; escala, peso y espacio crean contraste sin exigir la paleta del referente.", "Anima entradas, m\xE1scaras o desplazamientos entre estados; la captura de texto grande apoya la jerarqu\xEDa, no acredita una curva o cadencia espec\xEDfica.", "Sost\xE9n el mensaje completo y revisa acentos, interlineado y recortes; con movimiento reducido muestra estados estables sin parpadeos."],
|
|
733
|
+
[{ key: "headline_words", value: [2, 8], unit: "palabras por cartel", adaptation: "Divide mensajes extensos en carteles completos, no en fragmentos que cambien la interpretaci\xF3n." }, { key: "type_levels", value: [2, 3], unit: "niveles de jerarqu\xEDa", adaptation: "Usa peso, tama\xF1o y espaciado dentro de las fuentes de marca; no impone una tipograf\xEDa nueva." }, { key: "state_hold", value: [800, 1800], unit: "ms de partida", adaptation: "Ajusta a lectura real y evita destellos o alternancias r\xE1pidas de alto contraste." }],
|
|
734
|
+
["Texto confirmado, fuentes de marca disponibles y motor de composici\xF3n tipogr\xE1fica editable.", "Revisi\xF3n de caracteres espa\xF1oles, contraste y \xE1reas seguras en cada formato."],
|
|
735
|
+
["El texto es demasiado largo para leerse al ritmo propuesto.", "La animaci\xF3n recorta palabras esenciales, convierte decoraci\xF3n en un claim o requiere parpadeos intensos."]
|
|
736
|
+
],
|
|
737
|
+
{ title: "Referencia de palabra tipogr\xE1fica de gran escala", url: "https://www.instagram.com/p/DdKae1xzRao/" },
|
|
738
|
+
["1:1: organiza una palabra dominante y una l\xEDnea de apoyo con m\xE1rgenes consistentes y un estado final completo.", "9:16: apila l\xEDneas cortas de arriba abajo con pausas de lectura; recomp\xF3n los saltos sin cortar palabras ni acentos.", "16:9: extiende la jerarqu\xEDa horizontalmente o contrapone dos bloques; conserva el orden de lectura y evita ampliar una palabra hasta perder sus extremos."]
|
|
739
|
+
)
|
|
740
|
+
]
|
|
741
|
+
};
|
|
742
|
+
function getMediaStyle(id, version) {
|
|
743
|
+
return MEDIA_STYLE_CATALOG.styles.find((style) => style.id === id && (version === void 0 || style.version === version));
|
|
744
|
+
}
|
|
745
|
+
var record = (value) => !!value && typeof value === "object" && !Array.isArray(value);
|
|
746
|
+
var only = (value, fields) => Object.keys(value).every((key) => fields.includes(key));
|
|
747
|
+
function visualTasteIssues(value) {
|
|
748
|
+
if (!record(value) || !only(value, ["density", "energy", "contrast"])) return ["visualTaste supports density, energy and contrast only."];
|
|
749
|
+
const options = { density: ["minimal", "layered"], energy: ["calm", "lively"], contrast: ["soft", "strong"] };
|
|
750
|
+
return Object.entries(options).flatMap(([key, allowed]) => value[key] === void 0 || typeof value[key] === "string" && allowed.includes(value[key]) ? [] : [`Invalid visualTaste ${key}. Omit undecided choices.`]);
|
|
751
|
+
}
|
|
752
|
+
function motionSelectionIssues(value) {
|
|
753
|
+
return value === void 0 || value === "adaptive" || value === "guided" ? [] : ["motionSelection must be adaptive or guided. Omit undecided choices."];
|
|
754
|
+
}
|
|
755
|
+
function motionProductionIssues(value) {
|
|
756
|
+
return value === void 0 || value === "auto" || value === "code" || value === "ai" ? [] : ["motionProduction must be auto, code or ai. Omit undecided choices."];
|
|
757
|
+
}
|
|
758
|
+
function resolveMotionSelection(selections = [], motionSelection) {
|
|
759
|
+
return motionSelection ?? (selections.some((selection) => selection.state === "default" && getMediaStyle(selection.styleId, selection.version)?.category === "motion") ? "guided" : "adaptive");
|
|
760
|
+
}
|
|
761
|
+
function mediaStyleSelectionIssues(value, category) {
|
|
762
|
+
if (!Array.isArray(value) || value.length > 24) return ["mediaStyles must be an array of at most 24 selections."];
|
|
763
|
+
const issues = [], ids = /* @__PURE__ */ new Set(), defaults = /* @__PURE__ */ new Set();
|
|
764
|
+
for (const selection of value) {
|
|
765
|
+
if (!record(selection) || !only(selection, ["styleId", "version", "state"]) || typeof selection.styleId !== "string" || typeof selection.version !== "string" || !["favorite", "default", "avoid"].includes(String(selection.state))) {
|
|
766
|
+
issues.push("Media style selections require styleId, version and favorite/default/avoid state.");
|
|
767
|
+
continue;
|
|
768
|
+
}
|
|
769
|
+
const style = getMediaStyle(selection.styleId, selection.version);
|
|
770
|
+
if (!style) {
|
|
771
|
+
issues.push(`Unknown media style or unsupported version: ${selection.styleId}@${selection.version}.`);
|
|
772
|
+
continue;
|
|
773
|
+
}
|
|
774
|
+
if (category && style.category !== category) issues.push(`Media style ${style.id} does not belong to ${category}.`);
|
|
775
|
+
if (ids.has(style.id)) issues.push(`Duplicate media style selection: ${style.id}.`);
|
|
776
|
+
ids.add(style.id);
|
|
777
|
+
if (selection.state === "default") {
|
|
778
|
+
if (defaults.has(style.category)) issues.push(`Only one default media style is allowed for ${style.category}.`);
|
|
779
|
+
defaults.add(style.category);
|
|
780
|
+
}
|
|
781
|
+
}
|
|
782
|
+
return issues;
|
|
783
|
+
}
|
|
784
|
+
function mediaStyleOverrideIssues(value) {
|
|
785
|
+
if (!record(value) || !only(value, ["image", "motion"])) return ["mediaStyleOverrides supports image and motion categories only."];
|
|
786
|
+
const issues = [];
|
|
787
|
+
for (const category of ["image", "motion"]) {
|
|
788
|
+
const override = value[category];
|
|
789
|
+
if (override === void 0) continue;
|
|
790
|
+
if (!record(override) || !["inherit", "replace", "disabled"].includes(String(override.mode))) {
|
|
791
|
+
issues.push(`Invalid ${category} media style override.`);
|
|
792
|
+
continue;
|
|
793
|
+
}
|
|
794
|
+
if (override.mode === "replace") {
|
|
795
|
+
if (!only(override, ["mode", "selections"])) issues.push(`Unknown ${category} media style override fields.`);
|
|
796
|
+
if (!Array.isArray(override.selections) || !override.selections.length) issues.push(`The ${category} replace mode requires selections; use disabled to clear the category.`);
|
|
797
|
+
issues.push(...mediaStyleSelectionIssues(override.selections, category));
|
|
798
|
+
} else if (!only(override, ["mode"])) issues.push(`The ${category} ${override.mode} mode cannot contain selections.`);
|
|
799
|
+
}
|
|
800
|
+
return issues;
|
|
801
|
+
}
|
|
802
|
+
function resolveMediaPreferences(selections = [], overrides = {}, taste = {}, motionSelection, motionProduction, editorialDelivery, captionsEnabled) {
|
|
803
|
+
const issues = [...mediaStyleSelectionIssues(selections), ...mediaStyleOverrideIssues(overrides), ...visualTasteIssues(taste), ...motionSelectionIssues(motionSelection), ...motionProductionIssues(motionProduction), ...editorialDeliveryIssues(editorialDelivery)];
|
|
804
|
+
if (issues.length) throw new Error(issues.join("\n"));
|
|
805
|
+
const category = (kind) => {
|
|
806
|
+
const override = overrides[kind], mode = override?.mode ?? "inherit";
|
|
807
|
+
const selected = mode === "disabled" ? [] : override?.mode === "replace" ? override.selections : selections.filter((selection) => getMediaStyle(selection.styleId, selection.version).category === kind);
|
|
808
|
+
return { mode, source: mode === "inherit" ? selected.length ? "brand" : "unspecified" : "project", selections: selected.map((selection) => ({ ...selection, name: getMediaStyle(selection.styleId, selection.version).name })) };
|
|
809
|
+
};
|
|
810
|
+
const motion2 = category("motion");
|
|
811
|
+
const effectiveMotionSelection = resolveMotionSelection(motion2.selections, motionSelection);
|
|
812
|
+
const motionSelectionSource = motionSelection !== void 0 ? "brand" : effectiveMotionSelection === "guided" && motion2.source !== "unspecified" ? motion2.source : "implicit";
|
|
813
|
+
if (effectiveMotionSelection === "adaptive") motion2.selections = motion2.selections.map((selection) => selection.state === "default" ? { ...selection, state: "favorite" } : selection);
|
|
814
|
+
const delivery = resolveEditorialDelivery(editorialDelivery, captionsEnabled);
|
|
815
|
+
return { schemaVersion: 1, catalog: { id: MEDIA_STYLE_CATALOG.id, version: MEDIA_STYLE_CATALOG.version }, image: category("image"), motion: motion2, motionSelection: effectiveMotionSelection, motionSelectionSource, motionProduction: motionProduction ?? "auto", motionProductionSource: motionProduction === void 0 ? "implicit" : "brand", ...Object.keys(delivery).length ? { editorialDelivery: delivery } : {}, taste: { ...taste }, policy: "Current explicit instructions govern. Project overrides define effective choices; disabled categories stay disabled. Required brand tokens govern, never preview palettes/fonts. Favorites are candidates; effective avoid entries always exclude. Adaptive motion chooses per scene or none; saved defaults become candidate favorites. Guided motion starts from saved choices and checks fit. Production auto chooses by scene and verified tools; code prefers complete editable JavaScript scenes, ai prefers generated clips. Keep exact text/data in controlled composition. Production preferences prove neither personal integration access nor model availability, rights or permission to spend. Check the current member\u2019s integrations and session tools; files access alone cannot generate. Taste guides intensity, not tokens or a forced recipe. Soft contrast must retain readable text and visible essential information." + (Object.keys(delivery).length ? " Editorial delivery contains explicit brand preferences only; omitted fields are for the agent to decide. Existing avoid and source restrictions constrain delivery; no-SFX overrides a general preference for effects. Silent music/effects does not silence speech, and no narration does not mute ambience. Text density excludes subtitles; B-roll format does not authorize a source. Disabled captions omit their language, treatment and position from the effective view without erasing saved choices; a chosen appearance cannot enable captions. Respect framing and safe areas; karaoke needs verified word timing. Explicit project output settings prevail; otherwise source uses verified speech language and brand uses language.json. CTA goal none excludes every CTA and omits its treatment. Other CTA goals guide each project; confirm final copy, keyword and destination in its brief or direction, never invent them. Spoken CTA grants no new speech or rewriting permission and cannot override no narration. Voiceover authorizes neither cloning nor providers or spending." : "") };
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
// packages/core/src/editorial-profile.ts
|
|
819
|
+
var profileOptions = {
|
|
820
|
+
experience: ["Estoy empezando", "Ya creo videos", "Edito profesionalmente"],
|
|
821
|
+
role: ["Marca personal / creador", "Negocio / equipo de marketing", "Agencia / editor para clientes"],
|
|
822
|
+
platforms: ["Instagram / Reels", "TikTok", "YouTube Shorts", "YouTube", "LinkedIn"],
|
|
823
|
+
formats: ["A c\xE1mara", "Tutoriales", "Entrevistas", "Vlogs", "Producto / UGC", "Sin aparecer en c\xE1mara"],
|
|
824
|
+
goal: ["Ense\xF1ar", "Generar confianza", "Vender", "Entretener", "Crear comunidad"],
|
|
825
|
+
pace: ["Pausado", "\xC1gil", "Muy din\xE1mico"],
|
|
826
|
+
captions: ["Subt\xEDtulos discretos", "Palabras destacadas", "Sin subt\xEDtulos"],
|
|
827
|
+
sound: ["Voz protagonista", "M\xFAsica discreta", "M\xFAsica y efectos presentes", "Sin m\xFAsica ni efectos"],
|
|
828
|
+
freedom: ["Mantener un estilo consistente", "Explorar seg\xFAn el proyecto"],
|
|
829
|
+
assets: ["Solo mi material", "Se puede proponer material de stock", "Se puede proponer material generado"],
|
|
830
|
+
avoid: ["Sin emojis", "Sin transiciones llamativas", "Sin efectos de sonido", "No reescribir mis palabras", "No usar im\xE1genes generadas"],
|
|
831
|
+
aspects: ["Tipograf\xEDa", "Composici\xF3n", "Colores", "Im\xE1genes", "Ritmo", "Animaci\xF3n", "Sonido"]
|
|
832
|
+
};
|
|
833
|
+
var emptyProfile = () => ({ schemaVersion: 1, experience: "", role: "", niche: "", platforms: [], formats: [], goal: "", pace: "", captions: "", sound: "", freedom: "", assets: "", avoid: [], notes: "", references: [] });
|
|
834
|
+
var record2 = (value) => !!value && typeof value === "object" && !Array.isArray(value);
|
|
835
|
+
var string = (value, min, max) => typeof value === "string" && value.length >= min && value.length <= max;
|
|
836
|
+
var choices = (value, options) => Array.isArray(value) && value.length <= options.length && value.every((item) => typeof item === "string" && options.includes(item));
|
|
837
|
+
function editorialProfileIssues(value) {
|
|
838
|
+
if (!record2(value)) return ["El perfil editorial debe ser un objeto."];
|
|
839
|
+
const issues = [];
|
|
840
|
+
if (value.schemaVersion !== 1) issues.push("schemaVersion del perfil editorial debe ser 1.");
|
|
841
|
+
const fields = [...Object.keys(emptyProfile()), "mediaStyles", "visualTaste", "motionSelection", "motionProduction", "editorialDelivery"];
|
|
842
|
+
if (Object.keys(value).some((key) => !fields.includes(key))) issues.push("El perfil editorial contiene campos desconocidos.");
|
|
843
|
+
for (const key of ["experience", "role", "goal", "pace", "captions", "sound", "freedom", "assets"]) {
|
|
844
|
+
const selected = value[key];
|
|
845
|
+
if ((key === "experience" || key === "role") && selected === void 0) continue;
|
|
846
|
+
if (typeof selected !== "string" || selected !== "" && !profileOptions[key].includes(selected)) issues.push(`Preferencia editorial inv\xE1lida: ${key}.`);
|
|
847
|
+
}
|
|
848
|
+
for (const key of ["platforms", "formats", "avoid"]) if (!choices(value[key], profileOptions[key])) issues.push(`Opciones editoriales inv\xE1lidas: ${key}.`);
|
|
849
|
+
if (!string(value.niche, 0, 500)) issues.push("niche debe ser texto de hasta 500 caracteres.");
|
|
850
|
+
if (!string(value.notes, 0, 1500)) issues.push("notes debe ser texto de hasta 1500 caracteres.");
|
|
851
|
+
if (!Array.isArray(value.references) || value.references.length > 12) issues.push("references admite hasta 12 referencias.");
|
|
852
|
+
else for (const ref of value.references) {
|
|
853
|
+
if (!record2(ref) || !string(ref.videoId, 1, 150) || !string(ref.exampleId, 1, 150) || typeof ref.timestampMs !== "number" || !Number.isFinite(ref.timestampMs) || ref.timestampMs < 0 || !["like", "avoid"].includes(String(ref.sentiment)) || !choices(ref.aspects, profileOptions.aspects)) issues.push("Referencia editorial inv\xE1lida.");
|
|
854
|
+
}
|
|
855
|
+
if (value.mediaStyles !== void 0) issues.push(...mediaStyleSelectionIssues(value.mediaStyles));
|
|
856
|
+
if (value.visualTaste !== void 0) issues.push(...visualTasteIssues(value.visualTaste));
|
|
857
|
+
issues.push(...motionSelectionIssues(value.motionSelection));
|
|
858
|
+
issues.push(...motionProductionIssues(value.motionProduction));
|
|
859
|
+
issues.push(...editorialDeliveryIssues(value.editorialDelivery));
|
|
860
|
+
return issues;
|
|
861
|
+
}
|
|
862
|
+
function parseEditorialProfile(value) {
|
|
863
|
+
const issues = editorialProfileIssues(value);
|
|
864
|
+
if (issues.length) throw new Error(issues.join("\n"));
|
|
865
|
+
const input = value;
|
|
866
|
+
return { ...input, experience: input.experience ?? "", role: input.role ?? "", platforms: [...input.platforms], formats: [...input.formats], avoid: [...input.avoid], references: input.references.map((ref) => ({ videoId: ref.videoId, exampleId: ref.exampleId, timestampMs: ref.timestampMs, sentiment: ref.sentiment, aspects: [...ref.aspects] })), ...input.mediaStyles !== void 0 ? { mediaStyles: input.mediaStyles.map((selection) => ({ ...selection })) } : {}, ...input.visualTaste !== void 0 ? { visualTaste: { ...input.visualTaste } } : {}, ...input.editorialDelivery !== void 0 ? { editorialDelivery: { ...input.editorialDelivery } } : {} };
|
|
867
|
+
}
|
|
868
|
+
var start = "<!-- editorbrain:editorial-profile:start -->";
|
|
869
|
+
var end = "<!-- editorbrain:editorial-profile:end -->";
|
|
870
|
+
function bounds(content) {
|
|
871
|
+
const a = content.indexOf(start), b = content.indexOf(end);
|
|
872
|
+
if (a === -1 && b === -1) return null;
|
|
873
|
+
if (a === -1 || b < a || content.indexOf(start, a + start.length) !== -1 || content.indexOf(end, b + end.length) !== -1) throw new Error("El perfil fue editado fuera del formulario. Revisa su bloque en Gu\xEDa visual antes de guardar.");
|
|
874
|
+
return [a, b + end.length];
|
|
875
|
+
}
|
|
876
|
+
function readProfile(content) {
|
|
877
|
+
const span = bounds(content);
|
|
878
|
+
if (!span) return emptyProfile();
|
|
879
|
+
const match = content.slice(...span).match(/```json\r?\n([\s\S]*?)\r?\n```/);
|
|
880
|
+
if (!match) throw new Error("No pudimos leer tu perfil. Revisa el bloque en Gu\xEDa visual; conservamos el archivo original.");
|
|
881
|
+
return parseEditorialProfile(JSON.parse(match[1]));
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
// packages/core/src/production-plan.ts
|
|
885
|
+
var record3 = (v) => !!v && typeof v === "object" && !Array.isArray(v);
|
|
886
|
+
var positive = (v) => typeof v === "number" && Number.isFinite(v) && v > 0;
|
|
887
|
+
var nonnegative = (v) => v === 0 || positive(v);
|
|
888
|
+
var hash = (v) => typeof v === "string" && /^[a-f0-9]{64}$/.test(v);
|
|
889
|
+
var text = (v) => typeof v === "string" && v.trim().length > 0 && v.length <= 2e3;
|
|
890
|
+
var range = (v) => record3(v) && nonnegative(v.startSeconds) && positive(v.endSeconds) && v.endSeconds > v.startSeconds;
|
|
891
|
+
var MAX_ENTRIES = 2e3;
|
|
892
|
+
function validateProductionPlan(value) {
|
|
893
|
+
const issues = [];
|
|
894
|
+
if (!record3(value) || value.schemaVersion !== 1 || value.kind !== "editorbrain.production-plan") return { valid: false, issues: ["Expected schemaVersion 1 and kind editorbrain.production-plan."] };
|
|
895
|
+
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.");
|
|
896
|
+
if (!Array.isArray(value.inspections) || value.inspections.length > MAX_ENTRIES) issues.push("inspections must be an array of at most 2000 observations.");
|
|
897
|
+
else value.inspections.forEach((item, index) => {
|
|
898
|
+
const label = `inspections[${index}]`;
|
|
899
|
+
if (!record3(item) || !hash(item.sourceSha256) || !["visual", "motion", "audio"].includes(String(item.modality)) || !["sampled_frames", "playback", "listening"].includes(String(item.method))) {
|
|
900
|
+
issues.push(`${label}: invalid source hash, modality or method.`);
|
|
901
|
+
return;
|
|
902
|
+
}
|
|
903
|
+
if (Object.keys(item).some((key) => !["sourceSha256", "modality", "method", "intervals", "sampledSeconds", "note"].includes(key))) issues.push(`${label}: unsupported field.`);
|
|
904
|
+
if (item.note !== void 0 && !text(item.note)) issues.push(`${label}: note must be nonempty text of at most 2000 characters.`);
|
|
905
|
+
if (item.method === "sampled_frames") {
|
|
906
|
+
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.`);
|
|
907
|
+
} else {
|
|
908
|
+
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.`);
|
|
909
|
+
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.`);
|
|
910
|
+
}
|
|
911
|
+
});
|
|
912
|
+
if (!Array.isArray(value.uses) || value.uses.length > MAX_ENTRIES) issues.push("uses must be an array of at most 2000 source uses.");
|
|
913
|
+
else {
|
|
914
|
+
const ids = /* @__PURE__ */ new Set();
|
|
915
|
+
value.uses.forEach((item, index) => {
|
|
916
|
+
const label = `uses[${index}]`;
|
|
917
|
+
if (!record3(item) || typeof item.id !== "string" || !/^[a-zA-Z0-9_-]{1,80}$/.test(item.id) || !hash(item.sourceSha256) || !range(item.source) || !range(item.timeline)) {
|
|
918
|
+
issues.push(`${label}: requires ID, source hash, source and timeline intervals.`);
|
|
919
|
+
return;
|
|
920
|
+
}
|
|
921
|
+
if (ids.has(item.id)) issues.push(`${label}: duplicate use ID ${item.id}.`);
|
|
922
|
+
ids.add(item.id);
|
|
923
|
+
if (Object.keys(item).some((key) => !["id", "sourceSha256", "source", "timeline", "purpose", "reuseReason"].includes(key))) issues.push(`${label}: unsupported field.`);
|
|
924
|
+
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.`);
|
|
925
|
+
});
|
|
926
|
+
}
|
|
927
|
+
return { valid: !issues.length, issues };
|
|
928
|
+
}
|
|
929
|
+
function mergeProductionIntervals(intervals) {
|
|
930
|
+
const merged = [];
|
|
931
|
+
for (const interval of [...intervals].sort((a, b) => a.startSeconds - b.startSeconds)) {
|
|
932
|
+
const last = merged.at(-1);
|
|
933
|
+
if (last && interval.startSeconds <= last.endSeconds) last.endSeconds = Math.max(last.endSeconds, interval.endSeconds);
|
|
934
|
+
else merged.push({ ...interval });
|
|
935
|
+
}
|
|
936
|
+
return merged;
|
|
937
|
+
}
|
|
938
|
+
function checkProductionPlan(inventory, value) {
|
|
939
|
+
const checked = validateProductionPlan(value);
|
|
940
|
+
const findings = checked.issues.map((message) => ({ code: "INVALID_PLAN", severity: "error", message }));
|
|
941
|
+
const sources = /* @__PURE__ */ new Map();
|
|
942
|
+
let inventoryComplete = true;
|
|
943
|
+
if (!record3(inventory) || inventory.schemaVersion !== 1 || inventory.kind !== "editorbrain.media-inventory" || inventory.summary === true || typeof inventory.complete !== "boolean" || !Array.isArray(inventory.assets) || inventory.assets.length > 1e4) {
|
|
944
|
+
inventoryComplete = false;
|
|
945
|
+
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." });
|
|
946
|
+
} else {
|
|
947
|
+
if (inventory.complete === false || Array.isArray(inventory.failures) && inventory.failures.length) {
|
|
948
|
+
inventoryComplete = false;
|
|
949
|
+
findings.push({ code: "INVENTORY_INCOMPLETE", severity: "warning", message: "Inventory traversal or probing was incomplete. This plan check covers only recorded assets." });
|
|
950
|
+
}
|
|
951
|
+
for (const asset of inventory.assets) {
|
|
952
|
+
if (!record3(asset) || !hash(asset.sha256) || !(asset.durationSeconds === null || nonnegative(asset.durationSeconds)) || asset.streams !== void 0 && (!Array.isArray(asset.streams) || asset.streams.some((s) => !record3(s) || typeof s.type !== "string"))) {
|
|
953
|
+
findings.push({ code: "INVALID_INVENTORY", severity: "error", message: "Inventory assets need a source hash, nonnegative duration or null, and valid optional streams." });
|
|
954
|
+
continue;
|
|
955
|
+
}
|
|
956
|
+
const existing = sources.get(asset.sha256);
|
|
957
|
+
if (existing && existing.durationSeconds !== asset.durationSeconds) findings.push({ code: "INVENTORY_CONFLICT", severity: "error", message: "The same content hash has conflicting durations.", sourceSha256: asset.sha256 });
|
|
958
|
+
sources.set(asset.sha256, asset);
|
|
959
|
+
}
|
|
960
|
+
}
|
|
961
|
+
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" };
|
|
962
|
+
const plan = value;
|
|
963
|
+
const sourceFor = (sourceSha256) => {
|
|
964
|
+
const source = sources.get(sourceSha256);
|
|
965
|
+
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 });
|
|
966
|
+
return source;
|
|
967
|
+
};
|
|
968
|
+
const inBounds = (end2, source) => source.durationSeconds === null || end2 <= source.durationSeconds + 1e-3;
|
|
969
|
+
for (const inspection of plan.inspections) {
|
|
970
|
+
const source = sourceFor(inspection.sourceSha256);
|
|
971
|
+
if (!source) continue;
|
|
972
|
+
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 });
|
|
973
|
+
if ([...inspection.sampledSeconds ?? [], ...(inspection.intervals ?? []).map((r) => r.endSeconds)].some((end2) => !inBounds(end2, source))) findings.push({ code: "INSPECTION_OUT_OF_BOUNDS", severity: "error", message: "Inspection extends past the source duration.", sourceSha256: source.sha256 });
|
|
974
|
+
}
|
|
975
|
+
const validUses = [];
|
|
976
|
+
for (const use of plan.uses) {
|
|
977
|
+
const source = sourceFor(use.sourceSha256);
|
|
978
|
+
if (!source) continue;
|
|
979
|
+
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 });
|
|
980
|
+
else validUses.push(use);
|
|
981
|
+
}
|
|
982
|
+
for (const use of validUses) {
|
|
983
|
+
const source = sources.get(use.sourceSha256);
|
|
984
|
+
const audioOnly = source.streams?.some((s) => s.type === "audio") && !source.streams.some((s) => s.type === "video" && !s.attachedPicture);
|
|
985
|
+
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)));
|
|
986
|
+
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);
|
|
987
|
+
const missing = use.source.endSeconds - use.source.startSeconds - covered;
|
|
988
|
+
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 });
|
|
989
|
+
}
|
|
990
|
+
let repeatedPairs = 0;
|
|
991
|
+
for (let i = 0; i < validUses.length; i++) for (let j = i + 1; j < validUses.length; j++) {
|
|
992
|
+
const a = validUses[i], b = validUses[j];
|
|
993
|
+
if (a.sourceSha256 !== b.sourceSha256) continue;
|
|
994
|
+
const startSeconds = Math.max(a.source.startSeconds, b.source.startSeconds), endSeconds = Math.min(a.source.endSeconds, b.source.endSeconds);
|
|
995
|
+
if (endSeconds - startSeconds <= 1e-3) continue;
|
|
996
|
+
repeatedPairs++;
|
|
997
|
+
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 } });
|
|
998
|
+
}
|
|
999
|
+
if (repeatedPairs > 200) findings.push({ code: "REUSE_DETAILS_LIMIT", severity: "info", message: `${repeatedPairs} repeated pairs detected; only the first 200 are detailed.` });
|
|
1000
|
+
const coverage = [...sources.values()].map((source) => {
|
|
1001
|
+
const byModality = ["visual", "motion", "audio"].map((modality) => {
|
|
1002
|
+
const observations = plan.inspections.filter((i) => i.sourceSha256 === source.sha256 && i.modality === modality);
|
|
1003
|
+
const intervals = mergeProductionIntervals(observations.flatMap((i) => i.intervals ?? []).filter((r) => inBounds(r.endSeconds, source)));
|
|
1004
|
+
const seconds = intervals.reduce((sum, i) => sum + i.endSeconds - i.startSeconds, 0);
|
|
1005
|
+
return { modality, intervals, seconds, fraction: !source.durationSeconds ? null : seconds / source.durationSeconds, sampledSeconds: [...new Set(observations.flatMap((i) => i.sampledSeconds ?? []))].sort((a, b) => a - b) };
|
|
1006
|
+
});
|
|
1007
|
+
const sourceUses = validUses.filter((use) => use.sourceSha256 === source.sha256);
|
|
1008
|
+
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 });
|
|
1009
|
+
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 });
|
|
1010
|
+
return { sourceSha256: source.sha256, durationSeconds: source.durationSeconds, uses: sourceUses.length, byModality };
|
|
1011
|
+
});
|
|
1012
|
+
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" };
|
|
1013
|
+
}
|
|
1014
|
+
|
|
1015
|
+
// packages/core/src/integrations.ts
|
|
1016
|
+
var integrationCatalog = [
|
|
1017
|
+
{ id: "higgsfield", name: "Higgsfield", description: "Im\xE1genes y clips generados para apoyar tu edici\xF3n.", capabilities: ["image", "video"], accessMethods: ["agent", "api", "browser", "files"], docsUrl: "https://open.higgsfield.ai/quick-start" },
|
|
1018
|
+
{ id: "elevenlabs", name: "ElevenLabs", description: "Voces, efectos de sonido y m\xFAsica generada.", capabilities: ["voiceover", "sfx", "music"], accessMethods: ["agent", "api", "browser", "files"], docsUrl: "https://elevenlabs.io/docs/overview" },
|
|
1019
|
+
{ id: "suno", name: "Suno", description: "M\xFAsica generada con Suno; el acceso API depende de tu cuenta.", capabilities: ["music"], accessMethods: ["browser", "api", "files"], docsUrl: "https://platform.suno.com/" },
|
|
1020
|
+
{ id: "openai-images", name: "OpenAI Images", description: "Creaci\xF3n y edici\xF3n de im\xE1genes para tu contenido.", capabilities: ["image"], accessMethods: ["agent", "api", "browser", "files"], docsUrl: "https://platform.openai.com/docs/guides/image-generation" },
|
|
1021
|
+
{ id: "pexels", name: "Pexels", description: "Fotos y videos de stock; revisa la licencia y el contexto de uso.", capabilities: ["stock-image", "stock-video"], accessMethods: ["agent", "api", "browser", "files"], docsUrl: "https://www.pexels.com/api/documentation/" },
|
|
1022
|
+
{ id: "assemblyai", name: "AssemblyAI", description: "Transcripci\xF3n con marcas de tiempo para planificar cortes y subt\xEDtulos.", capabilities: ["transcription"], accessMethods: ["agent", "api", "files"], docsUrl: "https://www.assemblyai.com/docs" }
|
|
1023
|
+
];
|
|
1024
|
+
function object2(value) {
|
|
1025
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
1026
|
+
}
|
|
1027
|
+
function knownFields(value, allowed) {
|
|
1028
|
+
if (Object.keys(value).some((key) => !allowed.includes(key))) throw new Error("Unknown integration field. Credentials and user/workspace IDs are not accepted.");
|
|
1029
|
+
}
|
|
1030
|
+
function containsCredential(value) {
|
|
1031
|
+
return /-----BEGIN [A-Z ]*PRIVATE KEY-----|\bBearer\s+\S{12,}|\b(?:sk-(?:proj-)?|AIza|cfat_|apikey_|tlk_)[A-Za-z0-9_-]{15,}|\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+|(?:api[_ -]?key|access[_ -]?token|secret|password)\s*[:=]\s*["']?\S{8,}/i.test(value);
|
|
1032
|
+
}
|
|
1033
|
+
function validateIntegrationUpdate(providerId, input) {
|
|
1034
|
+
const provider = integrationCatalog.find((entry) => entry.id === providerId);
|
|
1035
|
+
if (!provider) throw new Error("Unknown integration provider.");
|
|
1036
|
+
if (!object2(input)) throw new Error("Integration settings must be an object.");
|
|
1037
|
+
knownFields(input, ["enabled", "accessMethod", "preferences", "expectedRevision"]);
|
|
1038
|
+
if (typeof input.enabled !== "boolean") throw new Error("enabled must be a boolean.");
|
|
1039
|
+
if (!provider.accessMethods.includes(input.accessMethod)) throw new Error("Unsupported access method for this provider.");
|
|
1040
|
+
if (!Number.isSafeInteger(input.expectedRevision) || input.expectedRevision < 0) throw new Error("The current expectedRevision is required.");
|
|
1041
|
+
if (!object2(input.preferences)) throw new Error("preferences must be an object.");
|
|
1042
|
+
knownFields(input.preferences, ["instructions", "model", "voiceId", "language", "musicMode", "spending"]);
|
|
1043
|
+
const preferences = {};
|
|
1044
|
+
for (const [field, max] of [["instructions", 1e3], ["model", 120], ["voiceId", 120], ["language", 60]]) {
|
|
1045
|
+
const value = input.preferences[field];
|
|
1046
|
+
if (value === void 0) continue;
|
|
1047
|
+
if (typeof value !== "string" || value.length > max || /[\u0000-\u0008\u000b\u000c\u000e-\u001f]/.test(value)) throw new Error(`${field} must be text of at most ${max} characters.`);
|
|
1048
|
+
if (containsCredential(value)) throw new Error("Credentials must stay in your provider connection, never in integration preferences.");
|
|
1049
|
+
if (value.trim()) preferences[field] = value.trim();
|
|
1050
|
+
}
|
|
1051
|
+
const musicMode = input.preferences.musicMode, spending = input.preferences.spending;
|
|
1052
|
+
if (musicMode !== void 0) {
|
|
1053
|
+
if (!provider.capabilities.includes("music") || !["instrumental", "vocals", "either"].includes(musicMode)) throw new Error("musicMode is only available for music integrations.");
|
|
1054
|
+
preferences.musicMode = musicMode;
|
|
1055
|
+
}
|
|
1056
|
+
if (preferences.voiceId && !provider.capabilities.includes("voiceover")) throw new Error("voiceId is only available for voice integrations.");
|
|
1057
|
+
if (spending !== void 0 && !["ask", "use-approved-budget"].includes(spending)) throw new Error("Invalid spending preference.");
|
|
1058
|
+
preferences.spending = spending ?? "ask";
|
|
1059
|
+
return { enabled: input.enabled, accessMethod: input.accessMethod, preferences, expectedRevision: input.expectedRevision };
|
|
201
1060
|
}
|
|
202
1061
|
|
|
203
1062
|
// packages/core/src/index.ts
|
|
@@ -214,7 +1073,7 @@ var ValidationError = class extends Error {
|
|
|
214
1073
|
var identifier2 = /^[a-z0-9][a-z0-9_-]{0,79}$/;
|
|
215
1074
|
var tokenName = /^[a-z][a-z0-9_.-]{0,79}$/;
|
|
216
1075
|
var own2 = (value, key) => Object.prototype.hasOwnProperty.call(value, key);
|
|
217
|
-
var
|
|
1076
|
+
var object3 = (value) => !!value && typeof value === "object" && !Array.isArray(value);
|
|
218
1077
|
var nonempty2 = (value) => typeof value === "string" && value.trim().length > 0;
|
|
219
1078
|
var finite = (value) => typeof value === "number" && Number.isFinite(value);
|
|
220
1079
|
var tokenValue = (value) => typeof value === "string" || typeof value === "boolean" || finite(value) || Array.isArray(value) && value.every((v) => typeof v === "string");
|
|
@@ -231,13 +1090,13 @@ function safeDocumentPath(path) {
|
|
|
231
1090
|
return path;
|
|
232
1091
|
}
|
|
233
1092
|
function checkRules(value, add) {
|
|
234
|
-
if (!
|
|
1093
|
+
if (!object3(value)) {
|
|
235
1094
|
add("tokens must be an object");
|
|
236
1095
|
return;
|
|
237
1096
|
}
|
|
238
1097
|
for (const [key, rule] of Object.entries(value)) {
|
|
239
1098
|
if (!tokenName.test(key) || ["__proto__", "constructor", "prototype"].includes(key)) add(`Invalid semantic token ${key}`);
|
|
240
|
-
if (!
|
|
1099
|
+
if (!object3(rule) || !tokenValue(rule.value) || !["required", "preferred", "example"].includes(String(rule.strength))) add(`Token ${key} requires value and strength required/preferred/example`);
|
|
241
1100
|
}
|
|
242
1101
|
}
|
|
243
1102
|
function checkEvidence(value, add) {
|
|
@@ -246,7 +1105,7 @@ function checkEvidence(value, add) {
|
|
|
246
1105
|
return;
|
|
247
1106
|
}
|
|
248
1107
|
for (const ref of value) {
|
|
249
|
-
if (!
|
|
1108
|
+
if (!object3(ref) || !nonempty2(ref.videoId) || !["reference_only", "licensed_asset", "owned_asset"].includes(String(ref.relationship))) add("Evidence must include videoId and explicit relationship");
|
|
250
1109
|
else {
|
|
251
1110
|
if (ref.timestampSeconds !== void 0 && (!finite(ref.timestampSeconds) || ref.timestampSeconds < 0)) add("Evidence timestamp must be nonnegative seconds");
|
|
252
1111
|
if (ref.reviewStatus !== void 0 && !["unreviewed", "agent_reviewed", "human_verified"].includes(String(ref.reviewStatus))) add("Invalid evidence reviewStatus");
|
|
@@ -267,7 +1126,16 @@ function validateDocument(path, content) {
|
|
|
267
1126
|
return { valid: false, issues };
|
|
268
1127
|
}
|
|
269
1128
|
if (content.includes("\0")) add("NUL bytes are not allowed");
|
|
270
|
-
if (path.endsWith(".md"))
|
|
1129
|
+
if (path.endsWith(".md")) {
|
|
1130
|
+
if (/^\.editorbrain\/brands\/[^/]+\/design\.md$/.test(canonicalDocumentPath(path))) {
|
|
1131
|
+
try {
|
|
1132
|
+
readProfile(content);
|
|
1133
|
+
} catch (error) {
|
|
1134
|
+
add(error.message);
|
|
1135
|
+
}
|
|
1136
|
+
}
|
|
1137
|
+
return { valid: issues.length === 0, issues };
|
|
1138
|
+
}
|
|
271
1139
|
let doc;
|
|
272
1140
|
try {
|
|
273
1141
|
doc = JSON.parse(content);
|
|
@@ -275,7 +1143,7 @@ function validateDocument(path, content) {
|
|
|
275
1143
|
add("Invalid JSON");
|
|
276
1144
|
return { valid: false, issues };
|
|
277
1145
|
}
|
|
278
|
-
if (!
|
|
1146
|
+
if (!object3(doc)) {
|
|
279
1147
|
add("JSON document must be an object");
|
|
280
1148
|
return { valid: false, issues };
|
|
281
1149
|
}
|
|
@@ -299,19 +1167,20 @@ function validateDocument(path, content) {
|
|
|
299
1167
|
if (isStyle) {
|
|
300
1168
|
if (!nonempty2(doc.name)) add("Style name is required");
|
|
301
1169
|
if (doc.references !== void 0) checkEvidence(doc.references, add);
|
|
302
|
-
if (doc.components !== void 0 && (!Array.isArray(doc.components) || doc.components.some((c) => !
|
|
1170
|
+
if (doc.components !== void 0 && (!Array.isArray(doc.components) || doc.components.some((c) => !object3(c) || !nonempty2(c.id) || !["concept", "implemented", "transfer_tested"].includes(String(c.status))))) add("Components need id and explicit implementation status");
|
|
303
1171
|
}
|
|
304
1172
|
}
|
|
305
1173
|
if (isProject) {
|
|
306
1174
|
for (const key of ["id", "brand", "style", "outputProfile"]) if (typeof doc[key] !== "string" || !identifier2.test(doc[key])) add(`${key} must be a valid identifier`);
|
|
307
|
-
if (!
|
|
1175
|
+
if (!object3(doc.intent) || !nonempty2(doc.intent.topic) || !nonempty2(doc.intent.goal)) add("intent requires topic and goal");
|
|
308
1176
|
if (doc.reviewMode !== void 0 && !["direct", "directions", "sample"].includes(doc.reviewMode)) add("reviewMode must be direct, directions or sample");
|
|
1177
|
+
if (doc.mediaStyleOverrides !== void 0) for (const issue of mediaStyleOverrideIssues(doc.mediaStyleOverrides)) add(issue);
|
|
309
1178
|
if (doc.script !== void 0 && typeof doc.script !== "string") add("script must be text");
|
|
310
1179
|
if (doc.overrides !== void 0) {
|
|
311
|
-
if (!
|
|
1180
|
+
if (!object3(doc.overrides)) add("overrides must be an object");
|
|
312
1181
|
else for (const [key, rule] of Object.entries(doc.overrides)) {
|
|
313
1182
|
if (!tokenName.test(key) || ["__proto__", "constructor", "prototype"].includes(key)) add(`Invalid override token ${key}`);
|
|
314
|
-
if (!
|
|
1183
|
+
if (!object3(rule) || !tokenValue(rule.value) || !nonempty2(rule.reason) || rule.overrideRequired !== void 0 && typeof rule.overrideRequired !== "boolean") add(`Override ${key} requires value and a reason; overrideRequired must be boolean`);
|
|
315
1184
|
}
|
|
316
1185
|
}
|
|
317
1186
|
}
|
|
@@ -321,7 +1190,7 @@ function validateDocument(path, content) {
|
|
|
321
1190
|
else {
|
|
322
1191
|
const ids = /* @__PURE__ */ new Set();
|
|
323
1192
|
for (const asset of doc.assets) {
|
|
324
|
-
if (!
|
|
1193
|
+
if (!object3(asset) || typeof asset.id !== "string" || !/^[a-f0-9]{64}$/.test(asset.id) || !nonempty2(asset.name) || typeof asset.mimeType !== "string" || !/^(video|image|audio)\/[a-zA-Z0-9][a-zA-Z0-9.+-]*$/.test(asset.mimeType) || !Number.isSafeInteger(asset.bytes) || Number(asset.bytes) < 1) {
|
|
325
1194
|
add("Production assets require a SHA-256 id, name, media mimeType and positive integer bytes");
|
|
326
1195
|
continue;
|
|
327
1196
|
}
|
|
@@ -335,7 +1204,7 @@ function validateDocument(path, content) {
|
|
|
335
1204
|
for (const key of ["width", "height"]) if (!Number.isInteger(doc[key]) || Number(doc[key]) < 1 || Number(doc[key]) > 16384) add(`${key} must be an integer between 1 and 16384`);
|
|
336
1205
|
if (!finite(doc.fps) || doc.fps <= 0 || doc.fps > 240) add("fps must be between 0 and 240");
|
|
337
1206
|
if (doc.safeAreas !== void 0) {
|
|
338
|
-
if (!
|
|
1207
|
+
if (!object3(doc.safeAreas) || ["top", "right", "bottom", "left"].some((k) => !finite(doc.safeAreas[k]) || Number(doc.safeAreas[k]) < 0 || Number(doc.safeAreas[k]) >= 0.5)) add("safeAreas must have normalized fractions from 0 up to (not including) 0.5 for all four sides");
|
|
339
1208
|
}
|
|
340
1209
|
}
|
|
341
1210
|
return { valid: issues.length === 0, issues };
|
|
@@ -370,7 +1239,7 @@ function validateWorkspace(files) {
|
|
|
370
1239
|
return { valid: issues.length === 0, issues };
|
|
371
1240
|
}
|
|
372
1241
|
function stableJson(value) {
|
|
373
|
-
const sort = (item) => Array.isArray(item) ? item.map(sort) :
|
|
1242
|
+
const sort = (item) => Array.isArray(item) ? item.map(sort) : object3(item) ? Object.fromEntries(Object.keys(item).sort().map((key) => [key, sort(item[key])])) : item;
|
|
374
1243
|
return JSON.stringify(sort(value), null, 2) + "\n";
|
|
375
1244
|
}
|
|
376
1245
|
async function sha256(content) {
|
|
@@ -423,6 +1292,7 @@ async function resolveProject(files, projectId) {
|
|
|
423
1292
|
if (!files[directionPath].trim() || files[directionPath].includes("DIRECTION_PENDING")) warnings.push("Creative direction is pending: the host agent or editor must write a coherent plan for the actual material.");
|
|
424
1293
|
for (const component of style.components ?? []) if (component.status !== "transfer_tested") warnings.push(`Component ${component.id} is ${component.status}; transfer fidelity is not verified.`);
|
|
425
1294
|
const sourceHashes = Object.fromEntries(await Promise.all(sourcePaths.map(async (source) => [source, await sha256(files[source])])));
|
|
1295
|
+
const editorialProfile = readProfile(files[optionalPaths[0]] ?? "");
|
|
426
1296
|
return {
|
|
427
1297
|
schemaVersion: 1,
|
|
428
1298
|
project,
|
|
@@ -437,6 +1307,7 @@ async function resolveProject(files, projectId) {
|
|
|
437
1307
|
...own2(files, optionalPaths[3]) ? { media: JSON.parse(files[optionalPaths[3]]) } : {},
|
|
438
1308
|
...own2(files, optionalPaths[7]) ? { styleResources: JSON.parse(files[optionalPaths[7]]) } : {},
|
|
439
1309
|
memory: resolveMemory(files, project),
|
|
1310
|
+
mediaPreferences: resolveMediaPreferences(editorialProfile.mediaStyles, project.mediaStyleOverrides, editorialProfile.visualTaste, editorialProfile.motionSelection, editorialProfile.motionProduction, editorialProfile.editorialDelivery, editorialProfile.captions === "Sin subt\xEDtulos" || output2.captions?.enabled === false ? false : void 0),
|
|
440
1311
|
evidence: style.references ?? [],
|
|
441
1312
|
warnings,
|
|
442
1313
|
lock: { schemaVersion: 1, projectId, sourceHashes, overrides: project.overrides ?? {}, adapters: Object.fromEntries(["neutral", "hyperframes", "remotion"].map((key) => [key, { version: "0.2.1", kind: "editorial_handoff", rendered: false }])) }
|
|
@@ -458,6 +1329,10 @@ ${stableJson(resolved.media)}\`\`\``) : "";
|
|
|
458
1329
|
|
|
459
1330
|
\`\`\`json
|
|
460
1331
|
${stableJson(resolved.styleResources)}\`\`\``) : "";
|
|
1332
|
+
const mediaStyleSection = resolved.mediaPreferences.editorialDelivery || resolved.mediaPreferences.motionSelectionSource !== "implicit" || resolved.mediaPreferences.motionProductionSource !== "implicit" || Object.keys(resolved.mediaPreferences.taste).length || resolved.mediaPreferences.image.selections.length || resolved.mediaPreferences.motion.selections.length || resolved.mediaPreferences.image.mode !== "inherit" || resolved.mediaPreferences.motion.mode !== "inherit" ? section("Media style preferences", `Adaptive motion: choose per scene, using favorites as guidance and excluding avoided styles; no animation is also valid. Guided motion: start from saved choices, checking scene fit. Production auto/code/ai is separate: code can build complete JavaScript scenes; ai prefers generated clips only with verified personal tool access and existing authorization. Disabled categories remain disabled. Retrieve only relevant recipes by ID and version. Required brand tokens govern.
|
|
1333
|
+
|
|
1334
|
+
\`\`\`json
|
|
1335
|
+
${stableJson(resolved.mediaPreferences)}\`\`\``) : "";
|
|
461
1336
|
if (adapter === "neutral") return { ...common, "brief.md": `# ${resolved.project.id}
|
|
462
1337
|
|
|
463
1338
|
${disclaimer}
|
|
@@ -474,7 +1349,7 @@ ${resolved.editorial.brand}
|
|
|
474
1349
|
|
|
475
1350
|
## Style
|
|
476
1351
|
|
|
477
|
-
${resolved.editorial.style}${section("Design", resolved.editorial.design)}${section("Effects", resolved.editorial.effects)}${section("Music and sound", resolved.editorial.music)}${mediaSection}${resourcesSection}
|
|
1352
|
+
${resolved.editorial.style}${section("Design", resolved.editorial.design)}${section("Effects", resolved.editorial.effects)}${section("Music and sound", resolved.editorial.music)}${mediaSection}${resourcesSection}${mediaStyleSection}
|
|
478
1353
|
|
|
479
1354
|
Exact tokens, output settings and evidence are in resolved.json.
|
|
480
1355
|
` };
|
|
@@ -502,13 +1377,13 @@ ${resolved.editorial.direction}
|
|
|
502
1377
|
|
|
503
1378
|
## Style intent
|
|
504
1379
|
|
|
505
|
-
${resolved.editorial.style}${section("Effects", resolved.editorial.effects)}${section("Music and sound", resolved.editorial.music)}${mediaSection}${resourcesSection}
|
|
1380
|
+
${resolved.editorial.style}${section("Effects", resolved.editorial.effects)}${section("Music and sound", resolved.editorial.music)}${mediaSection}${resourcesSection}${mediaStyleSection}
|
|
506
1381
|
|
|
507
1382
|
## Delivery
|
|
508
1383
|
|
|
509
1384
|
${resolved.output.width} \xD7 ${resolved.output.height}, ${resolved.output.fps} fps. Adapt composition to the actual material and safe areas in resolved.json. Implement and preview the composition in Hyperframes before claiming completion.
|
|
510
1385
|
` };
|
|
511
|
-
if (adapter === "remotion") return { ...common, "remotion-plan.json": stableJson({ schemaVersion: 1, kind: "editorial_handoff", renderer: "remotion", executable: false, note: disclaimer, composition: { width: resolved.output.width, height: resolved.output.height, fps: resolved.output.fps, durationInFrames: null }, tokens: resolved.tokens, project: resolved.project, direction: resolved.editorial.direction, editorial: resolved.editorial, ...resolved.media ? { media: resolved.media } : {}, ...resolved.styleResources ? { styleResources: resolved.styleResources } : {}, evidence: resolved.evidence, implementationTasks: ["Determine duration from the script/audio and actual cuts.", "Implement React composition and assets.", "Preview and verify typography, safe areas, motion and audio."] }) };
|
|
1386
|
+
if (adapter === "remotion") return { ...common, "remotion-plan.json": stableJson({ schemaVersion: 1, kind: "editorial_handoff", renderer: "remotion", executable: false, note: disclaimer, composition: { width: resolved.output.width, height: resolved.output.height, fps: resolved.output.fps, durationInFrames: null }, tokens: resolved.tokens, project: resolved.project, direction: resolved.editorial.direction, editorial: resolved.editorial, mediaPreferences: resolved.mediaPreferences, ...resolved.media ? { media: resolved.media } : {}, ...resolved.styleResources ? { styleResources: resolved.styleResources } : {}, evidence: resolved.evidence, implementationTasks: ["Determine duration from the script/audio and actual cuts.", "Implement React composition and assets.", "Preview and verify typography, safe areas, motion and audio."] }) };
|
|
512
1387
|
throw new Error(`Unsupported adapter: ${adapter}`);
|
|
513
1388
|
}
|
|
514
1389
|
function createWorkspace(options = {}) {
|
|
@@ -985,7 +1860,7 @@ import { createHash } from "node:crypto";
|
|
|
985
1860
|
import { constants as constants2 } from "node:fs";
|
|
986
1861
|
import { lstat as lstat2, open as open2, readdir as readdir2, rename as rename2 } from "node:fs/promises";
|
|
987
1862
|
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"] };
|
|
1863
|
+
var previousSkillHashes = { "SKILL.md": ["9df2c89deeec30ddf556c359027efe1d90e6b4e9c08717cd8827e210b38a187f", "67b157cb45cae3621e6f2c43235c1d69907ad6abd45ab6b52a3c65f236732db9", "1e2fff65a1b303b18b2d65156bef6eb2439a19d77ba2a25b37bcb1819ff009e3", "dacce1c4127f4281df6f7e1681e243444bb6f8a8e74b829e52ee0108c6672348", "09740ab4b275eada65b5040ae6000104ff39094f534a2784e67feb2afc2a59b3", "f18269ad5504fff8a4fc522d463483261f5041e56cb7f1081f9614bc67e0d96f", "5779a28879313070b0d819771ac967d4a133cff1c3dbda2ec44b4dd2abcb17d1", "30f1e64622e70552a1a6129cacc646f56b09b3d7a1fd3f5bf22299bc6bfd0d1e", "6975d917226f75ad55c67543bf7f1c5ef18f4cfe43426c69400c56c1ed497db3"], "AGENTS.md": ["4f3ef860ade3a31aa7c6a6aab5cf6ec2a700850bf19ea2320324c12c2e0fbf5b", "1b07de01698a0040b47ea0eddb1b017f70a69e81acd0bf9710647ca5e02f5125", "b5ee3439ff974c668755816149fab76899d7ad9266f63978b30fbc9b7d2cd640", "bacedb7bdedab5abe75c9601717cfdb05a96208fa2e2a42af878e2dfd2ae4251", "ea3b473179857ccc07e9e4f21a6ede9e0906a09e495f4a051069d15aee1556cd", "faa2bba99ae3023be7f588d02177a4abf583d3a8a65f617b8cff7a3ceed09dfc", "83c75394bc7eaab6662a76d89339361e836cc1a988237720eeeacdd6b62e726f", "b685cb7ff21d5bfe19d96ce0139c17b7eb014b7ecda8ec5321558b0004bfdc71"], "references/cli-mcp.md": ["5d3a79343fbd76cdae5bdbe12fb9c944d5dff4f489ac6534fd96e0cb8630b988", "8e011c31368cef2117a28ee972b39aab9535eeb0f601a4a0f9d6fc019b22c1c2", "666082653efc000f8a7cc5f1836c80bbdda5f0f57cdbbdcb393f2b84aeb93a01", "78b737796a8685b1135e0acb91b620c51845b62e31dba04eb51dd9dd38b41008", "8b2e01bfd937237919f5ade794eed01dc6e32b556ede9de3a0ccc28cbdd988d1", "ca88b2816a795a5ebe834074b178469367634b80ad837e3697182ea585af1c05", "490daf154b428b536d0a6f590030c854b2fc8ca6adb995c6ecd5a162f85e471e", "9f98affecd69752c17f52fb51ae74b26a1cadea5919b8b74b7865a20661c4418", "19fc85e92cef203f4162e2bfb2dd2df8ef90472bc8f81dfb568b65a714335ff9"], "references/production-and-review.md": ["055c6df9e0f167be148b0a5c0bba5b43e108eb1f58f478d88b3536c6589ac7b3", "4a4035b87a6c024b419847ab8592bddfeb9b2d9c184f65880c47d661f0bb250e"], "references/workspace-files.md": ["e72af2871f42cd776be1bfff8009ed5562780e7f8a65b12c27887f3880631f81", "00780c64bd0244c6afb18d786b7512b43e315c376e8f88c67d6ba53d91275ccf", "97053b092e0ff36e3cfb764095fcb4a4fca94e722567e774b53bc118279f40e5", "70fc660e7ed3d70c16c33c62c8e0399b99c3251808f093ef3d4ea5e8d39e4c8a", "28c62c1e28842142607f5168ee79b18fd797900dd66dd9ac5eb3cfcf8a09f781", "b86eae85cf86dbf4409b10f4d8df1f1649fc408ce1a240f7cf26d5bb69aabfd1", "fc65f343e50b91327653bdc5d94f35af9c86c097bb46810b4b2f7c9a1c200166"], "references/first-edit.md": ["cb1e9d3272b2b22ae095aed02e33067c646da48e7b64cf71ff81197f6e67b673", "5a02253d9907f5e6705304565da7b2d1787e84ae17616414b295a8526ec3e4a5"], "references/memory-and-review.md": ["51a274e522270f1fc295d0c0b9a1c23672ce01abea097c2499c9a8db56c0c289"], "references/search-brief.md": ["137203383a7873593136428520f5f9cd6ce1ab6cfeab365c277175786eba3ab3", "df93154562bb89a9f22d31260c17406565f5f308c407643c85d15e528ef2c086"], "references/style-creator.md": ["1b1bc289a5bc308cbbf7f4d699de2933b8934733a2b69b3e98fb1b7420d986f2"], "references/local-production.md": ["0a13c65cd60e40ba703095f5e33a1ea66433323b38345c2034fb73c602c438ee"], "references/integrations.md": ["a3500dd79c71ab3ee80a865bb5bf8c94287efb933e07d577738891e276a819a0"], "references/media-preferences.md": ["970b97fbd55b8e9e05fd7e51b4a953cd1b96b9bee7830382aa7514f1b240b3aa", "481c643a5e6516413a6cae970b5170573c981d0effd7d4e146c5cd07c0ee3b1f", "8f11e1956ed06ab73ba58d8c880f0d2292eb76829b07d2de2eaaed1965885158", "e94bc13c05ffd57eceecc4ceaa42df2b83904c12f8c552e161c98f13e6427e5b"] };
|
|
989
1864
|
var destinations = {
|
|
990
1865
|
codex: { directory: ".agents/skills/editorbrain", entryPoint: "AGENTS.md" },
|
|
991
1866
|
claude: { directory: ".claude/skills/editorbrain", entryPoint: "CLAUDE.md" }
|
|
@@ -1034,6 +1909,9 @@ async function loadSkill(directory) {
|
|
|
1034
1909
|
if (!files.has("SKILL.md")) throw new Error("Bundled EditorBrain skill is missing SKILL.md. Reinstall the CLI package.");
|
|
1035
1910
|
return files;
|
|
1036
1911
|
}
|
|
1912
|
+
async function bundledGuidanceFiles() {
|
|
1913
|
+
return [...(await loadSkill(bundledSkillDirectory())).keys()];
|
|
1914
|
+
}
|
|
1037
1915
|
async function prepareAgentGuidance(root, agent = "both", sourceDirectory = bundledSkillDirectory()) {
|
|
1038
1916
|
root = await ensureRoot(root);
|
|
1039
1917
|
parseAgentTarget(agent);
|
|
@@ -1175,7 +2053,6 @@ async function cliVersion() {
|
|
|
1175
2053
|
const metadata = JSON.parse(await readFile(new URL("../package.json", import.meta.url), "utf8"));
|
|
1176
2054
|
return metadata.version;
|
|
1177
2055
|
}
|
|
1178
|
-
var guidanceFiles = ["SKILL.md", "AGENTS.md", "references/cli-mcp.md", "references/production-and-review.md", "references/workspace-files.md"];
|
|
1179
2056
|
async function diagnose(options, fetcher = fetch) {
|
|
1180
2057
|
const checks = [];
|
|
1181
2058
|
let root;
|
|
@@ -1203,7 +2080,7 @@ async function diagnose(options, fetcher = fetch) {
|
|
|
1203
2080
|
const directory = agent === "codex" ? ".agents/skills/editorbrain" : ".claude/skills/editorbrain";
|
|
1204
2081
|
let different = false;
|
|
1205
2082
|
try {
|
|
1206
|
-
for (const file of
|
|
2083
|
+
for (const file of await bundledGuidanceFiles()) {
|
|
1207
2084
|
const current = await readText(root, `${directory}/${file}`, false);
|
|
1208
2085
|
const bundled = await readFile(resolve2(bundledSkillDirectory(), file), "utf8");
|
|
1209
2086
|
if (current !== bundled) different = true;
|
|
@@ -1345,10 +2222,10 @@ async function setup(options, runner = runHost) {
|
|
|
1345
2222
|
}
|
|
1346
2223
|
|
|
1347
2224
|
// packages/cli/src/index.ts
|
|
1348
|
-
import { chmod, lstat as
|
|
1349
|
-
import { constants as
|
|
2225
|
+
import { chmod, lstat as lstat7, mkdir as mkdir5, open as open8 } from "node:fs/promises";
|
|
2226
|
+
import { constants as constants8 } from "node:fs";
|
|
1350
2227
|
import { homedir } from "node:os";
|
|
1351
|
-
import { basename, dirname as
|
|
2228
|
+
import { basename as basename2, dirname as dirname6, extname as extname3, join as join3, resolve as resolve6 } from "node:path";
|
|
1352
2229
|
|
|
1353
2230
|
// packages/cli/src/device-auth.ts
|
|
1354
2231
|
import { spawn as spawn2 } from "node:child_process";
|
|
@@ -1369,12 +2246,12 @@ function browserCommand(url, platform = process.platform) {
|
|
|
1369
2246
|
}
|
|
1370
2247
|
async function openSystemBrowser(url) {
|
|
1371
2248
|
const { command, args } = browserCommand(url);
|
|
1372
|
-
await new Promise((
|
|
2249
|
+
await new Promise((resolve7, reject) => {
|
|
1373
2250
|
const child = spawn2(command, args, { shell: false, stdio: "ignore", detached: true, windowsHide: true });
|
|
1374
2251
|
child.once("error", reject);
|
|
1375
2252
|
child.once("spawn", () => {
|
|
1376
2253
|
child.unref();
|
|
1377
|
-
|
|
2254
|
+
resolve7();
|
|
1378
2255
|
});
|
|
1379
2256
|
});
|
|
1380
2257
|
}
|
|
@@ -1387,7 +2264,7 @@ function expired() {
|
|
|
1387
2264
|
function invalid() {
|
|
1388
2265
|
return new DeviceLoginError("invalid_response", "The authorization server returned an invalid response. No credential was saved.");
|
|
1389
2266
|
}
|
|
1390
|
-
function
|
|
2267
|
+
function record4(value) {
|
|
1391
2268
|
return !!value && typeof value === "object" && !Array.isArray(value);
|
|
1392
2269
|
}
|
|
1393
2270
|
function abortCheck(signal) {
|
|
@@ -1435,11 +2312,11 @@ async function responseJson(response) {
|
|
|
1435
2312
|
}
|
|
1436
2313
|
}
|
|
1437
2314
|
function sessionFrom(value) {
|
|
1438
|
-
if (!
|
|
2315
|
+
if (!record4(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
2316
|
return { deviceCode: value.deviceCode, userCode: value.userCode, verificationUri: verificationUrl(value.verificationUri, value.userCode, value.deviceCode), expiresIn: Number(value.expiresIn), interval: Number(value.interval) };
|
|
1440
2317
|
}
|
|
1441
2318
|
function authorizationFrom(value) {
|
|
1442
|
-
if (!
|
|
2319
|
+
if (!record4(value) || typeof value.apiKey !== "string" || !/^eh_[a-f0-9]{64}$/.test(value.apiKey) || value.role !== "editor" || !record4(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
2320
|
return { apiKey: value.apiKey, workspace: { id: value.workspace.id, name: value.workspace.name }, role: "editor" };
|
|
1444
2321
|
}
|
|
1445
2322
|
async function loginWithDevice(options, overrides = {}) {
|
|
@@ -1463,7 +2340,7 @@ async function loginWithDevice(options, overrides = {}) {
|
|
|
1463
2340
|
throw new DeviceLoginError("network_error", "The authorization server could not be reached. No credential was saved.");
|
|
1464
2341
|
}
|
|
1465
2342
|
};
|
|
1466
|
-
const
|
|
2343
|
+
const parse2 = async (response) => {
|
|
1467
2344
|
try {
|
|
1468
2345
|
return await responseJson(response);
|
|
1469
2346
|
} catch (error) {
|
|
@@ -1475,7 +2352,7 @@ async function loginWithDevice(options, overrides = {}) {
|
|
|
1475
2352
|
};
|
|
1476
2353
|
const started = await post("/v1/auth/device", {});
|
|
1477
2354
|
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
|
|
2355
|
+
const session = sessionFrom(await parse2(started));
|
|
1479
2356
|
deadline = Math.min(deadline, runtime.now() + session.expiresIn * 1e3);
|
|
1480
2357
|
const { deviceCode, ...challenge } = session;
|
|
1481
2358
|
options.onChallenge?.(challenge);
|
|
@@ -1502,7 +2379,7 @@ async function loginWithDevice(options, overrides = {}) {
|
|
|
1502
2379
|
if (runtime.now() >= deadline) throw expired();
|
|
1503
2380
|
const response = await post("/v1/auth/device/token", { deviceCode });
|
|
1504
2381
|
if (response.status === 200) {
|
|
1505
|
-
const result = authorizationFrom(await
|
|
2382
|
+
const result = authorizationFrom(await parse2(response));
|
|
1506
2383
|
abortCheck(options.signal);
|
|
1507
2384
|
return result;
|
|
1508
2385
|
}
|
|
@@ -1697,14 +2574,14 @@ async function checkLocalResource(root, path, expected) {
|
|
|
1697
2574
|
const before = await handle.stat();
|
|
1698
2575
|
if (!before.isFile()) throw new Error("Resource is not a regular file.");
|
|
1699
2576
|
if (!expected) return {};
|
|
1700
|
-
const
|
|
1701
|
-
for await (const chunk of handle.createReadStream({ autoClose: false }))
|
|
2577
|
+
const hash2 = createHash2("sha256");
|
|
2578
|
+
for await (const chunk of handle.createReadStream({ autoClose: false })) hash2.update(chunk);
|
|
1702
2579
|
const after = await handle.stat();
|
|
1703
2580
|
const current = await lstat4(await checkedPath(root, path, false));
|
|
1704
2581
|
if (before.size !== after.size || before.mtimeMs !== after.mtimeMs || before.ctimeMs !== after.ctimeMs || current.dev !== before.dev || current.ino !== before.ino) {
|
|
1705
2582
|
throw new Error("Resource changed while its hash was being checked; check it again.");
|
|
1706
2583
|
}
|
|
1707
|
-
return { actualSha256:
|
|
2584
|
+
return { actualSha256: hash2.digest("hex") };
|
|
1708
2585
|
} finally {
|
|
1709
2586
|
await handle.close();
|
|
1710
2587
|
}
|
|
@@ -1769,6 +2646,774 @@ async function checkLocalStyle(options) {
|
|
|
1769
2646
|
};
|
|
1770
2647
|
}
|
|
1771
2648
|
|
|
2649
|
+
// packages/cli/src/media-inventory.ts
|
|
2650
|
+
import { createHash as createHash4 } from "node:crypto";
|
|
2651
|
+
import { link, lstat as lstat6, mkdir as mkdir3, mkdtemp, opendir, readFile as readFile2, rm } from "node:fs/promises";
|
|
2652
|
+
import { basename, dirname as dirname4, extname as extname2, join as join2 } from "node:path";
|
|
2653
|
+
|
|
2654
|
+
// packages/cli/src/media-runtime.ts
|
|
2655
|
+
import { spawn as spawn3 } from "node:child_process";
|
|
2656
|
+
import { createHash as createHash3 } from "node:crypto";
|
|
2657
|
+
import { constants as constants5 } from "node:fs";
|
|
2658
|
+
import { lstat as lstat5, open as open5, realpath as realpath2 } from "node:fs/promises";
|
|
2659
|
+
import { dirname as dirname3, extname, parse, resolve as resolve4 } from "node:path";
|
|
2660
|
+
var MediaToolError = class extends CliError {
|
|
2661
|
+
constructor(code, message) {
|
|
2662
|
+
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.");
|
|
2663
|
+
this.name = "MediaToolError";
|
|
2664
|
+
}
|
|
2665
|
+
};
|
|
2666
|
+
var SUPPORTED_MEDIA_EXTENSIONS = /* @__PURE__ */ new Set([
|
|
2667
|
+
".mp4",
|
|
2668
|
+
".mov",
|
|
2669
|
+
".m4v",
|
|
2670
|
+
".mkv",
|
|
2671
|
+
".webm",
|
|
2672
|
+
".avi",
|
|
2673
|
+
".mpg",
|
|
2674
|
+
".mpeg",
|
|
2675
|
+
".mts",
|
|
2676
|
+
".m2ts",
|
|
2677
|
+
".ts",
|
|
2678
|
+
".ogv",
|
|
2679
|
+
".wmv",
|
|
2680
|
+
".wav",
|
|
2681
|
+
".mp3",
|
|
2682
|
+
".m4a",
|
|
2683
|
+
".aac",
|
|
2684
|
+
".flac",
|
|
2685
|
+
".ogg",
|
|
2686
|
+
".opus",
|
|
2687
|
+
".aif",
|
|
2688
|
+
".aiff",
|
|
2689
|
+
".ac3",
|
|
2690
|
+
".eac3",
|
|
2691
|
+
".jpg",
|
|
2692
|
+
".jpeg",
|
|
2693
|
+
".png",
|
|
2694
|
+
".webp",
|
|
2695
|
+
".bmp",
|
|
2696
|
+
".tif",
|
|
2697
|
+
".tiff",
|
|
2698
|
+
".gif",
|
|
2699
|
+
".avif",
|
|
2700
|
+
".heic",
|
|
2701
|
+
".heif"
|
|
2702
|
+
]);
|
|
2703
|
+
var PROTOCOLS = "file,pipe";
|
|
2704
|
+
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";
|
|
2705
|
+
var DEFAULT_TIMEOUT = 3e4;
|
|
2706
|
+
var DEFAULT_OUTPUT_LIMIT = 2 * 1024 * 1024;
|
|
2707
|
+
function resolveLocalMediaPath(value) {
|
|
2708
|
+
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("\\\\")) {
|
|
2709
|
+
throw new MediaToolError("MEDIA_LOCAL_PATH_REQUIRED", "Provide a local filesystem path, not a URL, protocol or network share.");
|
|
2710
|
+
}
|
|
2711
|
+
return resolve4(value);
|
|
2712
|
+
}
|
|
2713
|
+
async function assertNoMediaSymlinks(value) {
|
|
2714
|
+
const path = resolveLocalMediaPath(value);
|
|
2715
|
+
let cursor = path;
|
|
2716
|
+
while (cursor !== parse(cursor).root) {
|
|
2717
|
+
const info = await lstat5(cursor);
|
|
2718
|
+
if (info.isSymbolicLink()) {
|
|
2719
|
+
const systemAlias = process.platform === "darwin" && (cursor === "/tmp" || cursor === "/var") && await realpath2(cursor) === `/private${cursor}`;
|
|
2720
|
+
if (!systemAlias) throw new MediaToolError("MEDIA_SYMLINK", "Symlink media paths are not followed. Use the original local path.");
|
|
2721
|
+
}
|
|
2722
|
+
cursor = dirname3(cursor);
|
|
2723
|
+
}
|
|
2724
|
+
return path;
|
|
2725
|
+
}
|
|
2726
|
+
async function assertLocalMediaFile(value) {
|
|
2727
|
+
const path = await assertNoMediaSymlinks(value);
|
|
2728
|
+
const info = await lstat5(path);
|
|
2729
|
+
if (!info.isFile()) throw new MediaToolError("MEDIA_NOT_REGULAR", "The media input must be a regular local file.");
|
|
2730
|
+
if (!SUPPORTED_MEDIA_EXTENSIONS.has(extname(path).toLowerCase())) throw new MediaToolError("MEDIA_FORMAT_UNSUPPORTED", "This file extension is not supported for local media inspection.");
|
|
2731
|
+
return path;
|
|
2732
|
+
}
|
|
2733
|
+
function localInputArgs(path) {
|
|
2734
|
+
return ["-protocol_whitelist", PROTOCOLS, "-format_whitelist", FORMATS, "-i", resolveLocalMediaPath(path)];
|
|
2735
|
+
}
|
|
2736
|
+
async function runMediaTool(tool, args, options = {}) {
|
|
2737
|
+
if (tool !== "ffmpeg" && tool !== "ffprobe") throw new MediaToolError("MEDIA_TOOL_INVALID", "Unsupported media executable.");
|
|
2738
|
+
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT;
|
|
2739
|
+
const maxOutputBytes = options.maxOutputBytes ?? DEFAULT_OUTPUT_LIMIT;
|
|
2740
|
+
if (!Number.isInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > 10 * 6e4 || !Number.isInteger(maxOutputBytes) || maxOutputBytes < 1 || maxOutputBytes > 16 * 1024 * 1024) {
|
|
2741
|
+
throw new MediaToolError("MEDIA_TOOL_LIMITS_INVALID", "Media runtime limits are outside the supported range.");
|
|
2742
|
+
}
|
|
2743
|
+
if (!Array.isArray(args) || args.length > 200 || args.some((arg) => typeof arg !== "string" || arg.length > 8192 || arg.includes("\0"))) {
|
|
2744
|
+
throw new MediaToolError("MEDIA_TOOL_ARGUMENTS_INVALID", "Invalid media tool arguments.");
|
|
2745
|
+
}
|
|
2746
|
+
for (let i = 0; i < args.length; i++) {
|
|
2747
|
+
const arg = args[i];
|
|
2748
|
+
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") {
|
|
2749
|
+
throw new MediaToolError("MEDIA_REMOTE_INPUT_FORBIDDEN", "Only self-contained local media inputs are allowed.");
|
|
2750
|
+
}
|
|
2751
|
+
if (arg === "-protocol_whitelist" && args[i + 1] !== PROTOCOLS || arg === "-format_whitelist" && args[i + 1] !== FORMATS) {
|
|
2752
|
+
throw new MediaToolError("MEDIA_TOOL_ARGUMENTS_INVALID", "The local media allowlists cannot be widened.");
|
|
2753
|
+
}
|
|
2754
|
+
if (arg === "-i") {
|
|
2755
|
+
await assertLocalMediaFile(args[i + 1] ?? "");
|
|
2756
|
+
if (args[i - 4] !== "-protocol_whitelist" || args[i - 3] !== PROTOCOLS || args[i - 2] !== "-format_whitelist" || args[i - 1] !== FORMATS) {
|
|
2757
|
+
throw new MediaToolError("MEDIA_TOOL_ARGUMENTS_INVALID", "Every media input must use localInputArgs.");
|
|
2758
|
+
}
|
|
2759
|
+
}
|
|
2760
|
+
}
|
|
2761
|
+
return new Promise((resolveResult, reject) => {
|
|
2762
|
+
const stdout = [], stderr = [];
|
|
2763
|
+
let size = 0, done = false;
|
|
2764
|
+
const child = spawn3(tool, ["-hide_banner", ...tool === "ffmpeg" ? ["-nostdin"] : [], ...args], { shell: false, stdio: ["ignore", "pipe", "pipe"], windowsHide: true });
|
|
2765
|
+
const fail = (error) => {
|
|
2766
|
+
if (done) return;
|
|
2767
|
+
done = true;
|
|
2768
|
+
clearTimeout(timer);
|
|
2769
|
+
child.kill("SIGKILL");
|
|
2770
|
+
reject(error);
|
|
2771
|
+
};
|
|
2772
|
+
const timer = setTimeout(() => fail(new MediaToolError("MEDIA_TOOL_TIMEOUT", "The local media check exceeded its time limit. No complete result is available.")), timeoutMs);
|
|
2773
|
+
const collect = (target) => (chunk) => {
|
|
2774
|
+
if (done) return;
|
|
2775
|
+
size += chunk.length;
|
|
2776
|
+
if (size > maxOutputBytes) return fail(new MediaToolError("MEDIA_TOOL_OUTPUT_LIMIT", "The local media check exceeded its output limit. No complete result is available."));
|
|
2777
|
+
target.push(chunk);
|
|
2778
|
+
};
|
|
2779
|
+
child.stdout.on("data", collect(stdout));
|
|
2780
|
+
child.stderr.on("data", collect(stderr));
|
|
2781
|
+
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.")));
|
|
2782
|
+
child.on("close", (code) => {
|
|
2783
|
+
if (done) return;
|
|
2784
|
+
done = true;
|
|
2785
|
+
clearTimeout(timer);
|
|
2786
|
+
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."));
|
|
2787
|
+
resolveResult({ stdout: Buffer.concat(stdout).toString("utf8"), stderr: Buffer.concat(stderr).toString("utf8") });
|
|
2788
|
+
});
|
|
2789
|
+
});
|
|
2790
|
+
}
|
|
2791
|
+
var finite2 = (value) => {
|
|
2792
|
+
if (typeof value !== "number" && typeof value !== "string" || value === "") return null;
|
|
2793
|
+
const n = Number(value);
|
|
2794
|
+
return Number.isFinite(n) ? n : null;
|
|
2795
|
+
};
|
|
2796
|
+
var nonnegative2 = (value) => {
|
|
2797
|
+
const n = finite2(value);
|
|
2798
|
+
return n !== null && n >= 0 ? n : null;
|
|
2799
|
+
};
|
|
2800
|
+
var positiveInteger = (value) => {
|
|
2801
|
+
const n = finite2(value);
|
|
2802
|
+
return n !== null && Number.isInteger(n) && n > 0 ? n : void 0;
|
|
2803
|
+
};
|
|
2804
|
+
var rate = (value) => {
|
|
2805
|
+
if (typeof value !== "string" || !/^\d+(?:\/\d+)?$/.test(value)) return null;
|
|
2806
|
+
const [n, d = "1"] = value.split("/");
|
|
2807
|
+
const result = Number(n) / Number(d);
|
|
2808
|
+
return Number.isFinite(result) && result > 0 ? result : null;
|
|
2809
|
+
};
|
|
2810
|
+
var sameFile = (a, b) => a.dev === b.dev && a.ino === b.ino && a.size === b.size && a.mtimeMs === b.mtimeMs && a.ctimeMs === b.ctimeMs;
|
|
2811
|
+
async function probeLocalMedia(value) {
|
|
2812
|
+
const path = await assertLocalMediaFile(value);
|
|
2813
|
+
const handle = await open5(path, constants5.O_RDONLY | (constants5.O_NOFOLLOW ?? 0));
|
|
2814
|
+
try {
|
|
2815
|
+
const before = await handle.stat();
|
|
2816
|
+
if (!before.isFile()) throw new MediaToolError("MEDIA_NOT_REGULAR", "The media input must be a regular local file.");
|
|
2817
|
+
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";
|
|
2818
|
+
const { stdout } = await runMediaTool("ffprobe", ["-v", "error", "-show_entries", entries, "-of", "json", ...localInputArgs(path)]);
|
|
2819
|
+
const hash2 = createHash3("sha256");
|
|
2820
|
+
for await (const chunk of handle.createReadStream({ autoClose: false })) hash2.update(chunk);
|
|
2821
|
+
const after = await handle.stat();
|
|
2822
|
+
await assertNoMediaSymlinks(path);
|
|
2823
|
+
const current = await lstat5(path);
|
|
2824
|
+
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.");
|
|
2825
|
+
let raw;
|
|
2826
|
+
try {
|
|
2827
|
+
raw = JSON.parse(stdout);
|
|
2828
|
+
} catch {
|
|
2829
|
+
throw new MediaToolError("MEDIA_PROBE_INVALID", "The media probe returned invalid metadata.");
|
|
2830
|
+
}
|
|
2831
|
+
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.");
|
|
2832
|
+
const streams = raw.streams.map((stream, offset) => {
|
|
2833
|
+
const type = typeof stream.codec_type === "string" && /^(video|audio|subtitle|data|attachment|unknown)$/.test(stream.codec_type) ? stream.codec_type : "unknown";
|
|
2834
|
+
const codec = typeof stream.codec_name === "string" && /^[a-zA-Z0-9_]{1,64}$/.test(stream.codec_name) ? stream.codec_name : null;
|
|
2835
|
+
const index = finite2(stream.index);
|
|
2836
|
+
return {
|
|
2837
|
+
index: index !== null && Number.isInteger(index) && index >= 0 ? index : offset,
|
|
2838
|
+
type,
|
|
2839
|
+
codec,
|
|
2840
|
+
durationSeconds: nonnegative2(stream.duration),
|
|
2841
|
+
...type === "video" ? { width: positiveInteger(stream.width), height: positiveInteger(stream.height), fps: rate(stream.avg_frame_rate), attachedPicture: stream.disposition?.attached_pic === 1 } : {},
|
|
2842
|
+
...type === "audio" ? { sampleRate: nonnegative2(stream.sample_rate), channels: positiveInteger(stream.channels) ?? null } : {}
|
|
2843
|
+
};
|
|
2844
|
+
});
|
|
2845
|
+
return { path, sha256: hash2.digest("hex"), bytes: after.size, durationSeconds: nonnegative2(raw.format?.duration), startTimeSeconds: finite2(raw.format?.start_time), streams };
|
|
2846
|
+
} finally {
|
|
2847
|
+
await handle.close();
|
|
2848
|
+
}
|
|
2849
|
+
}
|
|
2850
|
+
|
|
2851
|
+
// packages/cli/src/media-inventory.ts
|
|
2852
|
+
var GENERATED_DIRECTORIES = /* @__PURE__ */ new Set(["node_modules", "dist", "build", "out", "output", "outputs", "renders", "render", "cache", "caches", "proxies", "proxy", "samples", "thumbnails", "coverage", "venv", "__pycache__"]);
|
|
2853
|
+
function mediaFailure(error) {
|
|
2854
|
+
if (error instanceof MediaToolError) return { code: error.code, message: error.message };
|
|
2855
|
+
const code = error?.code;
|
|
2856
|
+
if (code === "ENOENT") return { code: "MEDIA_FILE_MISSING", message: "The local path is missing or changed during inspection." };
|
|
2857
|
+
if (code === "EACCES" || code === "EPERM") return { code: "MEDIA_FILE_UNREADABLE", message: "The local path cannot be read with the current filesystem permissions." };
|
|
2858
|
+
return { code: "MEDIA_INSPECTION_FAILED", message: "Local media inspection failed. No complete result is available for this path." };
|
|
2859
|
+
}
|
|
2860
|
+
async function inventoryMedia(options) {
|
|
2861
|
+
const maxFiles = options.maxFiles ?? 200;
|
|
2862
|
+
if (!Number.isInteger(maxFiles) || maxFiles < 1 || maxFiles > 1e3) throw new MediaToolError("MEDIA_LIMIT_INVALID", "maxFiles must be an integer between 1 and 1000.");
|
|
2863
|
+
const input = resolveLocalMediaPath(options.input);
|
|
2864
|
+
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 };
|
|
2865
|
+
const candidates = [];
|
|
2866
|
+
const skip = (path, reason, incomplete = false) => {
|
|
2867
|
+
report.skipped.push({ path, reason });
|
|
2868
|
+
if (incomplete) report.complete = false;
|
|
2869
|
+
};
|
|
2870
|
+
const visit = async (path, depth, explicit = false) => {
|
|
2871
|
+
if (report.scannedEntries >= report.limits.maxEntries) {
|
|
2872
|
+
skip(path, "entry_limit_reached", true);
|
|
2873
|
+
return;
|
|
2874
|
+
}
|
|
2875
|
+
report.scannedEntries++;
|
|
2876
|
+
try {
|
|
2877
|
+
const stat = await lstat6(path);
|
|
2878
|
+
if (stat.isSymbolicLink()) {
|
|
2879
|
+
if (explicit) throw new MediaToolError("MEDIA_SYMLINK", "Symlink inputs are not followed. Use the original local path.");
|
|
2880
|
+
skip(path, "symlink_not_followed");
|
|
2881
|
+
return;
|
|
2882
|
+
}
|
|
2883
|
+
if (!explicit && basename(path).startsWith(".")) {
|
|
2884
|
+
skip(path, "hidden_entry");
|
|
2885
|
+
return;
|
|
2886
|
+
}
|
|
2887
|
+
await assertNoMediaSymlinks(path);
|
|
2888
|
+
if (stat.isDirectory()) {
|
|
2889
|
+
if (!explicit && GENERATED_DIRECTORIES.has(basename(path).toLowerCase())) {
|
|
2890
|
+
skip(path, "generated_directory");
|
|
2891
|
+
return;
|
|
2892
|
+
}
|
|
2893
|
+
if (depth >= report.limits.maxDepth) {
|
|
2894
|
+
skip(path, "depth_limit_reached", true);
|
|
2895
|
+
return;
|
|
2896
|
+
}
|
|
2897
|
+
const directory = await opendir(path);
|
|
2898
|
+
for await (const entry of directory) {
|
|
2899
|
+
if (report.scannedEntries >= report.limits.maxEntries) {
|
|
2900
|
+
skip(path, "remaining_entries_not_scanned", true);
|
|
2901
|
+
break;
|
|
2902
|
+
}
|
|
2903
|
+
await visit(join2(path, entry.name), depth + 1);
|
|
2904
|
+
}
|
|
2905
|
+
} else if (!stat.isFile()) {
|
|
2906
|
+
if (explicit) throw new MediaToolError("MEDIA_NOT_REGULAR", "The explicit media input must be a regular local file or directory.");
|
|
2907
|
+
skip(path, "not_regular_file");
|
|
2908
|
+
} else if (!SUPPORTED_MEDIA_EXTENSIONS.has(extname2(path).toLowerCase())) {
|
|
2909
|
+
if (explicit) throw new MediaToolError("MEDIA_FORMAT_UNSUPPORTED", "This explicit input is not a supported media format.");
|
|
2910
|
+
skip(path, "unsupported_extension");
|
|
2911
|
+
} else if (candidates.length >= maxFiles) skip(path, "file_limit_reached", true);
|
|
2912
|
+
else candidates.push(path);
|
|
2913
|
+
} catch (error) {
|
|
2914
|
+
report.failures.push({ path, ...mediaFailure(error) });
|
|
2915
|
+
report.complete = false;
|
|
2916
|
+
}
|
|
2917
|
+
};
|
|
2918
|
+
await visit(input, 0, true);
|
|
2919
|
+
for (const path of candidates.sort()) {
|
|
2920
|
+
try {
|
|
2921
|
+
report.assets.push(await probeLocalMedia(path));
|
|
2922
|
+
} catch (error) {
|
|
2923
|
+
report.failures.push({ path, ...mediaFailure(error) });
|
|
2924
|
+
report.complete = false;
|
|
2925
|
+
}
|
|
2926
|
+
}
|
|
2927
|
+
return report;
|
|
2928
|
+
}
|
|
2929
|
+
async function sampleMedia(options) {
|
|
2930
|
+
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)) {
|
|
2931
|
+
throw new MediaToolError("MEDIA_SAMPLE_TIMES_INVALID", "Provide 1\u201312 sample times in seconds, between 0 and 86400.");
|
|
2932
|
+
}
|
|
2933
|
+
const file = await assertLocalMediaFile(options.file);
|
|
2934
|
+
const source = await probeLocalMedia(file);
|
|
2935
|
+
if (!source.streams.some((stream) => stream.type === "video")) throw new MediaToolError("MEDIA_VIDEO_REQUIRED", "Frame sampling requires a video or image stream.");
|
|
2936
|
+
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.");
|
|
2937
|
+
const outputDirectory = resolveLocalMediaPath(options.outputDirectory);
|
|
2938
|
+
await assertNoMediaSymlinks(dirname4(outputDirectory));
|
|
2939
|
+
try {
|
|
2940
|
+
await mkdir3(outputDirectory);
|
|
2941
|
+
} catch (error) {
|
|
2942
|
+
if (error.code !== "EEXIST") throw error;
|
|
2943
|
+
}
|
|
2944
|
+
await assertNoMediaSymlinks(outputDirectory);
|
|
2945
|
+
if (!(await lstat6(outputDirectory)).isDirectory()) throw new MediaToolError("MEDIA_OUTPUT_DIRECTORY_INVALID", "Frame output must be a local directory.");
|
|
2946
|
+
const temp = await mkdtemp(join2(outputDirectory, ".editorbrain-samples-"));
|
|
2947
|
+
const tempStat = await lstat6(temp);
|
|
2948
|
+
const frames = [];
|
|
2949
|
+
const created = [];
|
|
2950
|
+
try {
|
|
2951
|
+
for (let index = 0; index < options.times.length; index++) {
|
|
2952
|
+
const time = options.times[index];
|
|
2953
|
+
const filename = `${source.sha256.slice(0, 12)}-${String(index + 1).padStart(2, "0")}-${Math.round(time * 1e3)}ms.jpg`;
|
|
2954
|
+
const temporary = join2(temp, filename), output2 = join2(outputDirectory, filename);
|
|
2955
|
+
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 });
|
|
2956
|
+
const bytes = await readFile2(temporary);
|
|
2957
|
+
if (!bytes.length) throw new MediaToolError("MEDIA_SAMPLE_EMPTY", "The requested frame did not produce a usable image.");
|
|
2958
|
+
try {
|
|
2959
|
+
await link(temporary, output2);
|
|
2960
|
+
} catch (error) {
|
|
2961
|
+
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.");
|
|
2962
|
+
throw error;
|
|
2963
|
+
}
|
|
2964
|
+
const outputStat = await lstat6(output2);
|
|
2965
|
+
created.push({ path: output2, ino: outputStat.ino, dev: outputStat.dev, size: outputStat.size, mtimeMs: outputStat.mtimeMs, ctimeMs: outputStat.ctimeMs });
|
|
2966
|
+
frames.push({ path: output2, sha256: createHash4("sha256").update(bytes).digest("hex"), bytes: bytes.length, requestedTimeSeconds: time, actualPresentationTimeSeconds: null });
|
|
2967
|
+
}
|
|
2968
|
+
const after = await probeLocalMedia(file);
|
|
2969
|
+
if (source.sha256 !== after.sha256) throw new MediaToolError("MEDIA_FILE_CHANGED", "The source changed while frames were being sampled. Retry with a stable source.");
|
|
2970
|
+
return { schemaVersion: 1, kind: "editorbrain.media-samples", generatedAt: (/* @__PURE__ */ new Date()).toISOString(), source, frames, metadataOnly: false, contentReviewed: false, mediaUploaded: false };
|
|
2971
|
+
} catch (error) {
|
|
2972
|
+
for (const file2 of created) {
|
|
2973
|
+
try {
|
|
2974
|
+
await assertNoMediaSymlinks(file2.path);
|
|
2975
|
+
const current = await lstat6(file2.path);
|
|
2976
|
+
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 });
|
|
2977
|
+
} catch {
|
|
2978
|
+
}
|
|
2979
|
+
}
|
|
2980
|
+
throw error;
|
|
2981
|
+
} finally {
|
|
2982
|
+
try {
|
|
2983
|
+
await assertNoMediaSymlinks(temp);
|
|
2984
|
+
const current = await lstat6(temp);
|
|
2985
|
+
if (current.ino === tempStat.ino && current.dev === tempStat.dev) await rm(temp, { recursive: true, force: true });
|
|
2986
|
+
} catch {
|
|
2987
|
+
}
|
|
2988
|
+
}
|
|
2989
|
+
}
|
|
2990
|
+
|
|
2991
|
+
// packages/cli/src/render-check.ts
|
|
2992
|
+
import { resolve as resolve5 } from "node:path";
|
|
2993
|
+
function invalidProfile(message) {
|
|
2994
|
+
throw new CliError("RENDER_PROFILE_INVALID", message, "Use a schemaVersion: 1 render profile with explicit technical requirements.");
|
|
2995
|
+
}
|
|
2996
|
+
function object4(value, path, keys) {
|
|
2997
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) invalidProfile(`${path} must be an object.`);
|
|
2998
|
+
const result = value;
|
|
2999
|
+
for (const key of Object.keys(result)) if (!keys.includes(key)) invalidProfile(`${path} contains unsupported field ${key}.`);
|
|
3000
|
+
return result;
|
|
3001
|
+
}
|
|
3002
|
+
function validateRenderCheckProfile(value) {
|
|
3003
|
+
const root = object4(value, "profile", ["schemaVersion", "expected", "tolerance", "audio", "silence", "black", "peak", "limits"]);
|
|
3004
|
+
if (root.schemaVersion !== 1) invalidProfile("profile.schemaVersion must be 1.");
|
|
3005
|
+
const expected = root.expected === void 0 ? {} : object4(root.expected, "expected", ["width", "height", "fps", "durationSeconds"]);
|
|
3006
|
+
const tolerance = root.tolerance === void 0 ? {} : object4(root.tolerance, "tolerance", ["fps", "durationSeconds"]);
|
|
3007
|
+
const silence = root.silence === void 0 ? {} : object4(root.silence, "silence", ["enabled", "minDurationSeconds", "thresholdDb"]);
|
|
3008
|
+
const black = root.black === void 0 ? {} : object4(root.black, "black", ["enabled", "minDurationSeconds", "pixelThreshold", "pictureBlackRatio"]);
|
|
3009
|
+
const peak = root.peak === void 0 ? {} : object4(root.peak, "peak", ["enabled", "thresholdDbFS"]);
|
|
3010
|
+
const limits = root.limits === void 0 ? {} : object4(root.limits, "limits", ["timeoutMs", "maxOutputBytes"]);
|
|
3011
|
+
const number = (v, name, min, max, fallback, integer = false) => {
|
|
3012
|
+
if (v === void 0 && fallback !== void 0) return fallback;
|
|
3013
|
+
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}.`);
|
|
3014
|
+
return v;
|
|
3015
|
+
};
|
|
3016
|
+
const enabled = (v, name) => {
|
|
3017
|
+
if (v === void 0) return true;
|
|
3018
|
+
if (typeof v !== "boolean") invalidProfile(`${name} must be a boolean.`);
|
|
3019
|
+
return v;
|
|
3020
|
+
};
|
|
3021
|
+
if (root.audio !== void 0 && !["required", "optional", "forbidden"].includes(root.audio)) invalidProfile("audio must be required, optional or forbidden.");
|
|
3022
|
+
const validatedExpected = {};
|
|
3023
|
+
for (const key of ["width", "height"]) if (expected[key] !== void 0) validatedExpected[key] = number(expected[key], `expected.${key}`, 1, 32768, void 0, true);
|
|
3024
|
+
if (expected.fps !== void 0) validatedExpected.fps = number(expected.fps, "expected.fps", 1e-6, 1e3);
|
|
3025
|
+
if (expected.durationSeconds !== void 0) validatedExpected.durationSeconds = number(expected.durationSeconds, "expected.durationSeconds", 1e-6, 864e4);
|
|
3026
|
+
return {
|
|
3027
|
+
schemaVersion: 1,
|
|
3028
|
+
expected: validatedExpected,
|
|
3029
|
+
tolerance: { fps: number(tolerance.fps, "tolerance.fps", 0, 1e3, 0.01), durationSeconds: number(tolerance.durationSeconds, "tolerance.durationSeconds", 0, 86400, 0.1) },
|
|
3030
|
+
audio: root.audio ?? "optional",
|
|
3031
|
+
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) },
|
|
3032
|
+
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) },
|
|
3033
|
+
peak: { enabled: enabled(peak.enabled, "peak.enabled"), thresholdDbFS: number(peak.thresholdDbFS, "peak.thresholdDbFS", -150, 0, -0.1) },
|
|
3034
|
+
limits: { timeoutMs: number(limits.timeoutMs, "limits.timeoutMs", 1, 6e5, 6e5, true), maxOutputBytes: number(limits.maxOutputBytes, "limits.maxOutputBytes", 1024, 16777216, 4194304, true) }
|
|
3035
|
+
};
|
|
3036
|
+
}
|
|
3037
|
+
function errorCode(error) {
|
|
3038
|
+
if (error && typeof error === "object" && "code" in error && typeof error.code === "string" && /^[A-Z_0-9]+$/.test(error.code)) return error.code;
|
|
3039
|
+
return "MEDIA_CHECK_FAILED";
|
|
3040
|
+
}
|
|
3041
|
+
function addCheck(report, check) {
|
|
3042
|
+
report.checks.push(check);
|
|
3043
|
+
}
|
|
3044
|
+
function addFinding(report, finding) {
|
|
3045
|
+
report.findingsSummary.total++;
|
|
3046
|
+
report.findingsSummary.byKind[finding.kind]++;
|
|
3047
|
+
if (report.findings.length < 1e3) report.findings.push(finding);
|
|
3048
|
+
else {
|
|
3049
|
+
report.findingsSummary.truncated = true;
|
|
3050
|
+
report.complete = false;
|
|
3051
|
+
}
|
|
3052
|
+
report.findingsSummary.returned = report.findings.length;
|
|
3053
|
+
}
|
|
3054
|
+
function finish(report) {
|
|
3055
|
+
report.technicalStatus = report.checks.some((check) => check.status === "fail") ? "fail" : !report.complete ? "incomplete" : report.checks.some((check) => check.status === "warning") ? "warning" : "pass";
|
|
3056
|
+
return report;
|
|
3057
|
+
}
|
|
3058
|
+
function checkExpected(report, probe, profile) {
|
|
3059
|
+
const expected = profile.expected;
|
|
3060
|
+
if (!Object.keys(expected).length) return addCheck(report, { id: "expected_format", status: "not_applicable", detail: "No target dimensions, frame rate or duration were specified." });
|
|
3061
|
+
const video = probe.streams.find((stream) => stream.type === "video" && !stream.attachedPicture);
|
|
3062
|
+
const mismatches = [];
|
|
3063
|
+
const unknown = [];
|
|
3064
|
+
for (const key of ["width", "height", "fps"]) {
|
|
3065
|
+
const target = expected[key];
|
|
3066
|
+
if (target === void 0) continue;
|
|
3067
|
+
if (!video) {
|
|
3068
|
+
mismatches.push(`Expected ${key}, but the file has no video stream.`);
|
|
3069
|
+
continue;
|
|
3070
|
+
}
|
|
3071
|
+
const actual = video[key];
|
|
3072
|
+
if (actual === void 0 || actual === null || actual <= 0) unknown.push(`Could not measure ${key}.`);
|
|
3073
|
+
else if (Math.abs(actual - target) > (key === "fps" ? profile.tolerance.fps : 0)) mismatches.push(`${key}: measured ${actual}, expected ${target}.`);
|
|
3074
|
+
}
|
|
3075
|
+
if (expected.durationSeconds !== void 0) {
|
|
3076
|
+
if (probe.durationSeconds === null) unknown.push("Could not measure container duration.");
|
|
3077
|
+
else if (Math.abs(probe.durationSeconds - expected.durationSeconds) > profile.tolerance.durationSeconds) mismatches.push(`durationSeconds: measured ${probe.durationSeconds}, expected ${expected.durationSeconds}.`);
|
|
3078
|
+
}
|
|
3079
|
+
if (unknown.length) report.complete = false;
|
|
3080
|
+
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." });
|
|
3081
|
+
}
|
|
3082
|
+
async function toolVersion(tool) {
|
|
3083
|
+
try {
|
|
3084
|
+
const result = await runMediaTool(tool, ["-version"], { timeoutMs: 1e4, maxOutputBytes: 65536 });
|
|
3085
|
+
return { available: true, version: new RegExp(`^${tool} version ([A-Za-z0-9._+~-]+)`, "m").exec(result.stdout)?.[1] ?? null };
|
|
3086
|
+
} catch {
|
|
3087
|
+
return { available: false, version: null };
|
|
3088
|
+
}
|
|
3089
|
+
}
|
|
3090
|
+
function readDetectorOutput(report, stderr, audioStreams) {
|
|
3091
|
+
const samples = /* @__PURE__ */ new Map();
|
|
3092
|
+
for (const stream of audioStreams) samples.set(stream.index, { streamIndex: stream.index, sampleCount: null, samplePeakDbFS: null, peakState: "unavailable" });
|
|
3093
|
+
const pendingSilence = /* @__PURE__ */ new Map();
|
|
3094
|
+
for (const line of stderr.split("\n")) {
|
|
3095
|
+
const tag = /\[(blackdetect@v|silencedetect@a|astats@a)(\d+)\s+@/.exec(line);
|
|
3096
|
+
if (!tag) continue;
|
|
3097
|
+
const streamIndex = Number(tag[2]);
|
|
3098
|
+
if (tag[1] === "blackdetect@v") {
|
|
3099
|
+
const match = /black_start:([\d.e+-]+)\s+black_end:([\d.e+-]+)\s+black_duration:([\d.e+-]+)/i.exec(line);
|
|
3100
|
+
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." });
|
|
3101
|
+
} else if (tag[1] === "silencedetect@a") {
|
|
3102
|
+
const start2 = /silence_start:\s*([\d.e+-]+)/i.exec(line);
|
|
3103
|
+
if (start2 && Number.isFinite(Number(start2[1]))) pendingSilence.set(streamIndex, Number(start2[1]));
|
|
3104
|
+
const end2 = /silence_end:\s*([\d.e+-]+)\s*\|\s*silence_duration:\s*([\d.e+-]+)/i.exec(line);
|
|
3105
|
+
if (end2 && end2.slice(1).every((value) => Number.isFinite(Number(value)))) {
|
|
3106
|
+
addFinding(report, { kind: "silence_candidate", streamIndex, startSeconds: pendingSilence.get(streamIndex) ?? Number(end2[1]) - Number(end2[2]), endSeconds: Number(end2[1]), durationSeconds: Number(end2[2]), requiresReview: true, message: "A quiet interval met the configured detector threshold. Pauses and silent edits may be intentional." });
|
|
3107
|
+
pendingSilence.delete(streamIndex);
|
|
3108
|
+
}
|
|
3109
|
+
} else {
|
|
3110
|
+
const sample = samples.get(streamIndex);
|
|
3111
|
+
if (!sample) continue;
|
|
3112
|
+
const peak = /Peak level dB:\s*(-inf|[\d.e+-]+)/i.exec(line);
|
|
3113
|
+
if (peak?.[1] === "-inf") sample.peakState = "negative_infinity";
|
|
3114
|
+
else if (peak && Number.isFinite(Number(peak[1]))) {
|
|
3115
|
+
sample.peakState = "measured";
|
|
3116
|
+
sample.samplePeakDbFS = Number(peak[1]);
|
|
3117
|
+
}
|
|
3118
|
+
const count = /Number of samples:\s*([\d.e+-]+)/i.exec(line);
|
|
3119
|
+
if (count && Number.isFinite(Number(count[1]))) sample.sampleCount = Number(count[1]);
|
|
3120
|
+
}
|
|
3121
|
+
}
|
|
3122
|
+
report.measurements.audio = [...samples.values()];
|
|
3123
|
+
if (pendingSilence.size) {
|
|
3124
|
+
report.complete = false;
|
|
3125
|
+
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." });
|
|
3126
|
+
}
|
|
3127
|
+
}
|
|
3128
|
+
async function checkRender(options) {
|
|
3129
|
+
const profile = validateRenderCheckProfile(options.profile ?? { schemaVersion: 1 });
|
|
3130
|
+
const report = {
|
|
3131
|
+
schemaVersion: 1,
|
|
3132
|
+
kind: "editorbrain.render-check",
|
|
3133
|
+
generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
3134
|
+
mediaUploaded: false,
|
|
3135
|
+
file: { path: resolve5(options.file), sha256: null, bytes: null },
|
|
3136
|
+
effectiveProfile: profile,
|
|
3137
|
+
complete: true,
|
|
3138
|
+
technicalStatus: "incomplete",
|
|
3139
|
+
measurements: { probe: null, decodedThroughSeconds: null, audio: [] },
|
|
3140
|
+
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" },
|
|
3141
|
+
checks: [],
|
|
3142
|
+
findings: [],
|
|
3143
|
+
findingsSummary: { total: 0, returned: 0, truncated: false, byKind: { silence_candidate: 0, black_candidate: 0, peak_candidate: 0 } },
|
|
3144
|
+
creativeQuality: { narrative: "not_assessed", readability: "not_assessed", sfxAudibility: "not_assessed", style: "not_assessed" },
|
|
3145
|
+
limitations: [
|
|
3146
|
+
"This report is a technical inspection of the identified file, not approval of its editing or creative quality.",
|
|
3147
|
+
"Default detector thresholds are sensitivities, not requirements for a platform, niche, style, music, pacing or frame rate.",
|
|
3148
|
+
"Silence, black and peak findings require contextual review; no automatic cuts or audio changes are performed.",
|
|
3149
|
+
"Whole-mix sample peaks cannot establish SFX audibility, true-peak clipping, speech clarity or perceived loudness.",
|
|
3150
|
+
"Frame rate is container-reported average; constant frame cadence, HDR, color appearance, rotation and subtitle rendering are not certified.",
|
|
3151
|
+
"Black interval boundaries follow decoded frame timestamps and may differ from presentation duration by a frame.",
|
|
3152
|
+
"Attached cover art is decoded but excluded from video requirements and black-interval detection.",
|
|
3153
|
+
"Reports retain at most 1,000 timestamped findings; exceeding this cap marks the result incomplete and preserves total counts."
|
|
3154
|
+
]
|
|
3155
|
+
};
|
|
3156
|
+
let probe;
|
|
3157
|
+
try {
|
|
3158
|
+
probe = await probeLocalMedia(options.file);
|
|
3159
|
+
report.file = { path: probe.path, sha256: probe.sha256, bytes: probe.bytes };
|
|
3160
|
+
report.measurements.probe = probe;
|
|
3161
|
+
report.capabilities.ffprobe.available = true;
|
|
3162
|
+
addCheck(report, { id: "probe", status: "pass", detail: "Read stream metadata and SHA-256 from the local file." });
|
|
3163
|
+
} catch (error) {
|
|
3164
|
+
report.complete = false;
|
|
3165
|
+
addCheck(report, { id: "probe", status: "not_checked", code: errorCode(error), detail: "The local file could not be probed. No successful inspection is implied." });
|
|
3166
|
+
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." });
|
|
3167
|
+
return finish(report);
|
|
3168
|
+
}
|
|
3169
|
+
const versions = await Promise.all([toolVersion("ffprobe"), toolVersion("ffmpeg")]);
|
|
3170
|
+
report.capabilities.ffprobe = { ...versions[0], available: true };
|
|
3171
|
+
report.capabilities.ffmpeg = versions[1];
|
|
3172
|
+
const video = probe.streams.filter((stream) => stream.type === "video");
|
|
3173
|
+
const motionVideo = video.filter((stream) => !stream.attachedPicture);
|
|
3174
|
+
const audio = probe.streams.filter((stream) => stream.type === "audio");
|
|
3175
|
+
checkExpected(report, probe, profile);
|
|
3176
|
+
const audioMismatch = profile.audio === "required" && !audio.length || profile.audio === "forbidden" && audio.length > 0;
|
|
3177
|
+
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.` });
|
|
3178
|
+
const filters = [];
|
|
3179
|
+
const maps = [];
|
|
3180
|
+
for (const stream of video) {
|
|
3181
|
+
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}]`);
|
|
3182
|
+
maps.push("-map", `[v${stream.index}]`);
|
|
3183
|
+
}
|
|
3184
|
+
for (const stream of audio) {
|
|
3185
|
+
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}]`);
|
|
3186
|
+
maps.push("-map", `[a${stream.index}]`);
|
|
3187
|
+
}
|
|
3188
|
+
let decoded = false;
|
|
3189
|
+
if (!filters.length) {
|
|
3190
|
+
addCheck(report, { id: "decode", status: "fail", code: "NO_PLAYABLE_STREAMS", detail: "No audio or video streams are available for inspection." });
|
|
3191
|
+
} else {
|
|
3192
|
+
try {
|
|
3193
|
+
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);
|
|
3194
|
+
report.capabilities.ffmpeg.available = true;
|
|
3195
|
+
readDetectorOutput(report, result.stderr, audio);
|
|
3196
|
+
const progress = [...result.stdout.matchAll(/^out_time_us=(-?\d+)$/gm)].at(-1);
|
|
3197
|
+
report.measurements.decodedThroughSeconds = progress ? Number(progress[1]) / 1e6 : null;
|
|
3198
|
+
const frames = [...result.stdout.matchAll(/^frame=(\d+)$/gm)].at(-1);
|
|
3199
|
+
const empty = video.length > 0 && !!frames && Number(frames[1]) <= 0 || audio.length > 0 && report.measurements.audio.some((stream) => stream.sampleCount === 0);
|
|
3200
|
+
const missingEvidence = !/^progress=end$/m.test(result.stdout) || video.length > 0 && !frames || audio.length > 0 && report.measurements.audio.some((stream) => stream.sampleCount === null);
|
|
3201
|
+
if (empty) addCheck(report, { id: "decode", status: "fail", code: "EMPTY_DECODE", detail: "An expected stream produced no decoded frames or samples." });
|
|
3202
|
+
else if (missingEvidence) {
|
|
3203
|
+
report.complete = false;
|
|
3204
|
+
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." });
|
|
3205
|
+
} else {
|
|
3206
|
+
decoded = true;
|
|
3207
|
+
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." });
|
|
3208
|
+
}
|
|
3209
|
+
} catch (error) {
|
|
3210
|
+
const code = errorCode(error);
|
|
3211
|
+
const failed = code === "MEDIA_TOOL_FAILED";
|
|
3212
|
+
report.complete = false;
|
|
3213
|
+
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." });
|
|
3214
|
+
}
|
|
3215
|
+
}
|
|
3216
|
+
for (const id of ["silence", "black", "peak"]) {
|
|
3217
|
+
if (report.checks.some((check) => check.id === id)) continue;
|
|
3218
|
+
const applicable = id === "black" ? motionVideo.length > 0 : audio.length > 0;
|
|
3219
|
+
if (!applicable) {
|
|
3220
|
+
addCheck(report, { id, status: "not_applicable", detail: `No ${id === "black" ? "video streams excluding attached cover art" : "audio streams"}.` });
|
|
3221
|
+
continue;
|
|
3222
|
+
}
|
|
3223
|
+
if (!profile[id].enabled) {
|
|
3224
|
+
addCheck(report, { id, status: "not_checked", detail: "Explicitly disabled in the effective profile." });
|
|
3225
|
+
continue;
|
|
3226
|
+
}
|
|
3227
|
+
if (!decoded) {
|
|
3228
|
+
addCheck(report, { id, status: "not_checked", detail: "Requires successful full-file decoding." });
|
|
3229
|
+
continue;
|
|
3230
|
+
}
|
|
3231
|
+
if (id === "peak") {
|
|
3232
|
+
if (report.measurements.audio.some((stream) => stream.peakState === "unavailable")) {
|
|
3233
|
+
report.complete = false;
|
|
3234
|
+
addCheck(report, { id, status: "not_checked", code: "DETECTOR_OUTPUT_INCOMPLETE", detail: "A whole-mix sample peak could not be read for every audio stream." });
|
|
3235
|
+
continue;
|
|
3236
|
+
}
|
|
3237
|
+
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." });
|
|
3238
|
+
}
|
|
3239
|
+
const count = report.findingsSummary.byKind[`${id}_candidate`];
|
|
3240
|
+
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." });
|
|
3241
|
+
}
|
|
3242
|
+
try {
|
|
3243
|
+
const after = await probeLocalMedia(probe.path);
|
|
3244
|
+
const same = after.sha256 === probe.sha256 && after.bytes === probe.bytes;
|
|
3245
|
+
if (!same) report.complete = false;
|
|
3246
|
+
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." });
|
|
3247
|
+
} catch (error) {
|
|
3248
|
+
report.complete = false;
|
|
3249
|
+
addCheck(report, { id: "file_stability", status: "not_checked", code: errorCode(error), detail: "The input fingerprint could not be verified after inspection." });
|
|
3250
|
+
}
|
|
3251
|
+
return finish(report);
|
|
3252
|
+
}
|
|
3253
|
+
|
|
3254
|
+
// packages/cli/src/production-output.ts
|
|
3255
|
+
import { constants as constants6 } from "node:fs";
|
|
3256
|
+
import { mkdir as mkdir4, open as open6 } from "node:fs/promises";
|
|
3257
|
+
import { dirname as dirname5 } from "node:path";
|
|
3258
|
+
async function productionOutputPath(root, relativePath) {
|
|
3259
|
+
if (!relativePath.startsWith(".editorbrain-local/") || relativePath.split(/[\\/]/).some((p) => !p || p === ".." || p === ".") || /[\\\x00-\x1f\x7f]/.test(relativePath)) {
|
|
3260
|
+
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.");
|
|
3261
|
+
}
|
|
3262
|
+
return checkedPath(root, relativePath, false);
|
|
3263
|
+
}
|
|
3264
|
+
async function prepareSampleDirectory(root, path) {
|
|
3265
|
+
const target = await productionOutputPath(root, path);
|
|
3266
|
+
await mkdir4(dirname5(target), { recursive: true });
|
|
3267
|
+
await productionOutputPath(root, path);
|
|
3268
|
+
return target;
|
|
3269
|
+
}
|
|
3270
|
+
async function readProductionJson(root, path) {
|
|
3271
|
+
const target = await checkedPath(root, path, false);
|
|
3272
|
+
const handle = await open6(target, constants6.O_RDONLY | constants6.O_NOFOLLOW | constants6.O_NONBLOCK);
|
|
3273
|
+
try {
|
|
3274
|
+
const stat = await handle.stat();
|
|
3275
|
+
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.");
|
|
3276
|
+
return JSON.parse(await handle.readFile("utf8"));
|
|
3277
|
+
} finally {
|
|
3278
|
+
await handle.close();
|
|
3279
|
+
}
|
|
3280
|
+
}
|
|
3281
|
+
async function saveProductionReport(root, path, report) {
|
|
3282
|
+
const target = await productionOutputPath(root, path);
|
|
3283
|
+
if (!path.endsWith(".json")) throw new CliError("INVALID_ARGUMENT", "Report output needs a .json extension.", "Use a new path under .editorbrain-local/.");
|
|
3284
|
+
await atomicWrite(root, path, stableJson(report), { document: false, overwrite: false, mode: 384 });
|
|
3285
|
+
return target;
|
|
3286
|
+
}
|
|
3287
|
+
function summarizeProductionReport(value) {
|
|
3288
|
+
const r = value;
|
|
3289
|
+
const base = { schemaVersion: r.schemaVersion, kind: r.kind, summary: true, mediaUploaded: false, quality: "not_assessed" };
|
|
3290
|
+
if (r.kind === "editorbrain.media-inventory") return {
|
|
3291
|
+
...base,
|
|
3292
|
+
complete: r.complete,
|
|
3293
|
+
contentReviewed: false,
|
|
3294
|
+
counts: { assets: r.assets.length, failures: r.failures.length, skipped: r.skipped.length },
|
|
3295
|
+
assets: r.assets.slice(0, 8),
|
|
3296
|
+
failures: r.failures.slice(0, 5),
|
|
3297
|
+
omitted: { assets: Math.max(0, r.assets.length - 8), failures: Math.max(0, r.failures.length - 5), skipped: r.skipped.length },
|
|
3298
|
+
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."
|
|
3299
|
+
};
|
|
3300
|
+
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." };
|
|
3301
|
+
if (r.kind === "editorbrain.production-plan-check") return {
|
|
3302
|
+
...base,
|
|
3303
|
+
valid: r.valid,
|
|
3304
|
+
inventoryComplete: r.inventoryComplete,
|
|
3305
|
+
observations: r.observations,
|
|
3306
|
+
sourceFilesReverified: false,
|
|
3307
|
+
counts: { sources: r.coverage.length, findings: r.findings.length, repeatedPairs: r.repeatedPairs ?? 0 },
|
|
3308
|
+
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),
|
|
3309
|
+
omittedFindings: Math.max(0, r.findings.length - 8),
|
|
3310
|
+
next: "Review source reuse and inspection gaps in the saved report. Observations are agent-declared, not independently certified."
|
|
3311
|
+
};
|
|
3312
|
+
if (r.kind === "editorbrain.render-check") return {
|
|
3313
|
+
...base,
|
|
3314
|
+
complete: r.complete,
|
|
3315
|
+
technicalStatus: r.technicalStatus,
|
|
3316
|
+
file: r.file,
|
|
3317
|
+
checks: r.checks,
|
|
3318
|
+
findings: r.findings.slice(0, 8),
|
|
3319
|
+
findingsSummary: r.findingsSummary,
|
|
3320
|
+
omittedFindings: Math.max(0, r.findings.length - 8),
|
|
3321
|
+
next: "Reconcile technical candidates with the brief; inspect the actual video/audio. Technical checks do not assess narrative, aesthetics or SFX audibility."
|
|
3322
|
+
};
|
|
3323
|
+
throw new CliError("INVALID_REPORT", "Unknown production report kind.", "Use media inventory/sample or production check/plan-check.");
|
|
3324
|
+
}
|
|
3325
|
+
|
|
3326
|
+
// packages/cli/src/integrations.ts
|
|
3327
|
+
import { constants as constants7 } from "node:fs";
|
|
3328
|
+
import { open as open7 } from "node:fs/promises";
|
|
3329
|
+
function object5(value) {
|
|
3330
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
3331
|
+
}
|
|
3332
|
+
function invalidState(write) {
|
|
3333
|
+
return new CliError(
|
|
3334
|
+
"INVALID_RESPONSE",
|
|
3335
|
+
write ? "The integration write could not be confirmed from the API response." : "The API returned invalid integration state.",
|
|
3336
|
+
write ? "The write may have committed. Run editorbrain integrations list for this endpoint and reconcile remote state before another write." : "Run editorbrain integrations list again for this endpoint; check the service if the response remains invalid.",
|
|
3337
|
+
false,
|
|
3338
|
+
{ outcome: write ? "unknown" : "read_failed" }
|
|
3339
|
+
);
|
|
3340
|
+
}
|
|
3341
|
+
function integrationState(value, write = false) {
|
|
3342
|
+
if (!object5(value) || !["member", "connection"].includes(value.scope) || !Number.isSafeInteger(value.revision) || value.revision < 0 || !(value.updatedAt === null || typeof value.updatedAt === "string" && value.updatedAt.length <= 64 && Number.isFinite(Date.parse(value.updatedAt))) || !Array.isArray(value.entries) || !Array.isArray(value.catalog)) throw invalidState(write);
|
|
3343
|
+
const providers = /* @__PURE__ */ new Set();
|
|
3344
|
+
for (const entry of value.entries) {
|
|
3345
|
+
if (!object5(entry) || typeof entry.providerId !== "string" || !entry.providerId || entry.providerId.length > 120 || providers.has(entry.providerId) || typeof entry.enabled !== "boolean" || typeof entry.accessMethod !== "string" || !entry.accessMethod || entry.accessMethod.length > 64 || !object5(entry.preferences)) throw invalidState(write);
|
|
3346
|
+
providers.add(entry.providerId);
|
|
3347
|
+
}
|
|
3348
|
+
if (value.catalog.some((provider) => !object5(provider) || typeof provider.id !== "string" || !provider.id)) throw invalidState(write);
|
|
3349
|
+
return {
|
|
3350
|
+
scope: value.scope,
|
|
3351
|
+
revision: value.revision,
|
|
3352
|
+
updatedAt: value.updatedAt,
|
|
3353
|
+
entries: value.entries,
|
|
3354
|
+
catalog: value.catalog
|
|
3355
|
+
};
|
|
3356
|
+
}
|
|
3357
|
+
async function readIntegrationSettings(file) {
|
|
3358
|
+
const handle = await open7(file, constants7.O_RDONLY | constants7.O_NOFOLLOW | constants7.O_NONBLOCK);
|
|
3359
|
+
try {
|
|
3360
|
+
const stat = await handle.stat();
|
|
3361
|
+
if (!stat.isFile() || stat.size > 16 * 1024) throw new CliError("INVALID_ARGUMENT", "Choose a regular integration settings JSON file under 16 KiB.", "Store provider credentials in the provider connection, not this file.");
|
|
3362
|
+
try {
|
|
3363
|
+
return JSON.parse(await handle.readFile("utf8"));
|
|
3364
|
+
} catch {
|
|
3365
|
+
throw new CliError("INVALID_ARGUMENT", "Integration settings must be valid JSON.", "The file should contain enabled, accessMethod and preferences only.");
|
|
3366
|
+
}
|
|
3367
|
+
} finally {
|
|
3368
|
+
await handle.close();
|
|
3369
|
+
}
|
|
3370
|
+
}
|
|
3371
|
+
function integrationRevision(value) {
|
|
3372
|
+
if (value === void 0 || !/^(0|[1-9]\d*)$/.test(value) || !Number.isSafeInteger(Number(value))) {
|
|
3373
|
+
throw new CliError("INVALID_ARGUMENT", "--expected-revision must be the revision returned by integrations list.", "Read current settings before changing them.");
|
|
3374
|
+
}
|
|
3375
|
+
return Number(value);
|
|
3376
|
+
}
|
|
3377
|
+
async function listIntegrations(client, details = false) {
|
|
3378
|
+
const state = integrationState(await client.request("/v1/integrations"));
|
|
3379
|
+
if (details) return state;
|
|
3380
|
+
return {
|
|
3381
|
+
scope: state.scope,
|
|
3382
|
+
revision: state.revision,
|
|
3383
|
+
updatedAt: state.updatedAt,
|
|
3384
|
+
entries: state.entries,
|
|
3385
|
+
access: "user_declared",
|
|
3386
|
+
notice: "Verify provider access in this agent session before use. Settings do not connect tools or authorize charges."
|
|
3387
|
+
};
|
|
3388
|
+
}
|
|
3389
|
+
async function setIntegration(client, providerId, settings, expectedRevision) {
|
|
3390
|
+
let update;
|
|
3391
|
+
try {
|
|
3392
|
+
if (!settings || typeof settings !== "object" || Array.isArray(settings) || "expectedRevision" in settings) throw new Error();
|
|
3393
|
+
update = validateIntegrationUpdate(providerId, { ...settings, expectedRevision });
|
|
3394
|
+
} catch {
|
|
3395
|
+
throw new CliError("INVALID_ARGUMENT", "Invalid integration preferences, access method or provider.", "Use integrations catalog for supported providers. The JSON accepts enabled, accessMethod, preferences; never credentials.");
|
|
3396
|
+
}
|
|
3397
|
+
let response;
|
|
3398
|
+
try {
|
|
3399
|
+
response = await client.request(`/v1/integrations/${encodeURIComponent(providerId)}`, { method: "PUT", body: update });
|
|
3400
|
+
} catch (error) {
|
|
3401
|
+
if (error instanceof ApiError && error.status === 409) throw new CliError(
|
|
3402
|
+
"INTEGRATION_CONFLICT",
|
|
3403
|
+
"Integration preferences changed before this save.",
|
|
3404
|
+
"Run editorbrain integrations list for this endpoint, reconcile the preferences and use the returned revision. Do not retry the old write blindly.",
|
|
3405
|
+
false,
|
|
3406
|
+
{ status: 409 }
|
|
3407
|
+
);
|
|
3408
|
+
if (error instanceof CliError && ["INVALID_RESPONSE", "RESPONSE_TOO_LARGE"].includes(error.code)) throw invalidState(true);
|
|
3409
|
+
throw error;
|
|
3410
|
+
}
|
|
3411
|
+
const state = integrationState(response, true);
|
|
3412
|
+
const entry = state.entries.find((value) => value.providerId === providerId);
|
|
3413
|
+
if (state.revision !== expectedRevision + 1 || !entry || entry.enabled !== update.enabled || entry.accessMethod !== update.accessMethod || stableJson(entry.preferences) !== stableJson(update.preferences)) throw invalidState(true);
|
|
3414
|
+
return { scope: state.scope, revision: state.revision, updatedAt: state.updatedAt, entry, access: "user_declared" };
|
|
3415
|
+
}
|
|
3416
|
+
|
|
1772
3417
|
// packages/cli/src/index.ts
|
|
1773
3418
|
var HELP = `EditorBrain \u2014 portable editorial context for AI video agents
|
|
1774
3419
|
|
|
@@ -1800,7 +3445,10 @@ editorbrain improvements accept RUN --index N --expected-revision N
|
|
|
1800
3445
|
editorbrain review list --project ID
|
|
1801
3446
|
editorbrain review create --project ID --asset SHA --title TEXT --kind sample|render --days 7 --share-authorized
|
|
1802
3447
|
editorbrain review revoke ID
|
|
1803
|
-
editorbrain workflow check [--project id] [--previous-version hash]
|
|
3448
|
+
editorbrain workflow check [--project id] [--previous-version hash] [--previous-context-version hash]
|
|
3449
|
+
editorbrain integrations catalog
|
|
3450
|
+
editorbrain integrations list [--details]
|
|
3451
|
+
editorbrain integrations set <provider-id> --file settings.json --expected-revision N
|
|
1804
3452
|
editorbrain search brief --file <editorial-brief.json> [--project id] [--mode styles|events|both] [--review direct|directions|sample]
|
|
1805
3453
|
editorbrain search edit [--script file | --video file | --asset sha256 | --project id] [--requirements text] [--assets tag,tag] [--mode styles|events|both] [--review direct|directions|sample]
|
|
1806
3454
|
editorbrain library search [query]
|
|
@@ -1811,6 +3459,12 @@ editorbrain narrative get <video-id> [--sha256 document-hash] [--event-offset 0]
|
|
|
1811
3459
|
editorbrain styles list
|
|
1812
3460
|
editorbrain styles create <style-id> [--name name] [--dir folder]
|
|
1813
3461
|
editorbrain styles check <style-id> [--dir folder]
|
|
3462
|
+
editorbrain media styles [--category image|motion]
|
|
3463
|
+
editorbrain media style <style-id> [--style-version 1.0.0]
|
|
3464
|
+
editorbrain media inventory --input file-or-folder [--max-files 200] [--dir folder] [--out .editorbrain-local/report.json] [--details]
|
|
3465
|
+
editorbrain media sample --file source --at 0,5,12 --out .editorbrain-local/frames [--dir folder] [--details]
|
|
3466
|
+
editorbrain production check --file render [--profile local-profile.json] [--dir folder] [--out .editorbrain-local/report.json] [--details]
|
|
3467
|
+
editorbrain production plan-check --inventory local-inventory.json --plan local-plan.json [--dir folder] [--out .editorbrain-local/report.json] [--details]
|
|
1814
3468
|
editorbrain styles recommend <project-id> [--assets tag,tag] [--capabilities tag,tag] [--style pack-id] [--duration seconds]
|
|
1815
3469
|
editorbrain direction draft <project-id> [--assets tag,tag] [--capabilities tag,tag] [--style pack-id] [--duration seconds] [--out new-folder]
|
|
1816
3470
|
editorbrain assets list [--limit 200] [--cursor opaque-cursor]
|
|
@@ -1826,14 +3480,17 @@ Resolve writes the source-hash lock. Export produces a handoff, not a rendered v
|
|
|
1826
3480
|
Push/pull preserve conflicting changes; media uploads use the separate assets command.
|
|
1827
3481
|
Skill install works offline, preserves existing instructions, and defaults to both agents.
|
|
1828
3482
|
Styles create/check work locally without login or media upload; checks do not certify render quality.
|
|
3483
|
+
Media/production commands run locally with ffprobe/ffmpeg, without automatic installation or media upload.
|
|
3484
|
+
Integrations store personal preferences, not credentials. Declared access is checked by your agent before use.
|
|
3485
|
+
Production output is compact by default; --details expands it. --out saves full private reports without overwriting.
|
|
1829
3486
|
Keys create stores the returned secret in the private user config; stdout has no key.
|
|
1830
3487
|
Output is JSON except help; login progress/code goes to stderr. --url overrides the configured endpoint.
|
|
1831
3488
|
`;
|
|
1832
3489
|
function parseArgs(argv) {
|
|
1833
3490
|
const positional = [];
|
|
1834
3491
|
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]);
|
|
3492
|
+
const booleans = /* @__PURE__ */ new Set(["help", "version", "offline", "dry-run", "key-stdin", "invite-stdin", "no-browser", "skip-mcp", "all", "share-authorized", "details"]);
|
|
3493
|
+
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", "previous-context-version", "category", "style-version", "file", "revision", "expected-revision", "before", "scope", "id", "text", "conditions", "status", "index", "title", "days", ...booleans]);
|
|
1837
3494
|
for (let i = 0; i < argv.length; i++) {
|
|
1838
3495
|
const arg = argv[i];
|
|
1839
3496
|
if (!arg.startsWith("--")) {
|
|
@@ -1859,16 +3516,16 @@ function output(value) {
|
|
|
1859
3516
|
process.stdout.write(stableJson(value));
|
|
1860
3517
|
}
|
|
1861
3518
|
async function configRoot(create = false) {
|
|
1862
|
-
let path = process.env.EDITORBRAIN_CONFIG_DIR ?? process.env.EDITORHARNESS_CONFIG_DIR ??
|
|
3519
|
+
let path = process.env.EDITORBRAIN_CONFIG_DIR ?? process.env.EDITORHARNESS_CONFIG_DIR ?? join3(homedir(), ".config", "editorbrain");
|
|
1863
3520
|
if (!create && !process.env.EDITORBRAIN_CONFIG_DIR && !process.env.EDITORHARNESS_CONFIG_DIR) {
|
|
1864
3521
|
try {
|
|
1865
|
-
await
|
|
3522
|
+
await lstat7(path);
|
|
1866
3523
|
} catch (error) {
|
|
1867
3524
|
if (error.code !== "ENOENT") throw error;
|
|
1868
|
-
path =
|
|
3525
|
+
path = join3(homedir(), ".config", "editorharness");
|
|
1869
3526
|
}
|
|
1870
3527
|
}
|
|
1871
|
-
if (create) await
|
|
3528
|
+
if (create) await mkdir5(path, { recursive: true, mode: 448 });
|
|
1872
3529
|
return ensureRoot(path);
|
|
1873
3530
|
}
|
|
1874
3531
|
async function loadConfig() {
|
|
@@ -1885,7 +3542,7 @@ async function loadConfig() {
|
|
|
1885
3542
|
async function saveConfig(config2) {
|
|
1886
3543
|
const root = await configRoot(true);
|
|
1887
3544
|
await atomicWrite(root, "config.json", stableJson(config2), { document: false, mode: 384 });
|
|
1888
|
-
await chmod(
|
|
3545
|
+
await chmod(join3(root, "config.json"), 384);
|
|
1889
3546
|
}
|
|
1890
3547
|
async function config(args, requireKey = true) {
|
|
1891
3548
|
const stored = await loadConfig();
|
|
@@ -1913,7 +3570,7 @@ async function init(args) {
|
|
|
1913
3570
|
for (const path of Object.keys(files)) {
|
|
1914
3571
|
const target = await checkedPath(root, path);
|
|
1915
3572
|
try {
|
|
1916
|
-
await
|
|
3573
|
+
await lstat7(target);
|
|
1917
3574
|
throw new Error(`Refusing to replace existing file: ${path}`);
|
|
1918
3575
|
} catch (error) {
|
|
1919
3576
|
if (error.code !== "ENOENT") throw error;
|
|
@@ -1925,13 +3582,13 @@ async function init(args) {
|
|
|
1925
3582
|
for (const path of directories) {
|
|
1926
3583
|
const target = await checkedPath(root, path, false);
|
|
1927
3584
|
try {
|
|
1928
|
-
if (!(await
|
|
3585
|
+
if (!(await lstat7(target)).isDirectory()) throw new Error(`Not a directory: ${path}`);
|
|
1929
3586
|
} catch (error) {
|
|
1930
3587
|
if (error.code !== "ENOENT") throw error;
|
|
1931
3588
|
}
|
|
1932
3589
|
}
|
|
1933
3590
|
for (const [path, content] of Object.entries(files)) await atomicWrite(root, path, content, { overwrite: false });
|
|
1934
|
-
for (const path of directories) await
|
|
3591
|
+
for (const path of directories) await mkdir5(await checkedPath(root, path, false), { recursive: true });
|
|
1935
3592
|
const guidance = await installGuidance();
|
|
1936
3593
|
const entryPoints = guidance.readInstructions.filter((item) => item.entryPointStatus === "created").map((item) => item.entryPoint);
|
|
1937
3594
|
output({ root, created: Object.keys(files), entryPoints, guidance, next: `Edit brand tokens and brief, then run editorbrain resolve ${project}` });
|
|
@@ -1991,6 +3648,81 @@ async function main() {
|
|
|
1991
3648
|
process.stdout.write(HELP);
|
|
1992
3649
|
return;
|
|
1993
3650
|
}
|
|
3651
|
+
if (args.flags.details && !["media", "production", "integrations"].includes(command)) throw new CliError("INVALID_ARGUMENT", "--details is supported only by media, production and integrations list.", "Read editorbrain help.");
|
|
3652
|
+
if (command === "integrations") {
|
|
3653
|
+
const allowed = new Set(subcommand === "catalog" ? [] : subcommand === "list" ? ["url", "details"] : subcommand === "set" ? ["url", "file", "expected-revision", "dir"] : []);
|
|
3654
|
+
if (!["catalog", "list", "set"].includes(subcommand) || args.positional.length > (subcommand === "set" ? 3 : 2)) throw new CliError("INVALID_ARGUMENT", "Use integrations catalog, list or set <provider-id>.", "Read editorbrain help.");
|
|
3655
|
+
for (const flag of Object.keys(args.flags)) if (!allowed.has(flag)) throw new CliError("INVALID_ARGUMENT", `--${flag} is not supported for this integrations command.`, "Read editorbrain help.");
|
|
3656
|
+
if (subcommand === "catalog") {
|
|
3657
|
+
output({ catalog: integrationCatalog, credentialsRequired: false });
|
|
3658
|
+
return;
|
|
3659
|
+
}
|
|
3660
|
+
if (subcommand === "set") {
|
|
3661
|
+
const file = str(args, "file");
|
|
3662
|
+
if (!argument || !file) throw new CliError("INVALID_ARGUMENT", "integrations set requires a provider ID and --file.", "Use integrations list first, then supply --expected-revision.");
|
|
3663
|
+
const revision = integrationRevision(str(args, "expected-revision"));
|
|
3664
|
+
const settings = await readIntegrationSettings(resolve6(str(args, "dir") ?? process.cwd(), file));
|
|
3665
|
+
output(await setIntegration(new Client(await config(args)), argument, settings, revision));
|
|
3666
|
+
} else output(await listIntegrations(new Client(await config(args)), !!args.flags.details));
|
|
3667
|
+
return;
|
|
3668
|
+
}
|
|
3669
|
+
if (command === "media" && (subcommand === "styles" || subcommand === "style")) {
|
|
3670
|
+
const allowed = new Set(subcommand === "styles" ? ["category"] : ["style-version"]);
|
|
3671
|
+
for (const flag of Object.keys(args.flags)) if (!allowed.has(flag)) throw new CliError("INVALID_ARGUMENT", `--${flag} is not supported.`, "Read editorbrain help.");
|
|
3672
|
+
if (subcommand === "styles") {
|
|
3673
|
+
const category = str(args, "category");
|
|
3674
|
+
if (argument || category && !["image", "motion"].includes(category)) throw new CliError("INVALID_ARGUMENT", "Use --category image or motion.", "Read editorbrain media styles.");
|
|
3675
|
+
output({ id: MEDIA_STYLE_CATALOG.id, version: MEDIA_STYLE_CATALOG.version, styles: MEDIA_STYLE_CATALOG.styles.filter((s) => !category || s.category === category).map(({ id, version, category: category2, name, description, status }) => ({ id, version, category: category2, name, description, status })), next: "media style <id> --style-version <version>" });
|
|
3676
|
+
} else {
|
|
3677
|
+
if (!argument) throw new CliError("INVALID_ARGUMENT", "A media style id is required.", "Use editorbrain media styles.");
|
|
3678
|
+
const style = getMediaStyle(argument, str(args, "style-version"));
|
|
3679
|
+
if (!style) throw new CliError("NOT_FOUND", "This media style or version is not bundled with this CLI.", "Check the id/version or update EditorBrain; do not substitute another version silently.");
|
|
3680
|
+
output(style);
|
|
3681
|
+
}
|
|
3682
|
+
return;
|
|
3683
|
+
}
|
|
3684
|
+
if (command === "media" || command === "production") {
|
|
3685
|
+
const required = (name) => {
|
|
3686
|
+
const value = str(args, name);
|
|
3687
|
+
if (!value) throw new CliError("INVALID_ARGUMENT", `--${name} is required.`, "Read editorbrain help.");
|
|
3688
|
+
return value;
|
|
3689
|
+
};
|
|
3690
|
+
if (argument) throw new CliError("INVALID_ARGUMENT", "Media/production inputs use named flags.", "Read editorbrain help.");
|
|
3691
|
+
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"] : []]);
|
|
3692
|
+
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.");
|
|
3693
|
+
const root2 = await ensureRoot(str(args, "dir") ?? process.cwd());
|
|
3694
|
+
const out = str(args, "out");
|
|
3695
|
+
if (out) await productionOutputPath(root2, out);
|
|
3696
|
+
const run2 = async () => {
|
|
3697
|
+
let report;
|
|
3698
|
+
if (command === "media" && subcommand === "inventory") {
|
|
3699
|
+
const limit = str(args, "max-files");
|
|
3700
|
+
report = await inventoryMedia({ input: resolve6(root2, required("input")), ...limit ? { maxFiles: Number(limit) } : {} });
|
|
3701
|
+
if (!report.complete) process.exitCode = 2;
|
|
3702
|
+
} else if (command === "media" && subcommand === "sample") {
|
|
3703
|
+
const times = required("at").split(",");
|
|
3704
|
+
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");
|
|
3705
|
+
const directory = await prepareSampleDirectory(root2, required("out"));
|
|
3706
|
+
report = await sampleMedia({ file: resolve6(root2, required("file")), times: times.map(Number), outputDirectory: directory });
|
|
3707
|
+
} else if (command === "production" && subcommand === "check") {
|
|
3708
|
+
const profilePath = str(args, "profile");
|
|
3709
|
+
const profile = profilePath ? validateRenderCheckProfile(await readProductionJson(root2, profilePath)) : void 0;
|
|
3710
|
+
report = await checkRender({ file: resolve6(root2, required("file")), profile });
|
|
3711
|
+
if (report.technicalStatus === "fail") process.exitCode = 1;
|
|
3712
|
+
else if (!report.complete) process.exitCode = 2;
|
|
3713
|
+
} else if (command === "production" && subcommand === "plan-check") {
|
|
3714
|
+
const [inventory, plan] = await Promise.all([readProductionJson(root2, required("inventory")), readProductionJson(root2, required("plan"))]);
|
|
3715
|
+
report = checkProductionPlan(inventory, plan);
|
|
3716
|
+
if (!report.valid) process.exitCode = 1;
|
|
3717
|
+
else if (!report.inventoryComplete) process.exitCode = 2;
|
|
3718
|
+
} else throw new CliError("INVALID_ARGUMENT", "Unknown media/production command.", "Use media inventory/sample or production check/plan-check.");
|
|
3719
|
+
const reportPath = out && subcommand !== "sample" ? await saveProductionReport(root2, out, report) : void 0;
|
|
3720
|
+
output({ ...args.flags.details ? report : summarizeProductionReport(report), ...reportPath ? { reportPath } : {} });
|
|
3721
|
+
};
|
|
3722
|
+
if (out) await withWorkspaceLock(root2, run2, { migrateLegacy: false });
|
|
3723
|
+
else await run2();
|
|
3724
|
+
return;
|
|
3725
|
+
}
|
|
1994
3726
|
if (command === "doctor") {
|
|
1995
3727
|
let selected, configError;
|
|
1996
3728
|
if (!args.flags.offline) {
|
|
@@ -2073,10 +3805,10 @@ async function main() {
|
|
|
2073
3805
|
const destination = str(args, "out");
|
|
2074
3806
|
if (destination) {
|
|
2075
3807
|
if (command !== "direction" || !result.files) throw new Error("--out is supported only for direction draft.");
|
|
2076
|
-
const outRoot = await ensureRoot(
|
|
3808
|
+
const outRoot = await ensureRoot(resolve6(str(args, "dir") ?? process.cwd(), destination), true);
|
|
2077
3809
|
for (const path of Object.keys(result.files)) {
|
|
2078
3810
|
try {
|
|
2079
|
-
await
|
|
3811
|
+
await lstat7(await checkedPath(outRoot, path, false));
|
|
2080
3812
|
throw new Error(`Refusing to replace existing proposal: ${path}`);
|
|
2081
3813
|
} catch (error) {
|
|
2082
3814
|
if (error.code !== "ENOENT") throw error;
|
|
@@ -2102,7 +3834,7 @@ async function main() {
|
|
|
2102
3834
|
if (!/^[a-zA-Z0-9_-]{1,100}$/.test(result.id) || typeof result.apiKey !== "string") throw new Error("Invalid key response");
|
|
2103
3835
|
const file = `keys/${result.id}.json`;
|
|
2104
3836
|
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:
|
|
3837
|
+
output({ id: result.id, label, role, credentialFile: join3(root2, file) });
|
|
2106
3838
|
return;
|
|
2107
3839
|
}
|
|
2108
3840
|
if (subcommand === "revoke" && argument) {
|
|
@@ -2189,8 +3921,8 @@ async function main() {
|
|
|
2189
3921
|
return;
|
|
2190
3922
|
}
|
|
2191
3923
|
if (subcommand === "upload" && argument) {
|
|
2192
|
-
const target =
|
|
2193
|
-
const handle = await
|
|
3924
|
+
const target = resolve6(str(args, "dir") ?? process.cwd(), argument);
|
|
3925
|
+
const handle = await open8(target, constants8.O_RDONLY | constants8.O_NOFOLLOW);
|
|
2194
3926
|
let bytes;
|
|
2195
3927
|
try {
|
|
2196
3928
|
const info = await handle.stat();
|
|
@@ -2200,24 +3932,24 @@ async function main() {
|
|
|
2200
3932
|
await handle.close();
|
|
2201
3933
|
}
|
|
2202
3934
|
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[
|
|
3935
|
+
const mimeType = str(args, "mime") ?? types[extname3(target).toLowerCase()];
|
|
2204
3936
|
if (!mimeType) throw new Error("Unsupported extension; specify --mime for a supported media format");
|
|
2205
|
-
const
|
|
2206
|
-
const result = await client.media(`/v1/assets/${
|
|
3937
|
+
const hash2 = await sha256(bytes);
|
|
3938
|
+
const result = await client.media(`/v1/assets/${hash2}?name=${encodeURIComponent(basename2(target))}`, { content: bytes, mimeType });
|
|
2207
3939
|
const body = await result.json();
|
|
2208
|
-
if (body.sha256 !==
|
|
3940
|
+
if (body.sha256 !== hash2) throw new Error("Upload response hash mismatch");
|
|
2209
3941
|
output(body);
|
|
2210
3942
|
return;
|
|
2211
3943
|
}
|
|
2212
3944
|
if (subcommand === "download" && argument && str(args, "out")) {
|
|
2213
3945
|
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,
|
|
3946
|
+
const destination = resolve6(str(args, "dir") ?? process.cwd(), str(args, "out"));
|
|
3947
|
+
const root2 = await ensureRoot(dirname6(destination), true);
|
|
3948
|
+
await checkedPath(root2, basename2(destination), false);
|
|
2217
3949
|
const response = await client.media(`/v1/assets/${argument}`);
|
|
2218
3950
|
const bytes = new Uint8Array(await response.arrayBuffer());
|
|
2219
3951
|
if (await sha256(bytes) !== argument) throw new Error("Downloaded asset failed SHA-256 verification");
|
|
2220
|
-
await atomicWrite(root2,
|
|
3952
|
+
await atomicWrite(root2, basename2(destination), bytes, { document: false, overwrite: false });
|
|
2221
3953
|
output({ path: destination, sha256: argument, bytes: bytes.length });
|
|
2222
3954
|
return;
|
|
2223
3955
|
}
|
|
@@ -2241,13 +3973,13 @@ async function main() {
|
|
|
2241
3973
|
}
|
|
2242
3974
|
if (command === "workflow" && subcommand === "check") {
|
|
2243
3975
|
const client = new Client(await config(args));
|
|
2244
|
-
output(await client.request("/v1/workflow/check", { method: "POST", body: { projectId: str(args, "project"), previousVersion: str(args, "previous-version") } }));
|
|
3976
|
+
output(await client.request("/v1/workflow/check", { method: "POST", body: { projectId: str(args, "project"), previousVersion: str(args, "previous-version"), previousContextVersion: str(args, "previous-context-version") } }));
|
|
2245
3977
|
return;
|
|
2246
3978
|
}
|
|
2247
3979
|
if (command === "search" && subcommand === "brief") {
|
|
2248
3980
|
const path = str(args, "file");
|
|
2249
3981
|
if (!path) throw new Error("Provide --file with the editorial brief JSON after inspecting the actual material.");
|
|
2250
|
-
const handle = await
|
|
3982
|
+
const handle = await open8(resolve6(path), constants8.O_RDONLY | constants8.O_NOFOLLOW);
|
|
2251
3983
|
let brief;
|
|
2252
3984
|
try {
|
|
2253
3985
|
const stat = await handle.stat();
|
|
@@ -2269,7 +4001,7 @@ async function main() {
|
|
|
2269
4001
|
const scriptPath = str(args, "script"), videoPath = str(args, "video");
|
|
2270
4002
|
let script, assetId = str(args, "asset");
|
|
2271
4003
|
const readInput = async (path, max) => {
|
|
2272
|
-
const handle = await
|
|
4004
|
+
const handle = await open8(resolve6(path), constants8.O_RDONLY | constants8.O_NOFOLLOW);
|
|
2273
4005
|
try {
|
|
2274
4006
|
const stat = await handle.stat();
|
|
2275
4007
|
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 +4021,10 @@ async function main() {
|
|
|
2289
4021
|
if (assetId && !/^[a-f0-9]{64}$/.test(assetId)) throw new Error("--asset requires an owned production asset SHA-256.");
|
|
2290
4022
|
if (videoPath) {
|
|
2291
4023
|
const bytes = await readInput(videoPath, 12 * 1024 * 1024);
|
|
2292
|
-
const mimeType = { ".mp4": "video/mp4", ".mov": "video/quicktime", ".webm": "video/webm" }[
|
|
4024
|
+
const mimeType = { ".mp4": "video/mp4", ".mov": "video/quicktime", ".webm": "video/webm" }[extname3(videoPath).toLowerCase()];
|
|
2293
4025
|
if (!mimeType) throw new Error("Use MP4, MOV or WebM.");
|
|
2294
4026
|
assetId = await sha256(bytes);
|
|
2295
|
-
const uploaded = await client.media(`/v1/assets/${assetId}?name=${encodeURIComponent(
|
|
4027
|
+
const uploaded = await client.media(`/v1/assets/${assetId}?name=${encodeURIComponent(basename2(videoPath))}`, { content: bytes, mimeType });
|
|
2296
4028
|
if ((await uploaded.json()).sha256 !== assetId) throw new Error("Upload hash mismatch.");
|
|
2297
4029
|
}
|
|
2298
4030
|
if (!script && !assetId && !str(args, "project")) throw new Error("Provide --script, --video, --asset or --project with a saved script.");
|
|
@@ -2307,20 +4039,20 @@ async function main() {
|
|
|
2307
4039
|
}
|
|
2308
4040
|
if (subcommand === "get" && argument) {
|
|
2309
4041
|
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 ||
|
|
4042
|
+
const hash2 = str(args, "sha256");
|
|
4043
|
+
if (hash2 && !document) throw new Error("--sha256 requires --document for library get.");
|
|
4044
|
+
const result = await client.request(`/v1/library/${encodeURIComponent(argument)}${document ? `/documents?${new URLSearchParams({ path: document, ...hash2 ? { sha256: hash2 } : {} })}` : ""}`);
|
|
4045
|
+
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
4046
|
output(result);
|
|
2315
4047
|
return;
|
|
2316
4048
|
}
|
|
2317
4049
|
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/${
|
|
4050
|
+
const hash2 = str(args, "sha256");
|
|
4051
|
+
if (!hash2 || !/^[a-f0-9]{64}$/.test(hash2)) throw new Error("--sha256 must be a media SHA-256 from library get.");
|
|
4052
|
+
const destination = resolve6(str(args, "dir") ?? process.cwd(), str(args, "out"));
|
|
4053
|
+
const root2 = await ensureRoot(dirname6(destination), true);
|
|
4054
|
+
await checkedPath(root2, basename2(destination), false);
|
|
4055
|
+
const response = await client.media(`/v1/library/${encodeURIComponent(argument)}/media/${hash2}`);
|
|
2324
4056
|
const reader = response.body?.getReader();
|
|
2325
4057
|
if (!reader) throw new Error("Media body missing.");
|
|
2326
4058
|
const chunks = [];
|
|
@@ -2336,9 +4068,9 @@ async function main() {
|
|
|
2336
4068
|
chunks.push(value);
|
|
2337
4069
|
}
|
|
2338
4070
|
const bytes = Buffer.concat(chunks);
|
|
2339
|
-
if (await sha256(bytes) !==
|
|
2340
|
-
await atomicWrite(root2,
|
|
2341
|
-
output({ videoId: argument, path: destination, sha256:
|
|
4071
|
+
if (await sha256(bytes) !== hash2) throw new CliError("HASH_MISMATCH", "Reference media verification failed.", "Do not use this file as evidence.");
|
|
4072
|
+
await atomicWrite(root2, basename2(destination), bytes, { document: false, overwrite: false });
|
|
4073
|
+
output({ videoId: argument, path: destination, sha256: hash2, bytes: size, mimeType: response.headers.get("Content-Type"), reviewed: false });
|
|
2342
4074
|
return;
|
|
2343
4075
|
}
|
|
2344
4076
|
throw new Error("Use library search, library get, or library media <video-id> --sha256 hash --out file.");
|
|
@@ -2373,7 +4105,7 @@ async function main() {
|
|
|
2373
4105
|
const adapter = str(args, "adapter") ?? "neutral";
|
|
2374
4106
|
if (!["neutral", "hyperframes", "remotion"].includes(adapter)) throw new Error("Adapter must be neutral, hyperframes or remotion");
|
|
2375
4107
|
const generated = exportProject(resolved, adapter);
|
|
2376
|
-
const destination =
|
|
4108
|
+
const destination = resolve6(root, str(args, "out") ?? `videos/${project}/plans/${adapter}`);
|
|
2377
4109
|
const outRoot = await ensureRoot(destination, true);
|
|
2378
4110
|
for (const [path, content] of Object.entries(generated)) await atomicWrite(outRoot, path, content, { document: false });
|
|
2379
4111
|
output({ adapter, kind: "editorial_handoff", rendered: false, directory: outRoot, files: Object.keys(generated), warnings: resolved.warnings });
|