@koda-sl/baker-cli 0.272.0-dev.1f1c09c80 → 0.275.0-dev.1f1c09c80
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 +3 -0
- package/dist/cli.js +123 -14
- package/dist/cli.js.map +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -3648,6 +3648,9 @@ because a video model garbles a wordmark every time:
|
|
|
3648
3648
|
the ad needs something other than the client's default.
|
|
3649
3649
|
- **The spoken language is read off the script** when the spec does not declare it, so a
|
|
3650
3650
|
Spanish ad is cast from Spanish voices without anyone having to say so.
|
|
3651
|
+
- **`voiceover: false`** — a music-led ad. No voice, no transcription: the `say` lines
|
|
3652
|
+
become on-screen text and are captioned straight from the script, so the words are
|
|
3653
|
+
exact. Give it `music` too.
|
|
3651
3654
|
- **`--avatar <handle>`** — the same flag as on `studio generate` / `studio animate`, and the way to cast a person into an ad. Overrides `cast.avatar` in the spec.
|
|
3652
3655
|
- **`cast.avatar`** — the handle of a cast Avatar (`baker avatars list`). The ad grounds
|
|
3653
3656
|
every beat they appear in on that avatar's identity sheet and copies its subject
|
package/dist/cli.js
CHANGED
|
@@ -29566,9 +29566,32 @@ function emitBlueprintIngests(opts, nodes) {
|
|
|
29566
29566
|
params: { source: "path", path: opts.blueprintStylePath ?? "./prompt.style.json", expect: "json" }
|
|
29567
29567
|
});
|
|
29568
29568
|
}
|
|
29569
|
+
function emitStaticCaptions(opts, videoRef, nodes) {
|
|
29570
|
+
if (!opts.staticTranscriptPath) return { videoRef };
|
|
29571
|
+
const wordsPerGroup = opts.captionWordsPerGroup ? { words_per_group: opts.captionWordsPerGroup } : {};
|
|
29572
|
+
nodes.push({
|
|
29573
|
+
id: "captions_transcript",
|
|
29574
|
+
type: "ingest",
|
|
29575
|
+
params: { source: "path", path: opts.staticTranscriptPath, expect: "json" }
|
|
29576
|
+
});
|
|
29577
|
+
const overlaid = nodes.find((n) => n.id === "overlaid");
|
|
29578
|
+
if (overlaid) {
|
|
29579
|
+
overlaid.inputs = { ...overlaid.inputs, transcript: "$ref:captions_transcript.json" };
|
|
29580
|
+
overlaid.params = { ...overlaid.params, ...wordsPerGroup };
|
|
29581
|
+
return { videoRef };
|
|
29582
|
+
}
|
|
29583
|
+
nodes.push({
|
|
29584
|
+
id: "captions",
|
|
29585
|
+
type: "hyperframe_render",
|
|
29586
|
+
inputs: { background: videoRef, transcript: "$ref:captions_transcript.json" },
|
|
29587
|
+
params: { composition: opts.captionsCompositionPath, ...wordsPerGroup }
|
|
29588
|
+
});
|
|
29589
|
+
return { videoRef: "$ref:captions.video", videoNode: "captions" };
|
|
29590
|
+
}
|
|
29569
29591
|
function emitCaptionChain(blueprint, tracks, clock, opts, videoRef, nodes) {
|
|
29570
29592
|
const voTrackList = tracks.filter((t) => t.kind === "vo");
|
|
29571
|
-
if (!opts.captionsCompositionPath
|
|
29593
|
+
if (!opts.captionsCompositionPath) return { videoRef };
|
|
29594
|
+
if (voTrackList.length === 0) return emitStaticCaptions(opts, videoRef, nodes);
|
|
29572
29595
|
const voiceMixInputs = {};
|
|
29573
29596
|
for (const t of voTrackList) voiceMixInputs[t.slot] = t.ref;
|
|
29574
29597
|
nodes.push({
|
|
@@ -30100,6 +30123,9 @@ async function syncSceneNodeParams(canvas, canvasPath, log) {
|
|
|
30100
30123
|
try {
|
|
30101
30124
|
rebuildRaw = await readFile7(path9.join(creativeDir, REBUILD_FILE), "utf8");
|
|
30102
30125
|
} catch {
|
|
30126
|
+
log(
|
|
30127
|
+
`[scenes] this creative has scene files but no ${REBUILD_FILE}, so edits to scenes/*.json are NOT reaching the render \u2014 the prompts inlined in the canvas are what renders. Re-scaffold the creative to restore the link, or edit the canvas node prompts directly.`
|
|
30128
|
+
);
|
|
30103
30129
|
return "not_applicable";
|
|
30104
30130
|
}
|
|
30105
30131
|
const { elements, opts } = JSON.parse(rebuildRaw);
|
|
@@ -30330,7 +30356,8 @@ function buildRunRecord(result, meta, plan, finalLabels) {
|
|
|
30330
30356
|
durationMs: result.stats.duration_ms
|
|
30331
30357
|
},
|
|
30332
30358
|
nodes,
|
|
30333
|
-
finalOutputs: finalOutputs.length > 0 ? finalOutputs : void 0
|
|
30359
|
+
finalOutputs: finalOutputs.length > 0 ? finalOutputs : void 0,
|
|
30360
|
+
...meta.review && meta.review.length > 0 ? { review: meta.review.slice(0, 40) } : {}
|
|
30334
30361
|
};
|
|
30335
30362
|
}
|
|
30336
30363
|
function buildInitialRunRecord(runId, meta) {
|
|
@@ -31391,7 +31418,6 @@ ${describeRewrites(healed.rewrites)}
|
|
|
31391
31418
|
} : void 0
|
|
31392
31419
|
});
|
|
31393
31420
|
await clearRunMarker(outputsDir, filePath);
|
|
31394
|
-
if (poster) await poster.flush(buildRunRecord(result, recordMeta, progress?.planInfo(), finalLabels));
|
|
31395
31421
|
const keepRuns = opts.keepRuns;
|
|
31396
31422
|
if (keepRuns !== void 0 && Number.isFinite(keepRuns)) {
|
|
31397
31423
|
await pruneOldRuns(outputsDir, keepRuns, result.run_id, (line) => process.stdout.write(`${line}
|
|
@@ -31406,6 +31432,11 @@ ${describeRewrites(healed.rewrites)}
|
|
|
31406
31432
|
`Fix them, then re-run: edit the \`show\` of the scene files named above \u2014 a shot with a person handling equipment is where limbs and objects go wrong, so widen it, move the hands out of frame, or drop the person from that beat. Re-running re-renders ONLY the scenes you edited; everything else comes from the cache (this run: ${result.stats?.cached_nodes ?? 0} of ${result.stats?.total_nodes ?? 0} nodes were cached).`
|
|
31407
31433
|
] : []
|
|
31408
31434
|
] : [];
|
|
31435
|
+
if (poster) {
|
|
31436
|
+
await poster.flush(
|
|
31437
|
+
buildRunRecord(result, { ...recordMeta, ...review ? { review } : {} }, progress?.planInfo(), finalLabels)
|
|
31438
|
+
);
|
|
31439
|
+
}
|
|
31409
31440
|
process.stdout.write(
|
|
31410
31441
|
`${JSON.stringify(
|
|
31411
31442
|
{
|
|
@@ -32487,12 +32518,16 @@ import { defineCommand as defineCommand104 } from "citty";
|
|
|
32487
32518
|
// src/engine/scaffold/ad-spec.ts
|
|
32488
32519
|
import { z as z30 } from "zod";
|
|
32489
32520
|
var oneClause = (line) => (line.match(/[.!?](\s|$)/g) ?? []).length <= 1;
|
|
32521
|
+
var isSpeech = (line) => new RegExp("\\p{L}\\p{L}", "u").test(line);
|
|
32490
32522
|
var Beat = z30.object({
|
|
32491
32523
|
/**
|
|
32492
32524
|
* The line the voice says in this beat — ONE clause, ending in its own
|
|
32493
32525
|
* punctuation. It becomes the caption card verbatim.
|
|
32494
32526
|
*/
|
|
32495
|
-
say: z30.string().min(1).refine(
|
|
32527
|
+
say: z30.string().min(1).refine(
|
|
32528
|
+
isSpeech,
|
|
32529
|
+
"a beat's line has to be words someone says \u2014 a dot or a dash is not a line, and an ad with no voiceover is not supported yet"
|
|
32530
|
+
).refine(oneClause, "a beat is one clause: split a second sentence into its own beat"),
|
|
32496
32531
|
/** What is on screen while it is said, as a shot brief for the video model. */
|
|
32497
32532
|
show: z30.string().min(1),
|
|
32498
32533
|
/**
|
|
@@ -32594,6 +32629,24 @@ var AdSpec = z30.object({
|
|
|
32594
32629
|
cta: z30.string().min(1).optional()
|
|
32595
32630
|
})
|
|
32596
32631
|
]).optional(),
|
|
32632
|
+
/**
|
|
32633
|
+
* Whether a voice reads the lines.
|
|
32634
|
+
*
|
|
32635
|
+
* On by default. Turned off, the ad carries its music and its pictures and the `say`
|
|
32636
|
+
* lines become ON-SCREEN text instead of spoken ones — which is how most music-led
|
|
32637
|
+
* social ads are actually built.
|
|
32638
|
+
*
|
|
32639
|
+
* It exists because an agent wanted exactly this and had no way to ask: it wrote "·"
|
|
32640
|
+
* as every line with a voice description reading "No voiceover; music only". The dot
|
|
32641
|
+
* was sent to the voice model, the near-silence that came back was transcribed, and
|
|
32642
|
+
* the transcriber hallucinated "Thank you." — which became the ad's only caption card,
|
|
32643
|
+
* held over all sixteen seconds, in English, in a Spanish ad.
|
|
32644
|
+
*
|
|
32645
|
+
* The captions are BETTER on this path, not worse: they come from the script itself
|
|
32646
|
+
* rather than from transcribing a rendered read, so the text is exact and no speech
|
|
32647
|
+
* model is between the copy and the screen.
|
|
32648
|
+
*/
|
|
32649
|
+
voiceover: z30.boolean().default(true),
|
|
32597
32650
|
beats: z30.array(Beat).min(2)
|
|
32598
32651
|
});
|
|
32599
32652
|
function presenterIsInEveryShot(spec) {
|
|
@@ -32684,6 +32737,10 @@ function beatSeconds(line) {
|
|
|
32684
32737
|
return allowed.find((d) => d >= spoken) ?? allowed[allowed.length - 1];
|
|
32685
32738
|
}
|
|
32686
32739
|
var BRAND_PLATE = "A solid flat brand colour plate \u2014 an end card. No people, no objects, no scenery, no text of any kind.";
|
|
32740
|
+
var ASKS_FOR_LEGIBLE = /\b(focus on the (number|amount|total|price|figure|text)|readable|legible|clearly (shows?|visible)|highlighted (number|amount|total)|showing the (number|amount|total|price))\b/i;
|
|
32741
|
+
function briefWantsLegibleText(show) {
|
|
32742
|
+
return ASKS_FOR_LEGIBLE.test(show);
|
|
32743
|
+
}
|
|
32687
32744
|
function endCardWanted(spec) {
|
|
32688
32745
|
if (spec.end_card === false) return false;
|
|
32689
32746
|
return Boolean(spec.brand?.name || spec.brand?.logo);
|
|
@@ -32715,6 +32772,30 @@ function adSpecCastElements(spec, avatar) {
|
|
|
32715
32772
|
}
|
|
32716
32773
|
];
|
|
32717
32774
|
}
|
|
32775
|
+
function motionPrompt(beat, isClosingCard, place, physics) {
|
|
32776
|
+
if (isClosingCard) return BRAND_PLATE;
|
|
32777
|
+
const shot = `${beat.show}${place}${physics}`;
|
|
32778
|
+
return beat.on_camera ? shot : `${shot} Nobody in frame is speaking \u2014 mouths closed, no dialogue.`;
|
|
32779
|
+
}
|
|
32780
|
+
function staticTranscript(spec) {
|
|
32781
|
+
const out = [];
|
|
32782
|
+
let clock = 0;
|
|
32783
|
+
for (const beat of spec.beats) {
|
|
32784
|
+
const duration = beatSeconds(beat.say);
|
|
32785
|
+
const words2 = beat.say.trim().split(/\s+/).filter(Boolean);
|
|
32786
|
+
const each = words2.length > 0 ? duration / words2.length : duration;
|
|
32787
|
+
words2.forEach((text2, i) => {
|
|
32788
|
+
const start = clock + i * each;
|
|
32789
|
+
out.push({
|
|
32790
|
+
text: text2,
|
|
32791
|
+
start: Math.round(start * 100) / 100,
|
|
32792
|
+
end: Math.round((start + each) * 100) / 100
|
|
32793
|
+
});
|
|
32794
|
+
});
|
|
32795
|
+
clock += duration;
|
|
32796
|
+
}
|
|
32797
|
+
return out;
|
|
32798
|
+
}
|
|
32718
32799
|
function adSpecToBlueprint(spec) {
|
|
32719
32800
|
const total = spec.beats.length;
|
|
32720
32801
|
const market = marketFor(spec);
|
|
@@ -32739,7 +32820,10 @@ function adSpecToBlueprint(spec) {
|
|
|
32739
32820
|
// `on_camera` is the engine's own switch for "this line is narration" —
|
|
32740
32821
|
// absent, it guesses from who is in frame, which is how a still photograph
|
|
32741
32822
|
// once got lip-synced.
|
|
32742
|
-
dialogue:
|
|
32823
|
+
// No dialogue at all when nobody speaks: an empty array is what stops the engine
|
|
32824
|
+
// wiring a voice track, and no voice track is what makes this a music-led ad
|
|
32825
|
+
// rather than one with a silent narrator.
|
|
32826
|
+
dialogue: spec.voiceover === false ? [] : [
|
|
32743
32827
|
{
|
|
32744
32828
|
line: beat.say,
|
|
32745
32829
|
start_s: Math.round(start * 100) / 100,
|
|
@@ -32751,11 +32835,7 @@ function adSpecToBlueprint(spec) {
|
|
|
32751
32835
|
...spec.voice?.description ? { voice_description: spec.voice.description } : {}
|
|
32752
32836
|
}
|
|
32753
32837
|
],
|
|
32754
|
-
motion_prompt:
|
|
32755
|
-
// The subject must not be forming words: the voice belongs to the
|
|
32756
|
-
// narration, and a mouth moving under it reads as dubbing.
|
|
32757
|
-
`${beat.show}${place}${physics} Nobody in frame is speaking \u2014 mouths closed, no dialogue.`
|
|
32758
|
-
),
|
|
32838
|
+
motion_prompt: motionPrompt(beat, isClosingCard, place, physics),
|
|
32759
32839
|
start_frame_prompt: isClosingCard ? BRAND_PLATE : `${beat.show}${place}${physics}`
|
|
32760
32840
|
};
|
|
32761
32841
|
});
|
|
@@ -32765,8 +32845,9 @@ function adSpecToBlueprint(spec) {
|
|
|
32765
32845
|
transcript: spec.beats.map((b) => b.say).join(" "),
|
|
32766
32846
|
voiceover: {
|
|
32767
32847
|
// Narration over the picture. Never `on_camera`: that is what tells the
|
|
32768
|
-
// engine a mouth is a lip-sync candidate.
|
|
32769
|
-
|
|
32848
|
+
// engine a mouth is a lip-sync candidate. A music-led ad says `none`, which is
|
|
32849
|
+
// what keeps a voice out of it entirely.
|
|
32850
|
+
mode: spec.voiceover === false ? "none" : "voiceover",
|
|
32770
32851
|
...spec.voice?.description ? { voice_description: spec.voice.description } : {}
|
|
32771
32852
|
},
|
|
32772
32853
|
...spec.brand?.name || spec.brand?.logo ? {
|
|
@@ -32980,7 +33061,7 @@ registerSchema({
|
|
|
32980
33061
|
spec: {
|
|
32981
33062
|
type: "string",
|
|
32982
33063
|
required: true,
|
|
32983
|
-
description: 'Path to the ad spec JSON. Shape: { format?, market?, brand?, cast?, end_card?, voice?, music?, beats: [{ say, show, on_camera?, cast? }] }. Fill `brand` from src/brand/BRAND.md \u2014 `palette` (its hex tokens, most important first) and `logo` (the repo path to the mark) are what make the ad look like the client rather than like stock. `market` is where the ad is SET; omitted, it is inferred from the voice language, and getting it wrong is what fills a Spanish ad with British houses. `say` is ONE clause ending in its own punctuation \u2014 it becomes a caption card verbatim, so two sentences in one beat produce a card holding both. `show` is the shot brief for that line. Set `on_camera` ONLY when that beat\'s subject talks to camera. `cast` is WHO the ad is about, and an ad with people in it needs one: `{ "avatar": "marta" }` names a cast avatar (`baker avatars list`) and every beat is grounded on that avatar\'s identity sheet with its subject description copied verbatim \u2014 the same face here as in the rest of the company\'s work. Without it each beat invents its own stranger, which is how one 28-second ad came back with five different men playing one customer. `{ "description": "..." }` is the fallback when there is no avatar, and it scaffolds a canvas that asks you to drop a photo before it can run \u2014 so prefer the avatar. A beat sets `cast: false` for a shot they are not in. `end_card` is on by default whenever `brand` is set: the last beat becomes a flat brand plate with the mark and a call to action drawn over it. `{ "cta": "..." }` sets the button copy, `false` keeps the footage.'
|
|
33064
|
+
description: 'Path to the ad spec JSON. Shape: { format?, market?, brand?, cast?, end_card?, voice?, music?, beats: [{ say, show, on_camera?, cast? }] }. Fill `brand` from src/brand/BRAND.md \u2014 `palette` (its hex tokens, most important first) and `logo` (the repo path to the mark) are what make the ad look like the client rather than like stock. `market` is where the ad is SET; omitted, it is inferred from the voice language, and getting it wrong is what fills a Spanish ad with British houses. `say` is ONE clause ending in its own punctuation \u2014 it becomes a caption card verbatim, so two sentences in one beat produce a card holding both. `show` is the shot brief for that line. Set `on_camera` ONLY when that beat\'s subject talks to camera. `voiceover: false` makes it a MUSIC-LED ad: nobody speaks, the `say` lines become on-screen text captioned straight from the script, and it needs `music`. `cast` is WHO the ad is about, and an ad with people in it needs one: `{ "avatar": "marta" }` names a cast avatar (`baker avatars list`) and every beat is grounded on that avatar\'s identity sheet with its subject description copied verbatim \u2014 the same face here as in the rest of the company\'s work. Without it each beat invents its own stranger, which is how one 28-second ad came back with five different men playing one customer. `{ "description": "..." }` is the fallback when there is no avatar, and it scaffolds a canvas that asks you to drop a photo before it can run \u2014 so prefer the avatar. A beat sets `cast: false` for a shot they are not in. `end_card` is on by default whenever `brand` is set: the last beat becomes a flat brand plate with the mark and a call to action drawn over it. `{ "cta": "..." }` sets the button copy, `false` keeps the footage.'
|
|
32984
33065
|
},
|
|
32985
33066
|
avatar: {
|
|
32986
33067
|
type: "string",
|
|
@@ -33048,6 +33129,19 @@ var scaffoldAdCommand = defineCommand104({
|
|
|
33048
33129
|
if (flagAvatar) {
|
|
33049
33130
|
spec.data.cast = { ...spec.data.cast, avatar: flagAvatar };
|
|
33050
33131
|
}
|
|
33132
|
+
const contradicts = spec.data.beats.map((beat, i) => ({ i, show: beat.show })).filter(({ show }) => briefWantsLegibleText(show));
|
|
33133
|
+
if (contradicts.length > 0) {
|
|
33134
|
+
writeJson({
|
|
33135
|
+
ok: false,
|
|
33136
|
+
error: {
|
|
33137
|
+
code: "VALIDATION_ERROR",
|
|
33138
|
+
message: `Beat ${contradicts.map(({ i }) => i + 1).join(", ")} asks the picture for a readable figure (e.g. "${contradicts[0]?.show.trim().slice(0, 90)}"). Every frame also carries "no readable text or numbers", so the two fight and the picture wins \u2014 one such brief rendered a bill reading $58,085.00 over a total of $139.00.`,
|
|
33139
|
+
fix: "Say what the shot LOOKS like, not what it reads: 'a bill in her hands, her face falling'. The ad's numbers belong in the `say` line, where the caption track renders them correctly."
|
|
33140
|
+
}
|
|
33141
|
+
});
|
|
33142
|
+
process.exit(1);
|
|
33143
|
+
return;
|
|
33144
|
+
}
|
|
33051
33145
|
const handle = flagAvatar || spec.data.cast?.avatar?.trim();
|
|
33052
33146
|
let avatar = null;
|
|
33053
33147
|
let avatarError = null;
|
|
@@ -33092,6 +33186,11 @@ var scaffoldAdCommand = defineCommand104({
|
|
|
33092
33186
|
await writeSceneFiles(outDir, blueprint);
|
|
33093
33187
|
await writeFile9(blueprintStylePath, renderStyleProjectionFromValue(blueprint), "utf8");
|
|
33094
33188
|
const logoPath = spec.data.brand?.logo?.trim();
|
|
33189
|
+
const transcriptPath = path24.join(outDir, "transcript.json");
|
|
33190
|
+
if (spec.data.voiceover === false) {
|
|
33191
|
+
await writeFile9(transcriptPath, `${JSON.stringify(staticTranscript(spec.data), null, 2)}
|
|
33192
|
+
`, "utf-8");
|
|
33193
|
+
}
|
|
33095
33194
|
const opts = {
|
|
33096
33195
|
imageModel: AD_IMAGE_MODEL,
|
|
33097
33196
|
// The sheet on the SAME model as the frames it feeds. A sheet from another
|
|
@@ -33111,7 +33210,8 @@ var scaffoldAdCommand = defineCommand104({
|
|
|
33111
33210
|
captionWordsPerGroup: 8,
|
|
33112
33211
|
// Told, not guessed: the voice description is written in the ad's own language
|
|
33113
33212
|
// and the engine's trait parser reads English only.
|
|
33114
|
-
voiceLanguage: voiceLanguageFor(spec.data)
|
|
33213
|
+
voiceLanguage: voiceLanguageFor(spec.data),
|
|
33214
|
+
...spec.data.voiceover === false ? { staticTranscriptPath: path24.relative(outDir, transcriptPath) } : {}
|
|
33115
33215
|
};
|
|
33116
33216
|
const canvas = scaffoldVideoCanvas(blueprint, adSpecCastElements(spec.data, avatar), opts);
|
|
33117
33217
|
const renderNode3 = canvas.nodes.find((n) => n.type === "hyperframe_render");
|
|
@@ -33139,6 +33239,12 @@ var scaffoldAdCommand = defineCommand104({
|
|
|
33139
33239
|
const html = await readFile16(indexPath, "utf-8");
|
|
33140
33240
|
await writeFile9(indexPath, injectAdBrandOverlay(html, overlayHtml), "utf-8");
|
|
33141
33241
|
}
|
|
33242
|
+
await writeFile9(
|
|
33243
|
+
path24.join(outDir, REBUILD_FILE),
|
|
33244
|
+
`${JSON.stringify({ elements: adSpecCastElements(spec.data, avatar), opts }, null, 2)}
|
|
33245
|
+
`,
|
|
33246
|
+
"utf8"
|
|
33247
|
+
);
|
|
33142
33248
|
await writeFile9(outPath, `${JSON.stringify(canvas, null, 2)}
|
|
33143
33249
|
`, "utf-8");
|
|
33144
33250
|
writeJson({
|
|
@@ -33169,6 +33275,9 @@ var scaffoldAdCommand = defineCommand104({
|
|
|
33169
33275
|
...spec.data.cast && !avatar ? [
|
|
33170
33276
|
"This cast has a description but no avatar, so the canvas asks you to drop a real source photo before it can run. `baker avatars list` \u2014 casting one and passing `cast.avatar` fills that slot for you and keeps the face consistent across every piece of work."
|
|
33171
33277
|
] : [],
|
|
33278
|
+
...spec.data.voiceover === false ? [
|
|
33279
|
+
"Nobody speaks in this ad, so the `say` lines are ON-SCREEN text: they are captioned straight from the script, exactly as written, with no voice and no transcription in between. Give it `music` \u2014 a music-led ad with no bed is a silent one."
|
|
33280
|
+
] : [],
|
|
33172
33281
|
"The caption cards are your `say` lines verbatim \u2014 read them back before running; that is the copy the viewer sees.",
|
|
33173
33282
|
"Every clip renders with nobody speaking unless a beat set `on_camera`. If a beat needs a presenter talking, set it there rather than describing it in `show`.",
|
|
33174
33283
|
...spec.data.brand?.palette?.length && spec.data.brand?.logo ? [] : [
|