@koda-sl/baker-cli 0.302.0-dev.3ebf34fad → 0.305.0-dev.4e8ef1cd7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -669,7 +669,7 @@ Command groups: `budgets`, `campaigns`, `ad-groups`, `keywords` (add/update/remo
669
669
 
670
670
  **A device criterion can be added, but never removed** — `campaign-criteria add` with a device criterion works, but Google then rejects `campaign-criteria remove` on it forever, including on a criterion you added yourself (and Baker refuses the removal before publish). Adjust it instead with `campaign-criteria update customers/<cid>/campaignCriteria/<campaignId>~<criterionId> --bid-modifier <n>`: `1` for no adjustment, `1.25` to bid 25% more, `0.9` to bid 10% less, `0` to stop serving on that device. Locations, languages and ad schedules remove normally.
671
671
 
672
- **Images on a Search campaign** — `assets create` with an image publishes it into the advertiser's Google Ads asset library, but Google refuses the asset link that puts an image extension on a Search campaign (`AD_IMAGE` and every marketing-image field type, at campaign, ad group and customer level alike), so `assets attach` rejects it at stage time and names what to do instead. Staging an image therefore also stages a **Task** — "Add N images to <campaign> in Google Ads" — which applies with the rest of the chat, so the one step Google keeps inside its own interface is tracked rather than mentioned. Attach the image to a Performance Max asset group (`asset-groups attach`) and the Task withdraws itself. The one image a Search campaign does take through a link is the advertiser's logo: `BUSINESS_LOGO`, at campaign or customer level, on a verified account.
672
+ **Images on a Search campaign** — `assets create` with an image publishes it into the advertiser's Google Ads asset library, but Google refuses the asset link that puts an image extension on a Search campaign (`AD_IMAGE` and every marketing-image field type, at campaign, ad group and customer level alike), so `assets attach` **skips** it at stage time and names what to do instead — the op is never stored, the rest of a batch stages normally, and the skipped link is what records which images belong on which campaign. Staging an image, or asking to link one the account already holds, therefore also stages a **Task** — "Add N images to <campaign> in Google Ads" — which applies with the rest of the chat, so the one step Google keeps inside its own interface is tracked rather than mentioned. Attach the image to a Performance Max asset group (`asset-groups attach`) and the Task withdraws itself. The one image a Search campaign does take through a link is the advertiser's logo: `BUSINESS_LOGO`, at campaign or customer level, on a verified account.
673
673
 
674
674
  **Primary vs secondary conversion actions** — `conversions update <id> --primary` makes an action a primary action (automated bidding optimizes toward it); `--no-primary` (or `--primary=false`) demotes it to secondary (reported only) — the spaced form `--primary false` is rejected, since a boolean flag written with a space is set to true and the word dropped. `conversions create` stages a new action as **secondary** unless its `--file` payload sets `"primaryForGoal": true`, so a new action never silently joins the bidding target of a Maximize Conversions or Target CPA campaign. The staged card shows an "Action optimization" row with the before/after role.
675
675
 
@@ -5930,6 +5930,8 @@ baker landing critique spring-offer summer-offer claude # score three landings
5930
5930
 
5931
5931
  - **Positioning integrity is scored too, and it is the one copy rule that blocks on a single sentence.** `competitor-concession` fires when the page ranks somebody else above the client, or says the client does not lead at what the page sells ("su constructor es mejor que el nuestro", "we're not the best"). Every other prose rule waits for a cluster because one AI tell proves nothing; this one is the whole defect on its own — the page is paid traffic, and it is arguing the visitor should leave. Two shapes that read identically are exempt by design: a concession about **price**, in either direction ("we are not the cheapest / the most expensive, and here is why" is premium positioning), and a superlative flipped by a negation ("ningún equipo trabaja más rápido que el nuestro"), which is the strongest claim *for* the client. Its warn-tier sibling `competitor-named-in-comparison` fires when a name from `src/content/competitors/` appears in a comparative sentence outside a `<table>` — a comparison table is a section the landing skill teaches, but naming a rival anywhere else on paid traffic is the client's call, not the build's. Spanish, Portuguese and English.
5932
5932
 
5933
+ - **Comments are not markup, and no rule reads one.** Astro expression comments (`{/* … */}`) and HTML comments (`<!-- … -->`) are blanked before detection, offsets preserved so `file:line` stays exact. A shared scaffold component explaining its `preconnect` in prose that spelled `<img>` twice was read as two broken images — block-tier — so every landing rendering a video failed the publish gate on a file the client cannot edit. Comment prose is also where a component's reasoning lives, em-dashes and buzzwords included, so reading it inflated the count-threshold tells too.
5934
+
5933
5935
  - **Copy held in component frontmatter is read like markup copy.** Astro landings keep FAQ pairs, testimonial quotes, feature cards and pricing rows as a `const items = [{ q, a }]` array above the `---` fence and render them in a `.map()`, so a critic that blanked the whole block was blind to a large share of every page's actual words. Prose string literals in that block now feed every copy rule; imports, class strings, URLs and config do not, and the bar is deliberately high in that direction — a Tailwind class list in front of the copy rules is worse than one missed tell.
5934
5936
 
5935
5937
  Output is the standard envelope `{ ok, data, hints }` with `data = { advisory, slug, overall, counts, dimensions, findings }`. `dimensions` scores seven design families (typography, color, borders_depth, motion, spacing, copy, integrity) 0–1 (higher is better); `counts` is the block/warn/advisory tally.
@@ -6086,6 +6088,8 @@ Pushing to `main` with changes in `packages/cli/` triggers the GitHub Actions wo
6086
6088
 
6087
6089
  - **0.271.0**: **`--format demandGenVideo` — the Demand Gen ad most Demand Gen spend actually runs through can be edited.** A `DEMAND_GEN_VIDEO_RESPONSIVE_AD` keeps its copy in `demandGenVideoResponsiveAd`, which has a `longHeadlines` list no other ad format has and takes its business name as a text asset rather than a string. Baker modelled only the multi-asset Demand Gen ad and labelled all four Demand Gen types as one format, so every served video ad was uneditable: the strict content schema refused `longHeadlines` and named a field instead of a missing format, and the only route left was the Google Ads interface. On the account that surfaced it, Demand Gen was 76% of the spend. New flags on `ads create|update`: `--long-headlines` (up to 5 × 90 chars, this format only), `--video-assets`, `--logo-image-assets`, `--call-to-action-assets`; a create with no video is refused with the `assets create` call to make first, and an omitted media list on an update leaves that media on the ad. Two more gates ship with it. **A content flag a format does not read is now refused instead of dropped** — `--long-headlines` on `--format demandGen` staged the copy without them and reported success, the same silent-drop the backend's strict schemas exist to stop. And **the format is checked against the live `ad_group_ad.ad.type` when it stages**: a mismatch is refused with the right format named, and the two Demand Gen types Baker writes no format for (`DEMAND_GEN_CAROUSEL_AD`, `DEMAND_GEN_PRODUCT_AD`) are refused by name, so "Baker cannot edit this ad" arrives at stage time rather than as a publish failure a Session too late.
6088
6090
 
6091
+ - **0.291.0**: **the image handover Task no longer depends on having uploaded the images.** It was built only from staged `asset.create` image ops, so a Session that audited a live account — found the brand campaign carrying 22 image extensions and the generic one carrying none, and reached for `assets attach` on assets that were *already in the library* — got Google's refusal and nothing else: no upload to hang a Task on, and a CLI hint telling the agent not to raise one by hand because "Baker stages it itself". The one step only the user can take reached them as a sentence in a chat that scrolls away. An image `assets attach` is now **skipped rather than refused**: it still never reaches Google (re-verified live with `validate_only` on v23 *and* v25 against 3865083115, against a control op that validates clean in the same request — `AD_IMAGE` → `UNSUPPORTED_FIELD_TYPE` at customer and campaign level, `MARKETING_IMAGE` → `FIELD_TYPE_INCOMPATIBLE_WITH_CAMPAIGN_TYPE`, `BUSINESS_LOGO` → `CUSTOMER_NOT_VERIFIED`, which is what proves the field type is the objection; **we are not on Google's allowlist for it**), but the skip carries the image and campaign names, recorded on the draft so the next staged op cannot recompute the Task away, and a batch that also carries sitelinks keeps them instead of failing whole. The schema stopped refusing the field type for the same reason: a boundary refusal has no chat to record a handover against.
6092
+
6089
6093
  ### Testing a pre-release in sandboxes
6090
6094
 
6091
6095
  ```bash
@@ -1139,9 +1139,44 @@ function normalizeForCanonical(value) {
1139
1139
  return void 0;
1140
1140
  }
1141
1141
 
1142
+ // ../canvas-contract/src/clipWeight.ts
1143
+ var MB_PER_SECOND_720P = 2.13;
1144
+ var RESOLUTION_WEIGHT = {
1145
+ "360p": 0.25,
1146
+ "480p": 0.44,
1147
+ "720p": 1,
1148
+ "1080p": 2.25,
1149
+ "4k": 9,
1150
+ "4K": 9
1151
+ };
1152
+ var MAX_CLIP_INGEST_MB = 120;
1153
+ function estimateClipMb(durationSeconds2, resolution) {
1154
+ const weight = RESOLUTION_WEIGHT[resolution ?? "720p"] ?? 1;
1155
+ return durationSeconds2 * MB_PER_SECOND_720P * weight;
1156
+ }
1157
+ function clipTooHeavyToIngest(durationSeconds2, resolution) {
1158
+ if (!durationSeconds2) return null;
1159
+ const mb = estimateClipMb(durationSeconds2, resolution);
1160
+ if (mb <= MAX_CLIP_INGEST_MB) return null;
1161
+ const fits = Math.floor(MAX_CLIP_INGEST_MB / (estimateClipMb(1, resolution) || 1));
1162
+ return `A ${durationSeconds2}s clip at ${resolution ?? "720p"} comes to roughly ${Math.round(mb)} MB, more than the ${MAX_CLIP_INGEST_MB} MB we can take in \u2014 it would render, bill, and then fail on the way into the library. Shorten it to about ${fits}s at this resolution, or drop the resolution and keep the length.`;
1163
+ }
1164
+
1142
1165
  // ../canvas-contract/src/frameRealism.ts
1143
1166
  function frameRealismDirection(opts = {}) {
1144
- return " Everything obeys real-world physics: paper, card and screens are OPAQUE with nothing showing through from behind, every object is at believable real-world scale next to the people handling it, and every object has its real-world form and construction \u2014 a phone has ONE screen and it is on the front. NO readable text or numbers anywhere in frame \u2014 phone screens, documents and signage stay illegible or out of focus, because any figure the model invents will contradict the script. Hands are kept simple: no close-up of fingers manipulating small parts, no hand gripping the edge of an object, and each person has exactly TWO arms and TWO legs, all attached and all visible or all out of frame. Anyone working does so the way the trade actually does it: nobody stands or kneels on the equipment being installed, nothing is fitted overhanging an edge or floating unsupported, and every part rests on the structure that would really carry it." + // The newest clause, and the one no route had. A testimonial came back with the customer
1167
+ return " Everything obeys real-world physics: paper, card and screens are OPAQUE with nothing showing through from behind, every object is at believable real-world scale next to the people handling it, and every object has its real-world form and construction \u2014 a phone has ONE screen and it is on the front." + // The worst of the three failures behind this, because the viewer reads the number: a
1168
+ // model invents one whenever a screen or a document is in shot, and it contradicts the
1169
+ // script it sits under. Frames simply do not get legible text; the captions carry the
1170
+ // numbers.
1171
+ //
1172
+ // Default ON, and asked for explicitly rather than inferred: an ad frame with no
1173
+ // person in it still sits under a script for an invented number to argue with. Only a
1174
+ // plain Studio picture opts out — it has no script, and it may BE the poster somebody
1175
+ // asked for, which the model picker sells GPT Image 2.5 on being able to render.
1176
+ (opts.readableText === "allowed" ? "" : " NO readable text or numbers anywhere in frame \u2014 phone screens, documents and signage stay illegible or out of focus, because any figure the model invents will contradict the script.") + // Measured: one finished ad reported malformed hands or limbs in 8 of 12 sampled frames;
1177
+ // another, a woman with an extra right arm and an unattached hand gripping a panel.
1178
+ // Fiddly work at close range is where a video model breaks, every time.
1179
+ " Hands are kept simple: no close-up of fingers manipulating small parts, no hand gripping the edge of an object, and each person has exactly TWO arms and TWO legs, all attached and all visible or all out of frame. Anyone working does so the way the trade actually does it: nobody stands or kneels on the equipment being installed, nothing is fitted overhanging an edge or floating unsupported, and every part rests on the structure that would really carry it." + // The newest clause, and the one no route had. A testimonial came back with the customer
1145
1180
  // repairing the panel herself: nothing said she was not the installer, so the model cast
1146
1181
  // her as one. Who someone IS in the picture has to be stated, or it is guessed.
1147
1182
  (opts.role ? ` The person on camera is ${opts.role} \u2014 they are shown as that and never doing somebody else's job.` : "") + (opts.currency ? ` If a currency is unavoidably visible it is ${opts.currency}.` : "");
@@ -2165,10 +2200,14 @@ function clipAudioMode(model) {
2165
2200
  if (supportsParam("video_generate", model, "generate_audio")) return "toggle";
2166
2201
  return SILENT_VIDEO_MODELS.has(model) ? "never" : "always";
2167
2202
  }
2168
- function maxClipSeconds(model) {
2203
+ function clipDurationsFor(model) {
2169
2204
  const schema = MODEL_REGISTRY.video_generate[model]?.params.duration;
2170
2205
  const allowed = schema?.kind === "number" ? schema.enum : void 0;
2171
- return allowed?.length ? Math.max(...allowed) : void 0;
2206
+ return allowed?.length ? [...allowed].sort((a, b) => a - b) : void 0;
2207
+ }
2208
+ function maxClipSeconds(model) {
2209
+ const allowed = clipDurationsFor(model);
2210
+ return allowed ? allowed[allowed.length - 1] : void 0;
2172
2211
  }
2173
2212
  function splitScriptFor(words, model) {
2174
2213
  const maxSeconds = maxClipSeconds(model);
@@ -2294,6 +2333,13 @@ function validateModelParams(nodeType, model, params) {
2294
2333
  }
2295
2334
  const errors = [...checkRequiredParams(spec, params, model), ...checkParamValues(spec, params, model)];
2296
2335
  if (errors.length > 0) return { ok: false, errors };
2336
+ if (nodeType === "video_generate") {
2337
+ const tooHeavy = clipTooHeavyToIngest(
2338
+ typeof params.duration === "number" ? params.duration : void 0,
2339
+ typeof params.resolution === "string" ? params.resolution : void 0
2340
+ );
2341
+ if (tooHeavy) return { ok: false, errors: [{ code: "clip_too_heavy", message: tooHeavy }] };
2342
+ }
2297
2343
  return { ok: true };
2298
2344
  }
2299
2345
  function validateModelInputs(nodeType, model, providedKinds) {
@@ -6276,9 +6322,8 @@ async function ingestImageUrl(url, ctx) {
6276
6322
  } catch (e) {
6277
6323
  throw localExecError(ctx, `${url}: ${e.message}`);
6278
6324
  }
6279
- if (normalized.rasterizedFrom) {
6280
- ctx.log(`ingest: normalized ${normalized.rasterizedFrom} URL -> PNG (${normalized.bytes.length}B)`);
6281
- }
6325
+ const note = normalizationNote(normalized);
6326
+ if (note) ctx.log(`ingest: ${url} ${note}`);
6282
6327
  return uploadAndIngest({
6283
6328
  bytes: normalized.bytes,
6284
6329
  kind: "image",
@@ -6388,23 +6433,69 @@ async function rasterizeSvgToPng(bytes) {
6388
6433
  return await sharp(bytes, { density }).png({ force: true, palette: false }).toBuffer();
6389
6434
  }
6390
6435
  var MODEL_SAFE_IMAGE_MIMES = /* @__PURE__ */ new Set(["image/jpeg", "image/png", "image/gif", "image/webp"]);
6391
- async function toModelSafeImage(bytes) {
6436
+ var MAX_MODEL_REFERENCE_BYTES = 30 * 1024 * 1024;
6437
+ async function fitWithinReferenceLimit(bytes, mime, maxBytes) {
6438
+ if (bytes.byteLength <= maxBytes) return { bytes, mime };
6439
+ const { default: sharp } = await import("sharp");
6440
+ const read = () => sharp(bytes, { animated: true });
6441
+ const meta = await read().metadata();
6442
+ const animated = (meta.pages ?? 1) > 1;
6443
+ const keepAlpha = meta.hasAlpha === true;
6444
+ const width = meta.width ?? 0;
6445
+ const encode = async (scale2) => {
6446
+ let pipeline2 = read();
6447
+ if (scale2 < 1 && width > 0) pipeline2 = pipeline2.resize({ width: Math.max(256, Math.round(width * scale2)) });
6448
+ if (animated && mime === "image/gif") return { bytes: await pipeline2.gif().toBuffer(), mime: "image/gif" };
6449
+ if (animated || keepAlpha) return { bytes: await pipeline2.webp({ quality: 90 }).toBuffer(), mime: "image/webp" };
6450
+ return { bytes: await pipeline2.jpeg({ quality: 90, mozjpeg: true }).toBuffer(), mime: "image/jpeg" };
6451
+ };
6452
+ let out = await encode(1);
6453
+ let scale = 1;
6454
+ for (let pass = 0; pass < 4 && out.bytes.byteLength > maxBytes; pass++) {
6455
+ scale *= Math.min(0.9, Math.sqrt(maxBytes / out.bytes.byteLength));
6456
+ out = await encode(scale);
6457
+ }
6458
+ return out;
6459
+ }
6460
+ async function toModelSafeImage(bytes, opts) {
6461
+ const maxBytes = opts?.maxBytes ?? MAX_MODEL_REFERENCE_BYTES;
6462
+ const fit = async (candidate, mime, rasterizedFrom) => {
6463
+ const fitted = await fitWithinReferenceLimit(candidate, mime, maxBytes);
6464
+ return {
6465
+ bytes: fitted.bytes,
6466
+ mime: fitted.mime,
6467
+ ...rasterizedFrom ? { rasterizedFrom } : {},
6468
+ ...fitted.bytes === candidate ? {} : { shrunkFromBytes: candidate.byteLength }
6469
+ };
6470
+ };
6392
6471
  const safe = sniffImageMime(bytes);
6393
6472
  if (safe && MODEL_SAFE_IMAGE_MIMES.has(safe)) {
6394
- return { bytes, mime: safe };
6473
+ return await fit(bytes, safe);
6395
6474
  }
6396
6475
  if (sniffSvg(bytes)) {
6397
- return { bytes: await rasterizeSvgToPng(bytes), mime: "image/png", rasterizedFrom: "svg" };
6476
+ return await fit(await rasterizeSvgToPng(bytes), "image/png", "svg");
6398
6477
  }
6399
6478
  const { default: sharp } = await import("sharp");
6479
+ let png;
6480
+ let format;
6400
6481
  try {
6401
6482
  const img = sharp(bytes);
6402
- const format = (await img.metadata()).format;
6403
- const png = await img.png({ force: true }).toBuffer();
6404
- return { bytes: png, mime: "image/png", rasterizedFrom: format ?? "unknown" };
6483
+ format = (await img.metadata()).format ?? "unknown";
6484
+ png = await img.png({ force: true }).toBuffer();
6405
6485
  } catch (e) {
6406
6486
  throw new Error(`bytes are not a decodable image (${e.message})`);
6407
6487
  }
6488
+ return await fit(png, "image/png", format);
6489
+ }
6490
+ function normalizationNote(image) {
6491
+ const parts = [];
6492
+ if (image.rasterizedFrom) parts.push(`normalized ${image.rasterizedFrom} -> ${image.mime}`);
6493
+ if (image.shrunkFromBytes !== void 0) {
6494
+ parts.push(
6495
+ `fitted ${image.shrunkFromBytes}B -> ${image.bytes.length}B as ${image.mime} (providers refuse a reference over ${MAX_MODEL_REFERENCE_BYTES}B)`
6496
+ );
6497
+ }
6498
+ return parts.length > 0 ? parts.join("; ") : null;
6408
6499
  }
6409
6500
  function hasAscii(buf, offset, sig) {
6410
6501
  return buf.length >= offset + sig.length && buf.toString("ascii", offset, offset + sig.length) === sig;
@@ -6534,7 +6625,8 @@ async function execLocalFile(params, ctx) {
6534
6625
  outBytes = normalized.bytes;
6535
6626
  outMime = normalized.mime;
6536
6627
  rasterizedFrom = normalized.rasterizedFrom;
6537
- if (rasterizedFrom) ctx.log(`ingest: normalized ${rasterizedFrom} -> PNG (${outBytes.length}B)`);
6628
+ const note = normalizationNote(normalized);
6629
+ if (note) ctx.log(`ingest: ${note}`);
6538
6630
  }
6539
6631
  const durationMs = probeVideoDurationMs(params.expect, outBytes, ctx);
6540
6632
  const ref = await uploadAndIngest({
@@ -9493,6 +9585,7 @@ export {
9493
9585
  spokenWordsIn,
9494
9586
  nearestClipDuration,
9495
9587
  clipAudioMode,
9588
+ clipDurationsFor,
9496
9589
  splitScriptFor,
9497
9590
  promptMaxLength,
9498
9591
  maxInputSlot,
@@ -9538,4 +9631,4 @@ export {
9538
9631
  defaultRegistry,
9539
9632
  createEngineFromEnv
9540
9633
  };
9541
- //# sourceMappingURL=chunk-KKJT24WA.js.map
9634
+ //# sourceMappingURL=chunk-7TL4VPK5.js.map