@koda-sl/baker-cli 0.159.0-dev.a0159ea6f → 0.160.0-dev.fa00ad797
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 +12 -0
- package/dist/cli.js +681 -72
- package/dist/cli.js.map +1 -1
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -4844,7 +4844,10 @@ var callAdSchema = z13.object({
|
|
|
4844
4844
|
var appAdSchema = z13.object({
|
|
4845
4845
|
format: z13.literal("app"),
|
|
4846
4846
|
headlines: z13.array(z13.object({ text: z13.string().min(1).max(30) })).min(1),
|
|
4847
|
-
descriptions: z13.array(z13.object({ text: z13.string().min(1).max(90) })).min(1)
|
|
4847
|
+
descriptions: z13.array(z13.object({ text: z13.string().min(1).max(90) })).min(1),
|
|
4848
|
+
// An App campaign ad carries its images on its own content (`AppAdInfo.images`), not as
|
|
4849
|
+
// campaign-level asset links — same shape as a responsive display ad's marketing images.
|
|
4850
|
+
images: z13.array(refSchema).optional()
|
|
4848
4851
|
});
|
|
4849
4852
|
var videoAdSchema = z13.object({
|
|
4850
4853
|
format: z13.literal("video"),
|
|
@@ -6183,6 +6186,443 @@ import { defineCommand as defineCommand22 } from "citty";
|
|
|
6183
6186
|
// src/commands/ads/google/write-shared.ts
|
|
6184
6187
|
import { readFileSync as readFileSync2 } from "fs";
|
|
6185
6188
|
|
|
6189
|
+
// src/engine/copy-tells/patterns.ts
|
|
6190
|
+
var PHRASE_TELLS = {
|
|
6191
|
+
// §33 — the fake-candid pause before an ordinary claim. The single loudest
|
|
6192
|
+
// tell in AI-written Meta primary text. Anchored to a sentence start by the
|
|
6193
|
+
// detector, so "take a look at" and "honestly, I agree" mid-sentence are safe.
|
|
6194
|
+
"rhetorical-opener": [
|
|
6195
|
+
"honestly\\?",
|
|
6196
|
+
"honestly,",
|
|
6197
|
+
"here's the thing",
|
|
6198
|
+
"here is the thing",
|
|
6199
|
+
"the thing is",
|
|
6200
|
+
"let's be honest",
|
|
6201
|
+
"let's be real",
|
|
6202
|
+
"real talk",
|
|
6203
|
+
"look,",
|
|
6204
|
+
"listen,",
|
|
6205
|
+
"la verdad es que",
|
|
6206
|
+
"sinceramente,",
|
|
6207
|
+
"seamos honestos",
|
|
6208
|
+
"seamos sinceros",
|
|
6209
|
+
"mira,",
|
|
6210
|
+
"escucha,",
|
|
6211
|
+
"te lo digo claro"
|
|
6212
|
+
],
|
|
6213
|
+
// §9 — negative parallelism. Our landing doctrine already names this in prose;
|
|
6214
|
+
// this is the detector that was missing.
|
|
6215
|
+
"negation-pivot": [
|
|
6216
|
+
"it's not just",
|
|
6217
|
+
"it is not just",
|
|
6218
|
+
"it's not merely",
|
|
6219
|
+
"not only .{3,40} but also",
|
|
6220
|
+
"less about .{3,40} more about",
|
|
6221
|
+
"isn't about .{3,40} it's about",
|
|
6222
|
+
"no se trata solo de",
|
|
6223
|
+
"no se trata \xFAnicamente de",
|
|
6224
|
+
"no solo .{3,40} sino",
|
|
6225
|
+
"no es solo .{3,40} es",
|
|
6226
|
+
"m\xE1s que .{3,40}, es"
|
|
6227
|
+
],
|
|
6228
|
+
// §3 — present participle hung off a comma to fake depth. Detector requires
|
|
6229
|
+
// the preceding comma, so a sentence-initial participle is not a tell.
|
|
6230
|
+
"participle-tail": [
|
|
6231
|
+
"ensuring",
|
|
6232
|
+
"highlighting",
|
|
6233
|
+
"underscoring",
|
|
6234
|
+
"emphasizing",
|
|
6235
|
+
"showcasing",
|
|
6236
|
+
"fostering",
|
|
6237
|
+
"empowering",
|
|
6238
|
+
"reflecting",
|
|
6239
|
+
"symbolizing",
|
|
6240
|
+
"contributing to",
|
|
6241
|
+
"encompassing",
|
|
6242
|
+
"cultivating",
|
|
6243
|
+
"permitiendo",
|
|
6244
|
+
"garantizando",
|
|
6245
|
+
"impulsando",
|
|
6246
|
+
"reflejando",
|
|
6247
|
+
"consolidando",
|
|
6248
|
+
"potenciando"
|
|
6249
|
+
],
|
|
6250
|
+
// §8 — elaborate constructions standing in for "is" and "has".
|
|
6251
|
+
"copula-avoidance": [
|
|
6252
|
+
"serves as",
|
|
6253
|
+
"stands as",
|
|
6254
|
+
"boasts",
|
|
6255
|
+
"represents a",
|
|
6256
|
+
"marks a",
|
|
6257
|
+
"se erige como",
|
|
6258
|
+
"se posiciona como",
|
|
6259
|
+
"cuenta con",
|
|
6260
|
+
"se consolida como",
|
|
6261
|
+
"constituye un"
|
|
6262
|
+
],
|
|
6263
|
+
// §27 — pretending to cut through noise before restating an ordinary point.
|
|
6264
|
+
"authority-trope": [
|
|
6265
|
+
"the real question is",
|
|
6266
|
+
"at its core",
|
|
6267
|
+
"what really matters",
|
|
6268
|
+
"the heart of the matter",
|
|
6269
|
+
"the deeper issue",
|
|
6270
|
+
"in reality,",
|
|
6271
|
+
"la verdadera pregunta",
|
|
6272
|
+
"en el fondo,",
|
|
6273
|
+
"lo que realmente importa",
|
|
6274
|
+
"en esencia,"
|
|
6275
|
+
],
|
|
6276
|
+
// §28 — announcing the writing instead of doing it.
|
|
6277
|
+
signposting: [
|
|
6278
|
+
"let's dive in",
|
|
6279
|
+
"let's dive into",
|
|
6280
|
+
"let's explore",
|
|
6281
|
+
"let's break (?:this|it) down",
|
|
6282
|
+
"here's what you need to know",
|
|
6283
|
+
"here is what you need to know",
|
|
6284
|
+
"now let's look at",
|
|
6285
|
+
"without further ado",
|
|
6286
|
+
"vamos a ver",
|
|
6287
|
+
"vamos a analizar",
|
|
6288
|
+
"profundicemos",
|
|
6289
|
+
"esto es lo que necesitas saber",
|
|
6290
|
+
"te lo explicamos paso a paso"
|
|
6291
|
+
],
|
|
6292
|
+
// §20 — chatbot correspondence pasted in as content. Always a defect: it means
|
|
6293
|
+
// the agent shipped its own conversational frame into client-facing copy.
|
|
6294
|
+
//
|
|
6295
|
+
// This is the only tell the landing gate fires at ONE occurrence, so every
|
|
6296
|
+
// entry has to be unambiguous. The delivery openers are therefore bound to the
|
|
6297
|
+
// noun the assistant hands over ("here is a summary", "aquí tienes el
|
|
6298
|
+
// resumen"): a bare "here is a…" / "aquí tienes…" is ordinary marketing copy.
|
|
6299
|
+
"chat-artifact": [
|
|
6300
|
+
"i hope this helps",
|
|
6301
|
+
"let me know if",
|
|
6302
|
+
"would you like me to",
|
|
6303
|
+
"want me to",
|
|
6304
|
+
"here (?:is|are) (?:an? |the )?(?:overview|summary|breakdown|rundown|outline|draft|revised version)",
|
|
6305
|
+
"here's (?:an? |the )?(?:overview|summary|breakdown|rundown|outline|draft|revised version)",
|
|
6306
|
+
"certainly!",
|
|
6307
|
+
"great question",
|
|
6308
|
+
"you're absolutely right",
|
|
6309
|
+
"espero que (?:te sirva|esto ayude)",
|
|
6310
|
+
"av\xEDsame si",
|
|
6311
|
+
"\xBFquieres que",
|
|
6312
|
+
"aqu\xED tienes (?:un |una |el |la |los |las )?(?:resumen|listado|desglose|borrador|esquema|propuesta|versi\xF3n)",
|
|
6313
|
+
"por supuesto!"
|
|
6314
|
+
],
|
|
6315
|
+
// §5 — opinions attributed to nobody in particular.
|
|
6316
|
+
"vague-attribution": [
|
|
6317
|
+
"experts agree",
|
|
6318
|
+
"experts say",
|
|
6319
|
+
"experts argue",
|
|
6320
|
+
"studies show",
|
|
6321
|
+
"research shows",
|
|
6322
|
+
"industry reports",
|
|
6323
|
+
"observers have",
|
|
6324
|
+
"some critics",
|
|
6325
|
+
"it is believed that",
|
|
6326
|
+
"los expertos coinciden",
|
|
6327
|
+
"los expertos afirman",
|
|
6328
|
+
"los estudios demuestran",
|
|
6329
|
+
"seg\xFAn varios informes",
|
|
6330
|
+
"se cree que"
|
|
6331
|
+
],
|
|
6332
|
+
// §25 — vague upbeat endings that assert nothing.
|
|
6333
|
+
"hollow-conclusion": [
|
|
6334
|
+
"the future looks bright",
|
|
6335
|
+
"exciting times",
|
|
6336
|
+
"a step in the right direction",
|
|
6337
|
+
"the possibilities are endless",
|
|
6338
|
+
"the sky's the limit",
|
|
6339
|
+
"el futuro es prometedor",
|
|
6340
|
+
"las posibilidades son infinitas",
|
|
6341
|
+
"un paso en la direcci\xF3n correcta",
|
|
6342
|
+
"el cielo es el l\xEDmite"
|
|
6343
|
+
],
|
|
6344
|
+
// §23 — words that carry no load.
|
|
6345
|
+
"filler-phrase": [
|
|
6346
|
+
"in order to",
|
|
6347
|
+
"due to the fact that",
|
|
6348
|
+
"at this point in time",
|
|
6349
|
+
"has the ability to",
|
|
6350
|
+
"have the ability to",
|
|
6351
|
+
"it is important to note",
|
|
6352
|
+
"it is worth noting",
|
|
6353
|
+
"con el fin de",
|
|
6354
|
+
"debido al hecho de que",
|
|
6355
|
+
"en este momento del tiempo",
|
|
6356
|
+
"tiene la capacidad de",
|
|
6357
|
+
"cabe destacar",
|
|
6358
|
+
"cabe se\xF1alar",
|
|
6359
|
+
"es importante se\xF1alar"
|
|
6360
|
+
],
|
|
6361
|
+
// §32 — ordinary claims dressed as reusable aphorisms.
|
|
6362
|
+
"aphorism-formula": [
|
|
6363
|
+
"the language of",
|
|
6364
|
+
"the currency of",
|
|
6365
|
+
"the architecture of",
|
|
6366
|
+
"the backbone of",
|
|
6367
|
+
"el lenguaje de",
|
|
6368
|
+
"la moneda de",
|
|
6369
|
+
"la columna vertebral de"
|
|
6370
|
+
]
|
|
6371
|
+
};
|
|
6372
|
+
var SENTENCE_INITIAL_TELLS = /* @__PURE__ */ new Set(["rhetorical-opener"]);
|
|
6373
|
+
var COMMA_ANCHORED_TELLS = /* @__PURE__ */ new Set(["participle-tail"]);
|
|
6374
|
+
var STACCATO_MAX_WORDS = 4;
|
|
6375
|
+
var STACCATO_RUN = 3;
|
|
6376
|
+
var SYNONYM_OVERLAP = 0.6;
|
|
6377
|
+
var HEADLINE_SET_FLOOR = 3;
|
|
6378
|
+
var CYCLED_VERBS = [
|
|
6379
|
+
["boost", "increase", "grow", "raise", "lift", "improve", "enhance", "maximize", "drive"],
|
|
6380
|
+
["cut", "reduce", "lower", "slash", "shrink", "minimize", "decrease"],
|
|
6381
|
+
["get", "obtain", "receive", "secure", "unlock", "claim"],
|
|
6382
|
+
["fast", "quick", "rapid", "swift", "speedy", "instant"],
|
|
6383
|
+
["aumenta", "incrementa", "mejora", "impulsa", "potencia", "maximiza", "multiplica"],
|
|
6384
|
+
["reduce", "recorta", "baja", "disminuye", "minimiza"],
|
|
6385
|
+
["consigue", "obt\xE9n", "logra", "alcanza", "desbloquea"],
|
|
6386
|
+
["r\xE1pido", "veloz", "inmediato", "al instante"]
|
|
6387
|
+
];
|
|
6388
|
+
|
|
6389
|
+
// src/engine/copy-tells/detect.ts
|
|
6390
|
+
var SAMPLE_MAX = 60;
|
|
6391
|
+
function sample(text) {
|
|
6392
|
+
const clean = text.replace(/\s+/g, " ").trim();
|
|
6393
|
+
return clean.length <= SAMPLE_MAX ? clean : `${clean.slice(0, SAMPLE_MAX)}\u2026`;
|
|
6394
|
+
}
|
|
6395
|
+
var DELIBERATE_REGEX = /\.\{\d+,\d+\}|\(\?:[^()]*\)|\\?\?/g;
|
|
6396
|
+
function escapeLiteral(text) {
|
|
6397
|
+
return text.replace(/[.*+^$?()|[\]{}\\]/g, (ch) => `\\${ch}`);
|
|
6398
|
+
}
|
|
6399
|
+
function toPattern(phrase) {
|
|
6400
|
+
let out = "";
|
|
6401
|
+
let last = 0;
|
|
6402
|
+
for (const m of phrase.matchAll(DELIBERATE_REGEX)) {
|
|
6403
|
+
const start = m.index ?? 0;
|
|
6404
|
+
out += escapeLiteral(phrase.slice(last, start)) + m[0];
|
|
6405
|
+
last = start + m[0].length;
|
|
6406
|
+
}
|
|
6407
|
+
return out + escapeLiteral(phrase.slice(last));
|
|
6408
|
+
}
|
|
6409
|
+
var WORD_START = "(?<![\\p{L}\\p{N}])";
|
|
6410
|
+
function matcherFor(id, phrase) {
|
|
6411
|
+
const body = toPattern(phrase);
|
|
6412
|
+
if (SENTENCE_INITIAL_TELLS.has(id)) return new RegExp(`(?:^|[.!?\xBF\xA1]\\s+|\\n)\\s*${body}`, "giu");
|
|
6413
|
+
if (COMMA_ANCHORED_TELLS.has(id)) return new RegExp(`,\\s+${body}\\b`, "giu");
|
|
6414
|
+
return new RegExp(`${WORD_START}${body}`, "giu");
|
|
6415
|
+
}
|
|
6416
|
+
var FALSE_RANGE_PATTERNS = [
|
|
6417
|
+
/\bfrom\s+([^,.;]{3,30}?)\s+to\s+([^,.;]{3,30}?)(?=[,.;]|$)/gi,
|
|
6418
|
+
/\bdesde\s+([^,.;]{3,30}?)\s+hasta\s+([^,.;]{3,30}?)(?=[,.;]|$)/gi
|
|
6419
|
+
];
|
|
6420
|
+
function countFalseRanges(text) {
|
|
6421
|
+
let count = 0;
|
|
6422
|
+
let first = "";
|
|
6423
|
+
for (const re of FALSE_RANGE_PATTERNS) {
|
|
6424
|
+
for (const m of text.matchAll(re)) {
|
|
6425
|
+
const left = m[1] ?? "";
|
|
6426
|
+
const right = m[2] ?? "";
|
|
6427
|
+
if (/\d/.test(left) || /\d/.test(right)) continue;
|
|
6428
|
+
count++;
|
|
6429
|
+
if (!first) first = m[0] ?? "";
|
|
6430
|
+
}
|
|
6431
|
+
}
|
|
6432
|
+
return { count, first };
|
|
6433
|
+
}
|
|
6434
|
+
function countStaccato(text) {
|
|
6435
|
+
const sentences = text.split(/(?<=[.!?])\s+/).map((s) => s.trim()).filter(Boolean);
|
|
6436
|
+
let run = 0;
|
|
6437
|
+
let count = 0;
|
|
6438
|
+
let first = "";
|
|
6439
|
+
let runStart = 0;
|
|
6440
|
+
for (const [i, s] of sentences.entries()) {
|
|
6441
|
+
const words2 = s.split(/\s+/).filter(Boolean).length;
|
|
6442
|
+
if (words2 > 0 && words2 <= STACCATO_MAX_WORDS) {
|
|
6443
|
+
if (run === 0) runStart = i;
|
|
6444
|
+
run++;
|
|
6445
|
+
if (run === STACCATO_RUN) {
|
|
6446
|
+
count++;
|
|
6447
|
+
if (!first) first = sentences.slice(runStart, i + 1).join(" ");
|
|
6448
|
+
}
|
|
6449
|
+
} else {
|
|
6450
|
+
run = 0;
|
|
6451
|
+
}
|
|
6452
|
+
}
|
|
6453
|
+
return { count, first };
|
|
6454
|
+
}
|
|
6455
|
+
function countTitleCaseHeadings(text) {
|
|
6456
|
+
let count = 0;
|
|
6457
|
+
let first = "";
|
|
6458
|
+
for (const m of text.matchAll(/^\s{0,3}#{1,6}\s+(.+)$/gm)) {
|
|
6459
|
+
const heading = (m[1] ?? "").trim();
|
|
6460
|
+
const words2 = heading.split(/\s+/).filter((w) => new RegExp("\\p{L}", "u").test(w));
|
|
6461
|
+
if (words2.length < 4) continue;
|
|
6462
|
+
const capitalized = words2.filter((w) => new RegExp("^\\p{Lu}", "u").test(w)).length;
|
|
6463
|
+
if (capitalized / words2.length < 0.8) continue;
|
|
6464
|
+
count++;
|
|
6465
|
+
if (!first) first = heading;
|
|
6466
|
+
}
|
|
6467
|
+
return { count, first };
|
|
6468
|
+
}
|
|
6469
|
+
function countInlineHeaderList(text) {
|
|
6470
|
+
const matches = [...text.matchAll(/^\s*(?:[-*+]|\d+\.)\s+\*\*[^*\n]{2,40}:?\*\*:?\s/gm)];
|
|
6471
|
+
if (matches.length < 3) return { count: 0, first: "" };
|
|
6472
|
+
return { count: matches.length, first: (matches[0]?.[0] ?? "").trim() };
|
|
6473
|
+
}
|
|
6474
|
+
var EMOJI = /[\u{1F300}-\u{1FAFF}]|[\u{2600}-\u{27BF}]|[\u{1F000}-\u{1F2FF}]|\u{FE0F}/gu;
|
|
6475
|
+
var CURLY = /[‘’“”]/g;
|
|
6476
|
+
function countPhraseTell(text, id, phrases) {
|
|
6477
|
+
let count = 0;
|
|
6478
|
+
let first = "";
|
|
6479
|
+
for (const phrase of phrases) {
|
|
6480
|
+
for (const m of text.matchAll(matcherFor(id, phrase))) {
|
|
6481
|
+
count++;
|
|
6482
|
+
if (!first) first = m[0] ?? "";
|
|
6483
|
+
}
|
|
6484
|
+
}
|
|
6485
|
+
return { count, first };
|
|
6486
|
+
}
|
|
6487
|
+
function countPattern(text, re) {
|
|
6488
|
+
const matches = text.match(re) ?? [];
|
|
6489
|
+
return { count: matches.length, first: matches.join("") };
|
|
6490
|
+
}
|
|
6491
|
+
function detectCopyTells(text) {
|
|
6492
|
+
if (!text.trim()) return [];
|
|
6493
|
+
const results = [
|
|
6494
|
+
...Object.entries(PHRASE_TELLS).map(([id, phrases]) => [
|
|
6495
|
+
id,
|
|
6496
|
+
countPhraseTell(text, id, phrases)
|
|
6497
|
+
]),
|
|
6498
|
+
["false-range", countFalseRanges(text)],
|
|
6499
|
+
["staccato-drama", countStaccato(text)],
|
|
6500
|
+
["title-case-heading", countTitleCaseHeadings(text)],
|
|
6501
|
+
["inline-header-list", countInlineHeaderList(text)],
|
|
6502
|
+
["curly-quote", countPattern(text, CURLY)],
|
|
6503
|
+
["emoji", countPattern(text, EMOJI)]
|
|
6504
|
+
];
|
|
6505
|
+
return results.filter(([, result]) => result.count > 0).map(([id, result]) => ({ id, count: result.count, sample: sample(result.first) }));
|
|
6506
|
+
}
|
|
6507
|
+
function words(headline) {
|
|
6508
|
+
return headline.toLowerCase().replace(/[^\p{L}\p{N}\s]/gu, " ").split(/\s+/).filter(Boolean);
|
|
6509
|
+
}
|
|
6510
|
+
function canonical(word) {
|
|
6511
|
+
for (const [i, group] of CYCLED_VERBS.entries()) if (group.includes(word)) return `group:${i}`;
|
|
6512
|
+
return word;
|
|
6513
|
+
}
|
|
6514
|
+
function overlap(a, b) {
|
|
6515
|
+
if (a.size === 0 || b.size === 0) return 0;
|
|
6516
|
+
let shared = 0;
|
|
6517
|
+
for (const w of a) if (b.has(w)) shared++;
|
|
6518
|
+
return shared / Math.max(a.size, b.size);
|
|
6519
|
+
}
|
|
6520
|
+
function detectHeadlineSetTells(headlines) {
|
|
6521
|
+
const usable = headlines.map((h) => h.trim()).filter(Boolean);
|
|
6522
|
+
if (usable.length < HEADLINE_SET_FLOOR) return [];
|
|
6523
|
+
const sets = usable.map((h) => new Set(words(h).map(canonical)));
|
|
6524
|
+
const cycled = [];
|
|
6525
|
+
for (let i = 0; i < sets.length; i++) {
|
|
6526
|
+
for (let j = i + 1; j < sets.length; j++) {
|
|
6527
|
+
if (overlap(sets[i] ?? /* @__PURE__ */ new Set(), sets[j] ?? /* @__PURE__ */ new Set()) >= SYNONYM_OVERLAP) {
|
|
6528
|
+
cycled.push(`"${usable[i]}" / "${usable[j]}"`);
|
|
6529
|
+
}
|
|
6530
|
+
}
|
|
6531
|
+
}
|
|
6532
|
+
if (cycled.length === 0) return [];
|
|
6533
|
+
return [{ id: "synonym-cycling", count: cycled.length, sample: sample(cycled[0] ?? "") }];
|
|
6534
|
+
}
|
|
6535
|
+
|
|
6536
|
+
// src/commands/ads/copy-hints.ts
|
|
6537
|
+
var NOT_APPLICABLE_TO_ADS = /* @__PURE__ */ new Set([
|
|
6538
|
+
"title-case-heading",
|
|
6539
|
+
"staccato-drama",
|
|
6540
|
+
"emoji",
|
|
6541
|
+
"curly-quote",
|
|
6542
|
+
"inline-header-list"
|
|
6543
|
+
]);
|
|
6544
|
+
var COPY_KEYS = /* @__PURE__ */ new Set([
|
|
6545
|
+
"headline",
|
|
6546
|
+
"headlines",
|
|
6547
|
+
"longheadline",
|
|
6548
|
+
"longheadlines",
|
|
6549
|
+
"shortheadline",
|
|
6550
|
+
"shortheadlines",
|
|
6551
|
+
"description",
|
|
6552
|
+
"descriptions",
|
|
6553
|
+
"primarytext",
|
|
6554
|
+
"primarytexts",
|
|
6555
|
+
"message",
|
|
6556
|
+
"messages",
|
|
6557
|
+
"commentary",
|
|
6558
|
+
"body",
|
|
6559
|
+
"bodies",
|
|
6560
|
+
"text",
|
|
6561
|
+
"calltoaction",
|
|
6562
|
+
"linkdescription",
|
|
6563
|
+
"sitelinktext",
|
|
6564
|
+
"callout",
|
|
6565
|
+
"callouts",
|
|
6566
|
+
"introtext"
|
|
6567
|
+
]);
|
|
6568
|
+
var HEADLINE_SET_KEYS = /* @__PURE__ */ new Set(["headlines", "longheadlines", "shortheadlines"]);
|
|
6569
|
+
var MAX_HINTS = 5;
|
|
6570
|
+
var SYNONYM_PAIR_FLOOR = 2;
|
|
6571
|
+
function asStrings(value) {
|
|
6572
|
+
const one = (v) => {
|
|
6573
|
+
const text = typeof v === "string" ? v : v !== null && typeof v === "object" ? v.text : "";
|
|
6574
|
+
return typeof text === "string" && text.trim() ? text : void 0;
|
|
6575
|
+
};
|
|
6576
|
+
const values = Array.isArray(value) ? value : [value];
|
|
6577
|
+
return values.map(one).filter((v) => v !== void 0);
|
|
6578
|
+
}
|
|
6579
|
+
function collectCopy(value, depth = 0) {
|
|
6580
|
+
const texts = [];
|
|
6581
|
+
const headlineSets = [];
|
|
6582
|
+
if (depth > 4 || value === null || typeof value !== "object") return { texts, headlineSets };
|
|
6583
|
+
if (Array.isArray(value)) {
|
|
6584
|
+
for (const item of value) {
|
|
6585
|
+
const nested = collectCopy(item, depth + 1);
|
|
6586
|
+
texts.push(...nested.texts);
|
|
6587
|
+
headlineSets.push(...nested.headlineSets);
|
|
6588
|
+
}
|
|
6589
|
+
return { texts, headlineSets };
|
|
6590
|
+
}
|
|
6591
|
+
for (const [key, raw] of Object.entries(value)) {
|
|
6592
|
+
const normalized = key.toLowerCase().replace(/[_-]/g, "");
|
|
6593
|
+
const strings = COPY_KEYS.has(normalized) ? asStrings(raw) : [];
|
|
6594
|
+
if (strings.length > 0) {
|
|
6595
|
+
texts.push(...strings);
|
|
6596
|
+
if (HEADLINE_SET_KEYS.has(normalized) && strings.length > 1) headlineSets.push(strings);
|
|
6597
|
+
continue;
|
|
6598
|
+
}
|
|
6599
|
+
const nested = collectCopy(raw, depth + 1);
|
|
6600
|
+
texts.push(...nested.texts);
|
|
6601
|
+
headlineSets.push(...nested.headlineSets);
|
|
6602
|
+
}
|
|
6603
|
+
return { texts, headlineSets };
|
|
6604
|
+
}
|
|
6605
|
+
function hintFor(tell) {
|
|
6606
|
+
if (tell.id === "synonym-cycling") {
|
|
6607
|
+
return `AI COPY TELL \u2014 ${tell.count} headline pairs are the same claim reworded (${tell.sample}). A cycled set gives the auction nothing to choose between: make each headline a different claim, not a different verb.`;
|
|
6608
|
+
}
|
|
6609
|
+
return `AI COPY TELL (${tell.id}) \u2014 "${tell.sample}". This construction reads as machine-written; rewrite it as a plain, specific claim before publishing.`;
|
|
6610
|
+
}
|
|
6611
|
+
function adCopyHints(payload) {
|
|
6612
|
+
const { texts, headlineSets } = collectCopy(payload);
|
|
6613
|
+
if (texts.length === 0) return [];
|
|
6614
|
+
const tells = detectCopyTells(texts.join("\n")).filter((t) => !NOT_APPLICABLE_TO_ADS.has(t.id));
|
|
6615
|
+
for (const set of headlineSets) {
|
|
6616
|
+
for (const tell of detectHeadlineSetTells(set)) {
|
|
6617
|
+
if (tell.count >= SYNONYM_PAIR_FLOOR) tells.push(tell);
|
|
6618
|
+
}
|
|
6619
|
+
}
|
|
6620
|
+
tells.sort(
|
|
6621
|
+
(a, b) => (b.id === "synonym-cycling" ? 1 : 0) - (a.id === "synonym-cycling" ? 1 : 0) || b.count - a.count
|
|
6622
|
+
);
|
|
6623
|
+
return tells.slice(0, MAX_HINTS).map(hintFor);
|
|
6624
|
+
}
|
|
6625
|
+
|
|
6186
6626
|
// src/commands/ads/google/draft-status.ts
|
|
6187
6627
|
var OPERATION_LABELS = {
|
|
6188
6628
|
create: "Creating",
|
|
@@ -6454,7 +6894,7 @@ async function stageGoogleOp(raw, hints) {
|
|
|
6454
6894
|
try {
|
|
6455
6895
|
const chatId = requireChatId();
|
|
6456
6896
|
const response = await apiPost("/api/ads/google/draft/stage", { chatId, op: preflight.data });
|
|
6457
|
-
const allHints = [...noopHints(response.data), ...hints ?? []];
|
|
6897
|
+
const allHints = [...noopHints(response.data), ...hints ?? [], ...adCopyHints(preflight.data)];
|
|
6458
6898
|
writeJsonEnvelope(allHints.length > 0 ? { ...response, hints: allHints } : response);
|
|
6459
6899
|
} catch (err) {
|
|
6460
6900
|
handleGoogleError(err);
|
|
@@ -6496,15 +6936,16 @@ async function stageGoogleOps(rawOps, hints) {
|
|
|
6496
6936
|
...skipped.length > 0 ? [
|
|
6497
6937
|
`${skipped.length} of ${ops.length} op(s) NOT STAGED \u2014 already in the requested state: ${skipped.map((s) => s.reason).join("; ")}`
|
|
6498
6938
|
] : [],
|
|
6499
|
-
...hints ?? []
|
|
6939
|
+
...hints ?? [],
|
|
6940
|
+
...adCopyHints(ops)
|
|
6500
6941
|
];
|
|
6501
6942
|
writeJsonEnvelope(allHints.length > 0 ? { ...response, hints: allHints } : response);
|
|
6502
6943
|
} catch (err) {
|
|
6503
6944
|
handleGoogleError(err);
|
|
6504
6945
|
}
|
|
6505
6946
|
}
|
|
6506
|
-
async function stageUpdate(kind, customerId, target, payload) {
|
|
6507
|
-
await stageGoogleOp({ kind, customerId, target, payload });
|
|
6947
|
+
async function stageUpdate(kind, customerId, target, payload, hints) {
|
|
6948
|
+
await stageGoogleOp({ kind, customerId, target, payload }, hints);
|
|
6508
6949
|
}
|
|
6509
6950
|
async function stageTarget(kind, customerId, target) {
|
|
6510
6951
|
await stageGoogleOp({ kind, customerId, target });
|
|
@@ -8381,6 +8822,25 @@ function demandGenContentFromFlags(args, base) {
|
|
|
8381
8822
|
}
|
|
8382
8823
|
return content;
|
|
8383
8824
|
}
|
|
8825
|
+
function appContentFromFlags(args, base) {
|
|
8826
|
+
const content = {
|
|
8827
|
+
format: "app",
|
|
8828
|
+
...base !== null && typeof base === "object" && !Array.isArray(base) ? base : {}
|
|
8829
|
+
};
|
|
8830
|
+
const headlines = listFlag(args.headlines);
|
|
8831
|
+
if (headlines) {
|
|
8832
|
+
content.headlines = headlines.map((text) => ({ text }));
|
|
8833
|
+
}
|
|
8834
|
+
const descriptions = listFlag(args.descriptions);
|
|
8835
|
+
if (descriptions) {
|
|
8836
|
+
content.descriptions = descriptions.map((text) => ({ text }));
|
|
8837
|
+
}
|
|
8838
|
+
const images = listFlag(args["image-assets"]);
|
|
8839
|
+
if (images) {
|
|
8840
|
+
content.images = images;
|
|
8841
|
+
}
|
|
8842
|
+
return content;
|
|
8843
|
+
}
|
|
8384
8844
|
function adContentFromFlags(args, format, fileContent) {
|
|
8385
8845
|
if (format === "responsiveSearch") {
|
|
8386
8846
|
return fileContent ?? rsaContentFromFlags(args);
|
|
@@ -8394,8 +8854,48 @@ function adContentFromFlags(args, format, fileContent) {
|
|
|
8394
8854
|
if (format === "demandGen") {
|
|
8395
8855
|
return demandGenContentFromFlags(args, fileContent);
|
|
8396
8856
|
}
|
|
8857
|
+
if (format === "app") {
|
|
8858
|
+
return appContentFromFlags(args, fileContent);
|
|
8859
|
+
}
|
|
8397
8860
|
return fileContent ?? failWriteValidation(`--format ${format} needs --file with the ad content`);
|
|
8398
8861
|
}
|
|
8862
|
+
var adContentArgs = {
|
|
8863
|
+
format: {
|
|
8864
|
+
type: "string",
|
|
8865
|
+
description: "responsiveSearch (default) | responsiveDisplay | call | app | video | demandGen"
|
|
8866
|
+
},
|
|
8867
|
+
headlines: { type: "string", description: "Comma-separated headlines (RSA/RDA/demandGen/app)" },
|
|
8868
|
+
descriptions: { type: "string", description: "Comma-separated descriptions (RSA/RDA/demandGen/app)" },
|
|
8869
|
+
path1: { type: "string" },
|
|
8870
|
+
path2: { type: "string" },
|
|
8871
|
+
"long-headline": { type: "string", description: "Long headline (responsiveDisplay)" },
|
|
8872
|
+
"business-name": { type: "string", description: "Business name (responsiveDisplay/demandGen)" },
|
|
8873
|
+
"marketing-images": {
|
|
8874
|
+
type: "string",
|
|
8875
|
+
description: "Comma-separated marketing image asset refs (responsiveDisplay)"
|
|
8876
|
+
},
|
|
8877
|
+
"square-marketing-images": {
|
|
8878
|
+
type: "string",
|
|
8879
|
+
description: "Comma-separated square marketing image asset refs (responsiveDisplay)"
|
|
8880
|
+
},
|
|
8881
|
+
"logo-images": { type: "string", description: "Comma-separated logo image asset refs (responsiveDisplay)" },
|
|
8882
|
+
"video-assets": {
|
|
8883
|
+
type: "string",
|
|
8884
|
+
description: `Comma-separated video asset refs (video format) \u2014 stage the YouTube video as an asset first (assets create --file '{"type":"youtubeVideo",\u2026}')`
|
|
8885
|
+
},
|
|
8886
|
+
"image-assets": { type: "string", description: "Comma-separated image asset refs (demandGen/app)" },
|
|
8887
|
+
"square-image-assets": { type: "string", description: "Comma-separated square image asset refs (demandGen)" },
|
|
8888
|
+
"logo-image-assets": { type: "string", description: "Comma-separated logo image asset refs (demandGen)" },
|
|
8889
|
+
"final-url": { type: "string", description: "Comma-separated final URLs" }
|
|
8890
|
+
};
|
|
8891
|
+
function assertNoContentFlags(args) {
|
|
8892
|
+
const passed = Object.keys(adContentArgs).filter((flag) => flag !== "format" && args[flag] !== void 0);
|
|
8893
|
+
if (passed.length > 0) {
|
|
8894
|
+
failWriteValidation(
|
|
8895
|
+
`${passed.map((flag) => `--${flag}`).join(", ")} only build an ad's content alongside --format (responsiveSearch | responsiveDisplay | call | app | video | demandGen) \u2014 add it, or pass --file with the complete content`
|
|
8896
|
+
);
|
|
8897
|
+
}
|
|
8898
|
+
}
|
|
8399
8899
|
var adsCommand = defineCommand30({
|
|
8400
8900
|
meta: { name: "ads", description: "Stage ad create/update/pause/resume/remove" },
|
|
8401
8901
|
subCommands: {
|
|
@@ -8408,33 +8908,7 @@ var adsCommand = defineCommand30({
|
|
|
8408
8908
|
...customerIdArg,
|
|
8409
8909
|
...fileArg,
|
|
8410
8910
|
"ad-group-ref": { type: "string", description: "Ad group ref" },
|
|
8411
|
-
|
|
8412
|
-
type: "string",
|
|
8413
|
-
description: "responsiveSearch (default) | responsiveDisplay | call | app | video | demandGen"
|
|
8414
|
-
},
|
|
8415
|
-
headlines: { type: "string", description: "Comma-separated headlines (RSA/RDA/demandGen)" },
|
|
8416
|
-
descriptions: { type: "string", description: "Comma-separated descriptions (RSA/RDA/demandGen)" },
|
|
8417
|
-
path1: { type: "string" },
|
|
8418
|
-
path2: { type: "string" },
|
|
8419
|
-
"long-headline": { type: "string", description: "Long headline (responsiveDisplay)" },
|
|
8420
|
-
"business-name": { type: "string", description: "Business name (responsiveDisplay/demandGen)" },
|
|
8421
|
-
"marketing-images": {
|
|
8422
|
-
type: "string",
|
|
8423
|
-
description: "Comma-separated marketing image asset refs (responsiveDisplay)"
|
|
8424
|
-
},
|
|
8425
|
-
"square-marketing-images": {
|
|
8426
|
-
type: "string",
|
|
8427
|
-
description: "Comma-separated square marketing image asset refs (responsiveDisplay)"
|
|
8428
|
-
},
|
|
8429
|
-
"logo-images": { type: "string", description: "Comma-separated logo image asset refs (responsiveDisplay)" },
|
|
8430
|
-
"video-assets": {
|
|
8431
|
-
type: "string",
|
|
8432
|
-
description: `Comma-separated video asset refs (video format) \u2014 stage the YouTube video as an asset first (assets create --file '{"type":"youtubeVideo",\u2026}')`
|
|
8433
|
-
},
|
|
8434
|
-
"image-assets": { type: "string", description: "Comma-separated image asset refs (demandGen)" },
|
|
8435
|
-
"square-image-assets": { type: "string", description: "Comma-separated square image asset refs (demandGen)" },
|
|
8436
|
-
"logo-image-assets": { type: "string", description: "Comma-separated logo image asset refs (demandGen)" },
|
|
8437
|
-
"final-url": { type: "string", description: "Comma-separated final URLs" },
|
|
8911
|
+
...adContentArgs,
|
|
8438
8912
|
status: { type: "string" }
|
|
8439
8913
|
},
|
|
8440
8914
|
run: async ({ args }) => {
|
|
@@ -8450,17 +8924,27 @@ var adsCommand = defineCommand30({
|
|
|
8450
8924
|
}
|
|
8451
8925
|
}),
|
|
8452
8926
|
update: defineCommand30({
|
|
8453
|
-
meta: {
|
|
8454
|
-
|
|
8927
|
+
meta: {
|
|
8928
|
+
name: "update",
|
|
8929
|
+
description: "Stage an ad update. Any content change needs --format plus EVERY required field of that format (or --file with the complete content) \u2014 a field you send replaces its current value wholesale, so a list you pass must already include the entries you want to keep. Optional asset lists you omit entirely are left untouched on the ad. Status-only changes need neither --format nor content flags."
|
|
8930
|
+
},
|
|
8931
|
+
args: { ...customerIdArg, ...fileArg, ...adContentArgs, status: { type: "string" } },
|
|
8455
8932
|
run: async ({ args }) => {
|
|
8456
8933
|
const customerId = requireCustomerId(args);
|
|
8457
8934
|
const file = loadJsonFileArg(args.file);
|
|
8458
|
-
|
|
8459
|
-
|
|
8460
|
-
|
|
8461
|
-
|
|
8462
|
-
|
|
8463
|
-
|
|
8935
|
+
const format = args.format;
|
|
8936
|
+
if (!format) {
|
|
8937
|
+
assertNoContentFlags(args);
|
|
8938
|
+
}
|
|
8939
|
+
const payload = mergePayload(file, {
|
|
8940
|
+
status: args.status,
|
|
8941
|
+
...format ? { content: adContentFromFlags(args, format, file.content) } : {}
|
|
8942
|
+
});
|
|
8943
|
+
await stageUpdate("google.ad.update", customerId, requireTarget(args, "ad"), payload, [
|
|
8944
|
+
...payload.content !== void 0 ? [
|
|
8945
|
+
"Each content field you sent replaces its current value wholesale at publish \u2014 re-check that every headline, description, final URL and asset you want to KEEP is in this payload, not just the ones you changed."
|
|
8946
|
+
] : []
|
|
8947
|
+
]);
|
|
8464
8948
|
}
|
|
8465
8949
|
}),
|
|
8466
8950
|
pause: statusCommand2("google.ad.pause", "ad"),
|
|
@@ -10539,7 +11023,7 @@ async function stageOp(raw, hints) {
|
|
|
10539
11023
|
chatId,
|
|
10540
11024
|
op: preflight.data
|
|
10541
11025
|
});
|
|
10542
|
-
const allHints = [...batchNudge(response.data), ...hints ?? []];
|
|
11026
|
+
const allHints = [...batchNudge(response.data), ...hints ?? [], ...adCopyHints(preflight.data)];
|
|
10543
11027
|
writeJsonEnvelope(allHints.length > 0 ? { ...response, hints: allHints } : response);
|
|
10544
11028
|
} catch (err) {
|
|
10545
11029
|
handleLinkedinError(err);
|
|
@@ -10566,7 +11050,8 @@ async function stageLinkedinOps(rawOps, hints) {
|
|
|
10566
11050
|
try {
|
|
10567
11051
|
const chatId = requireChatId();
|
|
10568
11052
|
const response = await apiPost("/api/ads/linkedin/draft/stage-batch", { chatId, ops });
|
|
10569
|
-
|
|
11053
|
+
const allHints = [...hints ?? [], ...adCopyHints(ops)];
|
|
11054
|
+
writeJsonEnvelope(allHints.length > 0 ? { ...response, hints: allHints } : response);
|
|
10570
11055
|
} catch (err) {
|
|
10571
11056
|
handleLinkedinError(err);
|
|
10572
11057
|
}
|
|
@@ -13652,7 +14137,8 @@ async function stageOp2(raw) {
|
|
|
13652
14137
|
try {
|
|
13653
14138
|
const chatId = requireChatId();
|
|
13654
14139
|
const response = await apiPost("/api/ads/meta/draft/stage", { chatId, op: preflight.data });
|
|
13655
|
-
|
|
14140
|
+
const copyHints = adCopyHints(preflight.data);
|
|
14141
|
+
writeJsonEnvelope(copyHints.length > 0 ? { ...response, hints: copyHints } : response);
|
|
13656
14142
|
} catch (err) {
|
|
13657
14143
|
handleMetaError(err);
|
|
13658
14144
|
}
|
|
@@ -17511,10 +17997,10 @@ function estSpeechS(text) {
|
|
|
17511
17997
|
var OBSERVED_WPS_MIN = 1;
|
|
17512
17998
|
var OBSERVED_WPS_MAX = 6;
|
|
17513
17999
|
function estSpeechWindowS(text, startS, endS) {
|
|
17514
|
-
const
|
|
18000
|
+
const words2 = wordCount(text);
|
|
17515
18001
|
const window = (endS ?? 0) - (startS ?? 0);
|
|
17516
|
-
if (
|
|
17517
|
-
const wps =
|
|
18002
|
+
if (words2 > 0 && window > 0.3) {
|
|
18003
|
+
const wps = words2 / window;
|
|
17518
18004
|
if (wps >= OBSERVED_WPS_MIN && wps <= OBSERVED_WPS_MAX) return window;
|
|
17519
18005
|
}
|
|
17520
18006
|
return estSpeechS(text);
|
|
@@ -18366,10 +18852,10 @@ function scrubFloatSentences(text, floatDescs) {
|
|
|
18366
18852
|
if (floatDescs.length === 0 || !text) return text;
|
|
18367
18853
|
const tokenSets = floatDescs.map((d) => new Set(floatTokens(d)));
|
|
18368
18854
|
const kept = text.split(/(?<=[.!?])\s+/).filter((sentence) => {
|
|
18369
|
-
const
|
|
18855
|
+
const words2 = new Set(floatTokens(sentence));
|
|
18370
18856
|
return !tokenSets.some((ts) => {
|
|
18371
18857
|
let hits = 0;
|
|
18372
|
-
for (const w of
|
|
18858
|
+
for (const w of words2) if (ts.has(w)) hits++;
|
|
18373
18859
|
return hits >= 2;
|
|
18374
18860
|
});
|
|
18375
18861
|
}).join(" ").trim();
|
|
@@ -19099,10 +19585,10 @@ function isOnCameraSpeaker(speaker, casts, cameraOn) {
|
|
|
19099
19585
|
if (NARRATOR_SPEAKERS.has(speaker.toLowerCase())) return false;
|
|
19100
19586
|
return casts.has(speaker);
|
|
19101
19587
|
}
|
|
19102
|
-
function makePresenterPresent(slots,
|
|
19588
|
+
function makePresenterPresent(slots, canonical2, opts = {}) {
|
|
19103
19589
|
const personSlots = slots.filter((s) => s.type.toLowerCase() === "person");
|
|
19104
19590
|
const bySpeaker = /* @__PURE__ */ new Map();
|
|
19105
|
-
for (const slot of personSlots) if (slot.castId) bySpeaker.set(
|
|
19591
|
+
for (const slot of personSlots) if (slot.castId) bySpeaker.set(canonical2(slot.castId), slot.presence);
|
|
19106
19592
|
const solePerson = !opts.strict && personSlots.length === 1 ? personSlots[0].presence : null;
|
|
19107
19593
|
return (speaker, sceneIndex) => {
|
|
19108
19594
|
const presence = bySpeaker.get(speaker) ?? solePerson;
|
|
@@ -19134,17 +19620,17 @@ function joinDialogueTexts(texts) {
|
|
|
19134
19620
|
}
|
|
19135
19621
|
const prevWords = prev.split(/\s+/).filter(Boolean);
|
|
19136
19622
|
const nextWords = text.split(/\s+/).filter(Boolean);
|
|
19137
|
-
let
|
|
19623
|
+
let overlap2 = 0;
|
|
19138
19624
|
const maxK = Math.min(JOIN_DEDUP_MAX_WORDS, prevWords.length, nextWords.length);
|
|
19139
19625
|
for (let k = maxK; k >= 1; k--) {
|
|
19140
19626
|
const tail = prevWords.slice(-k).map(joinKey).join(" ");
|
|
19141
19627
|
const head = nextWords.slice(0, k).map(joinKey).join(" ");
|
|
19142
19628
|
if (tail && tail === head) {
|
|
19143
|
-
|
|
19629
|
+
overlap2 = k;
|
|
19144
19630
|
break;
|
|
19145
19631
|
}
|
|
19146
19632
|
}
|
|
19147
|
-
const rest = nextWords.slice(
|
|
19633
|
+
const rest = nextWords.slice(overlap2).join(" ");
|
|
19148
19634
|
if (rest) out.push(rest);
|
|
19149
19635
|
}
|
|
19150
19636
|
return out.join(" ");
|
|
@@ -19162,13 +19648,13 @@ function collapseVoiceover(blueprint) {
|
|
|
19162
19648
|
const presenter = [...presenters][0];
|
|
19163
19649
|
return (speaker) => NARRATOR_SPEAKERS.has(speaker.toLowerCase()) ? presenter : speaker;
|
|
19164
19650
|
}
|
|
19165
|
-
function multiSpeakerScenes(blueprint, casts, cameraOn,
|
|
19651
|
+
function multiSpeakerScenes(blueprint, casts, cameraOn, canonical2, presentStrict) {
|
|
19166
19652
|
const multiSpeaker = /* @__PURE__ */ new Set();
|
|
19167
19653
|
blueprint.scenes.forEach((scene, i) => {
|
|
19168
19654
|
const onCamAll = new Set(
|
|
19169
19655
|
(scene.dialogue ?? []).map((l) => l.speaker ?? "voiceover").filter((sp) => isOnCameraSpeaker(sp, casts, cameraOn))
|
|
19170
19656
|
);
|
|
19171
|
-
const onCamPresent = [...onCamAll].filter((sp) => presentStrict(
|
|
19657
|
+
const onCamPresent = [...onCamAll].filter((sp) => presentStrict(canonical2(sp), i));
|
|
19172
19658
|
const effective = onCamPresent.length > 0 ? new Set(onCamPresent) : onCamAll;
|
|
19173
19659
|
if (effective.size >= 2) multiSpeaker.add(i);
|
|
19174
19660
|
});
|
|
@@ -19199,14 +19685,14 @@ function dialogueLines(blueprint, ctx) {
|
|
|
19199
19685
|
});
|
|
19200
19686
|
}).sort((a, b) => a.start - b.start);
|
|
19201
19687
|
}
|
|
19202
|
-
function buildPhrases(blueprint,
|
|
19688
|
+
function buildPhrases(blueprint, canonical2, compositeScenes, presenterPresent, presentStrict) {
|
|
19203
19689
|
const casts = castIdSet(blueprint);
|
|
19204
19690
|
const cameraOn = onCameraDialogue(blueprint);
|
|
19205
|
-
const multiSpeaker = multiSpeakerScenes(blueprint, casts, cameraOn,
|
|
19691
|
+
const multiSpeaker = multiSpeakerScenes(blueprint, casts, cameraOn, canonical2, presentStrict);
|
|
19206
19692
|
const lines = dialogueLines(blueprint, {
|
|
19207
19693
|
compositeScenes,
|
|
19208
19694
|
multiSpeaker,
|
|
19209
|
-
canonical,
|
|
19695
|
+
canonical: canonical2,
|
|
19210
19696
|
casts,
|
|
19211
19697
|
cameraOn,
|
|
19212
19698
|
presenterPresent
|
|
@@ -19255,12 +19741,12 @@ function buildPhrases(blueprint, canonical, compositeScenes, presenterPresent, p
|
|
|
19255
19741
|
flush();
|
|
19256
19742
|
return phrases;
|
|
19257
19743
|
}
|
|
19258
|
-
function makeVoiceFactory(blueprint,
|
|
19744
|
+
function makeVoiceFactory(blueprint, canonical2, nodes) {
|
|
19259
19745
|
const bySpeaker = /* @__PURE__ */ new Map();
|
|
19260
19746
|
const describe = (speaker) => {
|
|
19261
19747
|
for (const scene of blueprint.scenes)
|
|
19262
19748
|
for (const line of scene.dialogue ?? [])
|
|
19263
|
-
if (
|
|
19749
|
+
if (canonical2(line.speaker ?? "voiceover") === speaker && line.voice_description) return line.voice_description;
|
|
19264
19750
|
const cast = blueprint.global?.cast?.find((c) => c.id === speaker);
|
|
19265
19751
|
return cast?.description ?? blueprint.global?.voiceover?.voice_description ?? `${speaker} voice`;
|
|
19266
19752
|
};
|
|
@@ -19447,15 +19933,15 @@ function emitPhraseTts(phrase, voiceNode, idx, used, clock, nodes, out, language
|
|
|
19447
19933
|
speaker: phrase.speaker
|
|
19448
19934
|
});
|
|
19449
19935
|
}
|
|
19450
|
-
function emitCompositeInTimeline(composite, scene, i, isLast, env,
|
|
19936
|
+
function emitCompositeInTimeline(composite, scene, i, isLast, env, canonical2, ensureVoiceNode, usedVoIds, nodes, out) {
|
|
19451
19937
|
const present2 = slotsForScene(env.slots, i);
|
|
19452
19938
|
const onCam = (scene.dialogue ?? []).filter(
|
|
19453
19939
|
(l) => Boolean(l.line?.trim()) && isOnCameraSpeaker(l.speaker ?? "voiceover", env.casts, env.cameraOn)
|
|
19454
19940
|
);
|
|
19455
|
-
const distinctSpeakers = new Set(onCam.map((l) =>
|
|
19941
|
+
const distinctSpeakers = new Set(onCam.map((l) => canonical2(l.speaker ?? "voiceover")));
|
|
19456
19942
|
let nativeTurn;
|
|
19457
19943
|
if (onCam.length > 0 && distinctSpeakers.size === 1) {
|
|
19458
|
-
const speaker =
|
|
19944
|
+
const speaker = canonical2(onCam[0]?.speaker ?? "voiceover");
|
|
19459
19945
|
const voiceNode = ensureVoiceNode(speaker);
|
|
19460
19946
|
const start = onCam[0]?.start_s ?? scene.start_s ?? 0;
|
|
19461
19947
|
const end = onCam[onCam.length - 1]?.end_s ?? scene.end_s ?? start;
|
|
@@ -19500,7 +19986,7 @@ function emitCompositeInTimeline(composite, scene, i, isLast, env, canonical, en
|
|
|
19500
19986
|
onCam,
|
|
19501
19987
|
scene,
|
|
19502
19988
|
i,
|
|
19503
|
-
|
|
19989
|
+
canonical2,
|
|
19504
19990
|
ensureVoiceNode,
|
|
19505
19991
|
usedVoIds,
|
|
19506
19992
|
env.clock,
|
|
@@ -19510,10 +19996,10 @@ function emitCompositeInTimeline(composite, scene, i, isLast, env, canonical, en
|
|
|
19510
19996
|
);
|
|
19511
19997
|
}
|
|
19512
19998
|
}
|
|
19513
|
-
function emitCompositeMultiSpeakerVoice(onCam, scene, i,
|
|
19999
|
+
function emitCompositeMultiSpeakerVoice(onCam, scene, i, canonical2, ensureVoiceNode, usedVoIds, clock, nodes, out, languageCode) {
|
|
19514
20000
|
const bySpeaker = /* @__PURE__ */ new Map();
|
|
19515
20001
|
for (const l of onCam) {
|
|
19516
|
-
const speaker =
|
|
20002
|
+
const speaker = canonical2(l.speaker ?? "voiceover");
|
|
19517
20003
|
const text = l.line.trim();
|
|
19518
20004
|
const start = l.start_s ?? scene.start_s ?? 0;
|
|
19519
20005
|
const end = l.end_s ?? start + estSpeechS(text);
|
|
@@ -19666,8 +20152,8 @@ function buildTimeline(blueprint, slots, opts, nodes) {
|
|
|
19666
20152
|
if (layeredComposition(s, uiRouted.has(i))) compositeScenes.add(i);
|
|
19667
20153
|
});
|
|
19668
20154
|
}
|
|
19669
|
-
const
|
|
19670
|
-
const ensureVoiceNode = makeVoiceFactory(blueprint,
|
|
20155
|
+
const canonical2 = collapseVoiceover(blueprint);
|
|
20156
|
+
const ensureVoiceNode = makeVoiceFactory(blueprint, canonical2, nodes);
|
|
19671
20157
|
const aspect = resolveAspect(blueprint.source?.aspect_ratio, opts.aspect, genAspectsFor(opts.videoModel));
|
|
19672
20158
|
const env = {
|
|
19673
20159
|
blueprint,
|
|
@@ -19694,9 +20180,9 @@ function buildTimeline(blueprint, slots, opts, nodes) {
|
|
|
19694
20180
|
nativeSegments: [],
|
|
19695
20181
|
sceneSlice: /* @__PURE__ */ new Map()
|
|
19696
20182
|
};
|
|
19697
|
-
const presenterPresent = makePresenterPresent(slots,
|
|
19698
|
-
const presentStrict = makePresenterPresent(slots,
|
|
19699
|
-
const phrases = buildPhrases(blueprint,
|
|
20183
|
+
const presenterPresent = makePresenterPresent(slots, canonical2);
|
|
20184
|
+
const presentStrict = makePresenterPresent(slots, canonical2, { strict: true });
|
|
20185
|
+
const phrases = buildPhrases(blueprint, canonical2, compositeScenes, presenterPresent, presentStrict);
|
|
19700
20186
|
const usedVoIds = /* @__PURE__ */ new Set();
|
|
19701
20187
|
const claimed = /* @__PURE__ */ new Set();
|
|
19702
20188
|
phrases.forEach((phrase, k) => {
|
|
@@ -19720,7 +20206,7 @@ function buildTimeline(blueprint, slots, opts, nodes) {
|
|
|
19720
20206
|
i,
|
|
19721
20207
|
i === lastIndex,
|
|
19722
20208
|
env,
|
|
19723
|
-
|
|
20209
|
+
canonical2,
|
|
19724
20210
|
ensureVoiceNode,
|
|
19725
20211
|
usedVoIds,
|
|
19726
20212
|
nodes,
|
|
@@ -28525,6 +29011,24 @@ var BUZZWORDS = [
|
|
|
28525
29011
|
"drive results",
|
|
28526
29012
|
"harness the power"
|
|
28527
29013
|
];
|
|
29014
|
+
var COPY_TELL_GATES = {
|
|
29015
|
+
"chat-artifact": { minCount: 1, severity: "warn" },
|
|
29016
|
+
"rhetorical-opener": { minCount: 2, severity: "warn" },
|
|
29017
|
+
"negation-pivot": { minCount: 2, severity: "warn" },
|
|
29018
|
+
"vague-attribution": { minCount: 2, severity: "warn" },
|
|
29019
|
+
"participle-tail": { minCount: 2, severity: "advisory" },
|
|
29020
|
+
"copula-avoidance": { minCount: 2, severity: "advisory" },
|
|
29021
|
+
"authority-trope": { minCount: 2, severity: "advisory" },
|
|
29022
|
+
signposting: { minCount: 2, severity: "advisory" },
|
|
29023
|
+
"hollow-conclusion": { minCount: 2, severity: "advisory" },
|
|
29024
|
+
"filler-phrase": { minCount: 2, severity: "advisory" },
|
|
29025
|
+
"aphorism-formula": { minCount: 2, severity: "advisory" },
|
|
29026
|
+
"false-range": { minCount: 2, severity: "advisory" },
|
|
29027
|
+
"staccato-drama": { minCount: 2, severity: "advisory" },
|
|
29028
|
+
"title-case-heading": { minCount: 2, severity: "advisory" },
|
|
29029
|
+
"inline-header-list": { minCount: 3, severity: "advisory" },
|
|
29030
|
+
emoji: { minCount: 3, severity: "advisory" }
|
|
29031
|
+
};
|
|
28528
29032
|
var AUTONOMY_CLAIMS = [
|
|
28529
29033
|
"fully autonomous",
|
|
28530
29034
|
"completely autonomous",
|
|
@@ -28682,6 +29186,86 @@ var RULE_META = {
|
|
|
28682
29186
|
severity: "warn",
|
|
28683
29187
|
note: "Dense em-dashes are an AI cadence tell (and brand copy bans them). Recast with commas, colons, or full stops."
|
|
28684
29188
|
},
|
|
29189
|
+
"chat-artifact": {
|
|
29190
|
+
family: "copy",
|
|
29191
|
+
severity: "warn",
|
|
29192
|
+
note: "Chatbot framing ('Here is an overview\u2026', 'av\xEDsame si') leaked into the page. Delete it and state the claim directly."
|
|
29193
|
+
},
|
|
29194
|
+
"rhetorical-opener": {
|
|
29195
|
+
family: "copy",
|
|
29196
|
+
severity: "warn",
|
|
29197
|
+
note: "Fake-candid openers ('Honestly?', 'La verdad es que') manufacture intimacy before an ordinary claim. Just say the thing."
|
|
29198
|
+
},
|
|
29199
|
+
"negation-pivot": {
|
|
29200
|
+
family: "copy",
|
|
29201
|
+
severity: "warn",
|
|
29202
|
+
note: "'It's not just X, it's Y' / 'no se trata solo de' is now a louder AI tell than any single word. Cut the setup, keep the claim."
|
|
29203
|
+
},
|
|
29204
|
+
"vague-attribution": {
|
|
29205
|
+
family: "copy",
|
|
29206
|
+
severity: "warn",
|
|
29207
|
+
note: "'Experts agree' / 'los estudios demuestran' attributes to nobody. Name the source and the year, or drop the claim."
|
|
29208
|
+
},
|
|
29209
|
+
"participle-tail": {
|
|
29210
|
+
family: "copy",
|
|
29211
|
+
severity: "advisory",
|
|
29212
|
+
note: "Participles hung off a comma (', ensuring\u2026', ', permitiendo\u2026') fake depth. End the sentence, or make it a real clause."
|
|
29213
|
+
},
|
|
29214
|
+
"copula-avoidance": {
|
|
29215
|
+
family: "copy",
|
|
29216
|
+
severity: "advisory",
|
|
29217
|
+
note: "'serves as' / 'cuenta con' dodges plain 'is' and 'has'. Use the simple verb \u2014 it reads as confidence, not flatness."
|
|
29218
|
+
},
|
|
29219
|
+
"authority-trope": {
|
|
29220
|
+
family: "copy",
|
|
29221
|
+
severity: "advisory",
|
|
29222
|
+
note: "'At its core' / 'en el fondo' pretends to reach a deeper truth before restating an ordinary point. Make the point once."
|
|
29223
|
+
},
|
|
29224
|
+
signposting: {
|
|
29225
|
+
family: "copy",
|
|
29226
|
+
severity: "advisory",
|
|
29227
|
+
note: "Announcing the writing ('Let's dive in', 'vamos a ver') instead of doing it. Delete the announcement, keep the content."
|
|
29228
|
+
},
|
|
29229
|
+
"hollow-conclusion": {
|
|
29230
|
+
family: "copy",
|
|
29231
|
+
severity: "advisory",
|
|
29232
|
+
note: "'The future looks bright' / 'las posibilidades son infinitas' asserts nothing. End on a concrete next step or a number."
|
|
29233
|
+
},
|
|
29234
|
+
"filler-phrase": {
|
|
29235
|
+
family: "copy",
|
|
29236
|
+
severity: "advisory",
|
|
29237
|
+
note: "Filler ('in order to', 'cabe destacar que') costs words and says nothing. Cut to the verb."
|
|
29238
|
+
},
|
|
29239
|
+
"aphorism-formula": {
|
|
29240
|
+
family: "copy",
|
|
29241
|
+
severity: "advisory",
|
|
29242
|
+
note: "'X is the currency of Y' sounds profound without adding precision. Replace it with the concrete claim it gestures at."
|
|
29243
|
+
},
|
|
29244
|
+
"false-range": {
|
|
29245
|
+
family: "copy",
|
|
29246
|
+
severity: "advisory",
|
|
29247
|
+
note: "'From onboarding to retention' pairs ends that aren't on a scale. List what you actually cover."
|
|
29248
|
+
},
|
|
29249
|
+
"staccato-drama": {
|
|
29250
|
+
family: "copy",
|
|
29251
|
+
severity: "advisory",
|
|
29252
|
+
note: "A run of very short sentences manufactures drama. One clipped line lands; three in a row read as engineered."
|
|
29253
|
+
},
|
|
29254
|
+
"title-case-heading": {
|
|
29255
|
+
family: "copy",
|
|
29256
|
+
severity: "advisory",
|
|
29257
|
+
note: "Title Case headings are an AI tell \u2014 and in Spanish the convention doesn't exist at all. Use sentence case."
|
|
29258
|
+
},
|
|
29259
|
+
"inline-header-list": {
|
|
29260
|
+
family: "copy",
|
|
29261
|
+
severity: "advisory",
|
|
29262
|
+
note: "'**Speed:** it is fast' bullets are AI list shape. Write the sentence, or let the bullet carry the content directly."
|
|
29263
|
+
},
|
|
29264
|
+
emoji: {
|
|
29265
|
+
family: "copy",
|
|
29266
|
+
severity: "advisory",
|
|
29267
|
+
note: "Emoji-decorated copy is a chatbot habit. Let the words and the layout carry the emphasis."
|
|
29268
|
+
},
|
|
28685
29269
|
"dark-glow": {
|
|
28686
29270
|
family: "borders_depth",
|
|
28687
29271
|
severity: "warn",
|
|
@@ -28884,6 +29468,14 @@ function includesPhraseAtWordStart(haystack, phrase) {
|
|
|
28884
29468
|
function stripHtmlToText(html) {
|
|
28885
29469
|
return html.replace(/<script\b[^>]*>[\s\S]*?<\/script>/gi, " ").replace(/<style\b[^>]*>[\s\S]*?<\/style>/gi, " ").replace(/<!--[\s\S]*?-->/g, " ").replace(/<[^>]+>/g, " ").replace(/\s+/g, " ");
|
|
28886
29470
|
}
|
|
29471
|
+
function stripHtmlPreservingHeadings(html) {
|
|
29472
|
+
return html.replace(/<script\b[^>]*>[\s\S]*?<\/script>/gi, " ").replace(/<style\b[^>]*>[\s\S]*?<\/style>/gi, " ").replace(/<!--[\s\S]*?-->/g, " ").replace(
|
|
29473
|
+
/<h([1-6])\b[^>]*>([\s\S]*?)<\/h\1>/gi,
|
|
29474
|
+
(_all, _lvl, inner) => `
|
|
29475
|
+
## ${String(inner).replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim()}
|
|
29476
|
+
`
|
|
29477
|
+
).replace(/<\/(?:p|div|section|li|tr|br)>|<br\s*\/?>/gi, "\n").replace(/<[^>]+>/g, " ").replace(/[ \t]+/g, " ").replace(/\n{3,}/g, "\n\n");
|
|
29478
|
+
}
|
|
28887
29479
|
function blankAstroFrontmatter(text) {
|
|
28888
29480
|
if (!text.startsWith("---")) return text;
|
|
28889
29481
|
const lines = text.split("\n");
|
|
@@ -29068,6 +29660,23 @@ var ANALYZERS = [
|
|
|
29068
29660
|
return [{ id: "aphoristic-cadence", snippet: `${count} aphoristic constructions: "${firstSample}"`, file }];
|
|
29069
29661
|
}
|
|
29070
29662
|
},
|
|
29663
|
+
// Shared AI copy tells (engine/copy-tells) — the Wikipedia "Signs of AI
|
|
29664
|
+
// writing" set, English and Spanish. Page-scope: every gate here is a count
|
|
29665
|
+
// threshold, and a landing is always split across small components, so
|
|
29666
|
+
// measuring per file would dilute a page-wide habit into nothing.
|
|
29667
|
+
{
|
|
29668
|
+
scope: "page",
|
|
29669
|
+
run: (text, file) => {
|
|
29670
|
+
const body = stripHtmlPreservingHeadings(text);
|
|
29671
|
+
const out = [];
|
|
29672
|
+
for (const tell of detectCopyTells(body)) {
|
|
29673
|
+
const gate = COPY_TELL_GATES[tell.id];
|
|
29674
|
+
if (!gate || tell.count < gate.minCount) continue;
|
|
29675
|
+
out.push({ id: tell.id, snippet: `${tell.count}\xD7 \u2014 "${tell.sample}"`, file });
|
|
29676
|
+
}
|
|
29677
|
+
return out;
|
|
29678
|
+
}
|
|
29679
|
+
},
|
|
29071
29680
|
// Gradient text in authored CSS. A rule block spans lines, so this scans a
|
|
29072
29681
|
// ±6-line window around each `background-clip: text` instead of one line
|
|
29073
29682
|
// (the Tailwind class form stays a line matcher above).
|