@facelessad/cli 1.1.0 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +87 -3
  2. package/index.js +291 -20
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -27,6 +27,24 @@ stops on it immediately instead of polling forever. For long-running
27
27
  builds a webhook is still the better tool — a stuck shell is a stuck CI
28
28
  job. See https://facelessad.com/developers → Webhooks.
29
29
 
30
+ ## Getting the finished video
31
+
32
+ `--wait` tells you when the video is done, but it never prints the video URL.
33
+ Three ways to get the file:
34
+
35
+ ```bash
36
+ facelessad download 103 --out ad.mp4 # saves into the CURRENT directory
37
+ facelessad status 103 --json | jq -r .url # the raw link, valid ~1 hour
38
+ ```
39
+
40
+ The third is the browser: every video made through the CLI also appears in My
41
+ Files at facelessad.com.
42
+
43
+ Since 1.1.1 `download` prints the full path and the file size, and fails
44
+ loudly on an empty file instead of reporting success. The link is signed and
45
+ expires in about an hour — the video itself does not, so ask for the status
46
+ again to get a fresh one.
47
+
30
48
  ## Custom style (1.1.0)
31
49
 
32
50
  Most tools accept a look of your own instead of one of the built-in styles.
@@ -84,10 +102,76 @@ developers page and live on in scripts:
84
102
  | `--no-music` | `--music false` |
85
103
  | `-o` | `--out` |
86
104
 
87
- `--no-brand-kit` and `--no-winners` now work; before 1.0.1 they were
88
- accepted on the command line and silently dropped, so the video was built
89
- with the Brand Kit anyway. **An unknown flag is now an error** rather than
105
+ `--no-brand-kit` now works; before 1.0.1 it was accepted on the command line
106
+ and silently dropped, so the video was built with the Brand Kit anyway. **An unknown flag is now an error** rather than
90
107
  something quietly ignored — that silence is what hid the whole problem.
91
108
 
109
+ ## 1.2.0
110
+
111
+ **Video Banner has flags.** Its copy and images were reachable through the
112
+ HTTP API but had no flags at all, so the only way to set them was
113
+ `--file body.json`:
114
+
115
+ ```bash
116
+ facelessad create --tool video-banner --url https://example.com \
117
+ --headline "Ship ads in minutes" --subline "No camera, no crew" \
118
+ --cta-text "Try it free" --badge random_face --photo-query "nordic office" --wait
119
+ ```
120
+
121
+ Leave the three texts out and they are written for you from the materials.
122
+ Give a headline and only your own copy is used — the fields you leave empty
123
+ stay empty rather than being written for you (`--text-mode` makes the choice
124
+ explicit). `--background-image <url>` replaces the automatic photo pick
125
+ entirely, and `--badge-image <url>` puts your own image in the corner instead
126
+ of the AI face.
127
+
128
+ **Product Showcase extra angles:** `--product-images url1,url2,url3` (up to 8),
129
+ which improves how well the product keeps its shape and label across clips.
130
+
131
+ **SaaS UI Ad takes screenshots.** The tool decomposes real screens with vision
132
+ and rebuilds them animated — that is the whole point of it, and without them
133
+ the ad is built from your written description alone:
134
+
135
+ ```bash
136
+ facelessad create --tool saas-ui-ad --url https://your-app.com \
137
+ --screenshots "https://.../dashboard.png,https://.../editor.png" --wait
138
+ ```
139
+
140
+ How many fit depends on the length, because each screen needs time on camera:
141
+ 1 for short, 3 for medium, 4 for long. `facelessad tools --tool saas-ui-ad`
142
+ prints the limits.
143
+
144
+ **Character does dialogue.** `--speakers 2` makes it a conversation between two
145
+ characters instead of one narrator, and the ad structure has to match — the
146
+ two pools are listed by `facelessad tools --tool character`:
147
+
148
+ ```bash
149
+ facelessad create --tool character --url https://your-product.com \
150
+ --speakers 2 --structure ch-qa --gender-right male --wait
151
+ ```
152
+
153
+ `--voice-right <id>` and `--gender-right female|male` steer the second voice.
154
+ Leave them out and a distinct one is picked for you — a different gender
155
+ first, then a different id. They need `--speakers 2`; without it the command
156
+ stops before it reaches the network.
157
+
158
+ **A flag that needs a value is an error without one.** `--brand-kit` on its own
159
+ used to mean brand number 1, and `download 5 -o` used to write a file called
160
+ `true`. Both now stop with `missing_value`. Flags that are booleans by nature
161
+ (`--sfx`, `--no-voice`, `--wait`) are unaffected.
162
+
163
+ **Boolean flags are validated.** `--captions yes`, `--captions 1` and
164
+ `--captions TRUE` used to become **false** without a word, because only the
165
+ exact string `true` was accepted. All the usual spellings now work in both
166
+ directions, and anything else is an error instead of a silent guess.
167
+
168
+ **`facelessad tools --tool <id>` shows every ad structure**, grouped the way
169
+ the app groups them. It used to print only the default group — 17 of animated
170
+ ad's 46, and 6 of motion graphics' 119.
171
+
172
+ **`--winners` / `--no-winners` are gone.** Winning-ad analyses are not offered
173
+ through the API while the feature is unfinished; the server no longer reads the
174
+ field.
175
+
92
176
  Errors are printed with the API's own wording and exit code 1. The machine
93
177
  code (e.g. `unknown_style`) follows the message.
package/index.js CHANGED
@@ -116,12 +116,54 @@ const KNOWN_FLAGS = new Set([
116
116
  // kayttajan teksti.
117
117
  'custom-style', 'custom-graphics-style', 'no-refine-style',
118
118
  'structure', 'hook', 'video-mode', 'brand-color', 'brand-name', 'cta', 'name',
119
- 'voice', 'gender', 'no-voice', 'music', 'sfx', 'captions', 'product-image',
120
- 'no-brand-kit', 'no-winners', 'brand-kit',
119
+ 'voice', 'gender', 'no-voice', 'voice-over', 'music', 'sfx', 'captions', 'product-image',
120
+ // §708: character-dialogi — puhujamäärä ja toisen puhujan ääni.
121
+ 'speakers', 'voice-right', 'gender-right',
122
+ 'no-brand-kit', 'brand-kit',
123
+ // 1.2.0: showcasen lisäkulmat ja video-bannerin kahdeksan kenttää.
124
+ 'product-images', 'screenshots',
125
+ 'headline', 'subline', 'cta-text', 'text-mode',
126
+ 'background-image', 'badge-image', 'badge', 'photo-query',
121
127
  'file', 'dry-run', 'wait', 'timeout', 'json', 'out', 'limit', 'offset', 'version',
122
128
  ]);
123
129
  const unknownFlags = Object.keys(flags).filter((f) => !KNOWN_FLAGS.has(f)).map((f) => '--' + f).concat(badShort);
124
130
 
131
+ /**
132
+ * 1.2.0 — ARVOA VAATIVA LIPPU ILMAN ARVOA ON VIRHE.
133
+ *
134
+ * Sama hiljainen vikaluokka jota koko 1.0.1 korjasi, mutta yhtä kerrosta
135
+ * syvemmällä: lippu TUNNETAAN, se vain jäi ilman arvoa. Silloin sen arvoksi
136
+ * tulee `true`, ja jokainen String()-muunnos tekee siitä merkkijonon
137
+ * "true". Osa niistä pysähtyy palvelimen validointiin, mutta kaikki eivät —
138
+ * ja juuri ne jotka eivät, ovat pahimpia:
139
+ *
140
+ * facelessad create --brand-kit --tool x → brand_kit_id: 1
141
+ * `Number(true) === 1` ja `Number.isInteger(1)`, joten `num()` päästi
142
+ * sen läpi. Id 1 on oikea brändi — vain väärä. Video valmistuu toisen
143
+ * brändin väreillä ja äänensävyllä eikä siitä kerrota missään.
144
+ * facelessad download 5 -o → tiedosto nimeltä `true`
145
+ * Ilman päätettä, työhakemistoon. 1.1.1 lisäsi juuri koko polun
146
+ * tulostamisen siksi ettei tiedostoa löytynyt; tämä on sama ongelma
147
+ * pahempana.
148
+ * facelessad list --limit → limit=1, lista kutistuu
149
+ * facelessad create --headline --tool … → bannerin otsikoksi "true"
150
+ * --brand-name, --cta, --language, --gender, --photo-query, --custom-style
151
+ * → kaikki hiljaa väärä arvo, ei virhettä mistään
152
+ *
153
+ * Yksittäisten kutsupaikkojen paikkaaminen olisi jättänyt loput; tämä on
154
+ * yksi portti kaikille 39:lle. Totuusarvoliput (--sfx, --wait, --no-voice…)
155
+ * ovat tarkoituksella ulkopuolella: niille arvottomuus ON arvo.
156
+ */
157
+ const BOOLEAN_FLAGS = new Set([
158
+ 'no-captions', 'no-music', 'no-refine-style', 'no-voice', 'no-brand-kit',
159
+ 'dry-run', 'wait', 'json', 'version',
160
+ // Näillä neljällä arvo on VALINNAINEN: `--sfx` tarkoittaa `--sfx true`.
161
+ 'sfx', 'music', 'captions', 'voice-over',
162
+ ]);
163
+ const missingValues = Object.keys(flags)
164
+ .filter((f) => flags[f] === true && KNOWN_FLAGS.has(f) && !BOOLEAN_FLAGS.has(f))
165
+ .map((f) => '--' + f);
166
+
125
167
  const asJson = flags.json === true || flags.json === 'true';
126
168
 
127
169
  // ────────────────────────── config / auth ──────────────────────────
@@ -200,6 +242,57 @@ function num(flag, raw) {
200
242
  return n;
201
243
  }
202
244
 
245
+ /**
246
+ * §706 — `--duration` ottaa vastaan kolmen vaihtoehdon id:n TAI sekuntiluvun.
247
+ * Sekuntien kelvollisuuden ratkaisee palvelin (se tuntee työkalukohtaiset
248
+ * vaihtoehdot rekisteristä), joten täällä tarkistetaan vain muoto: id tai
249
+ * kokonaisluku. `facelessad tools --tool <id>` tulostaa vaihtoehdot.
250
+ */
251
+ const DURATION_IDS = new Set(['short', 'medium', 'long']);
252
+ function durationValue(raw) {
253
+ if (raw === undefined) return undefined;
254
+ if (raw === true) die('--duration needs a value: short | medium | long, or the seconds for that length', 'invalid_duration');
255
+ const v = String(raw).trim().toLowerCase();
256
+ if (DURATION_IDS.has(v)) return v;
257
+ const n = Number(raw);
258
+ if (!Number.isInteger(n)) {
259
+ die('--duration must be short, medium or long — or the seconds for that length. See: facelessad tools --tool <id>', 'invalid_duration');
260
+ }
261
+ return n;
262
+ }
263
+
264
+ /**
265
+ * §706 — kyselymerkkijono ilman `URLSearchParams.size`ä. Se on Nodessa vasta
266
+ * 18.16 / 19.8, mutta tämä paketti lupaa `node>=18`: sitä vanhemmalla 18:lla
267
+ * `q.size` on undefined, jolloin `?`-osa jäi pois KOKONAAN ja `--language`,
268
+ * `--gender`, `--limit` ja `--offset` katosivat ilman mitään ilmoitusta.
269
+ * Sama hiljainen luokka jota tämä paketti on muuten täynnä korjaamassa.
270
+ */
271
+ function qs(q) {
272
+ return [...q.keys()].length ? '?' + q.toString() : '';
273
+ }
274
+
275
+ /**
276
+ * 1.2.0 — BOOLEAN-LIPUT VALIDOIDAAN. Tämä oli sama hiljainen vikaluokka jota
277
+ * koko 1.0.1 korjasi, mutta se jäi näihin kolmeen lippuun: koodi vertasi
278
+ * arvoa merkkijonoon "true", joten `--captions yes`, `--captions 1` ja
279
+ * `--captions TRUE` päätyivät kaikki arvoon FALSE ilman mitään ilmoitusta.
280
+ * Käyttäjä pyysi tekstitykset ja sai videon ilman.
281
+ *
282
+ * Nyt tavanomaiset kirjoitusasut hyväksytään molempiin suuntiin ja mikä
283
+ * tahansa muu on VIRHE — hiljaista arvausta ei tehdä kumpaankaan suuntaan.
284
+ */
285
+ const BOOL_TRUE = new Set(['true', '1', 'yes', 'y', 'on']);
286
+ const BOOL_FALSE = new Set(['false', '0', 'no', 'n', 'off']);
287
+ function bool(flag, raw) {
288
+ if (raw === undefined) return undefined;
289
+ if (raw === true) return true; // pelkkä --sfx ilman arvoa
290
+ const v = String(raw).trim().toLowerCase();
291
+ if (BOOL_TRUE.has(v)) return true;
292
+ if (BOOL_FALSE.has(v)) return false;
293
+ die('--' + flag + ' must be true or false, got "' + raw + '"', 'invalid_boolean');
294
+ }
295
+
203
296
  // ────────────────────────── request body from flags ──────────────────────────
204
297
 
205
298
  /**
@@ -219,7 +312,12 @@ function buildBody() {
219
312
  if (flags.url !== undefined) body.materials.landing_page_url = String(flags.url);
220
313
  if (flags.text !== undefined) body.materials.text = String(flags.text);
221
314
  }
222
- set('duration', num('duration', flags.duration));
315
+ // §706: kesto on kolme vaihtoehtoa, ei väli. Palvelin hyväksyy joko id:n
316
+ // ("short"|"medium"|"long") tai sitä vastaavan sekuntiluvun ja hylkää muut
317
+ // (invalid_duration). Ennen se puristi arvon hiljaa rajoihin ja worker
318
+ // pudotti sen samoihin kolmeen ämpäriin, joten --duration 45 teki saman
319
+ // videon kuin --duration 50 eikä siitä kerrottu missään.
320
+ set('duration', durationValue(flags.duration));
223
321
  set('aspect_ratio', flags.aspect !== undefined ? String(flags.aspect) : undefined);
224
322
  set('language', flags.language !== undefined ? String(flags.language) : undefined);
225
323
  set('style', flags.style !== undefined ? String(flags.style) : undefined);
@@ -241,15 +339,83 @@ function buildBody() {
241
339
  set('product_image_url', flags['product-image'] !== undefined ? String(flags['product-image']) : undefined);
242
340
  // 1.0.1 (§640): kumpi brändi. Ilman tätä API otti aina tilin oletusbrändin.
243
341
  set('brand_kit_id', num('brand-kit', flags['brand-kit']));
244
- if (flags.music !== undefined) body.music = flags.music === true || flags.music === 'true';
245
- if (flags.sfx !== undefined) body.sfx = flags.sfx === true || flags.sfx === 'true';
246
- if (flags.captions !== undefined) body.captions = flags.captions === true || flags.captions === 'true';
342
+ if (flags.music !== undefined) body.music = bool('music', flags.music);
343
+ if (flags.sfx !== undefined) body.sfx = bool('sfx', flags.sfx);
344
+ if (flags.captions !== undefined) body.captions = bool('captions', flags.captions);
345
+ /**
346
+ * §724 — `--voice-over` PUUTTUI KOKONAAN. CLI osasi vain kytkeä kertojan
347
+ * POIS (`--no-voice`), ei päälle. Kolmella työkalulla valinta on olemassa
348
+ * (`supports.voiceOverToggle`: slideshow, inspiration, product-showcase), ja
349
+ * Inspiration Reelillä KAIKKI kymmenen tyyliä ovat oletuksena mykkiä — eli
350
+ * `voice_over: true` on ainoa tapa saada siihen kertoja, eikä sitä voinut
351
+ * komentoriviltä pyytää lainkaan. HTTP-API ja MCP ovat tukeneet kenttää
352
+ * alusta asti; vain tämä paketti ei.
353
+ *
354
+ * `--no-voice` säilyy ja voittaa, jos molemmat annetaan.
355
+ */
356
+ if (flags['voice-over'] !== undefined) body.voice_over = bool('voice-over', flags['voice-over']);
247
357
  if (flags['no-voice'] === true) body.voice_over = false;
248
358
  // 1.0.1: nämä kaksi puuttuivat kokonaan. facelessad.com/developers tuotti
249
359
  // niille lippuja joita tämä tiedosto ei lukenut, joten "älä käytä brand
250
360
  // kittiä" -pyyntö meni hiljaa roskiin ja API:n oletus (true) voitti.
251
361
  if (flags['no-brand-kit'] === true) body.use_brand_kit = false;
252
- if (flags['no-winners'] === true) body.use_winners = false;
362
+ // 1.2.0: --winners / --no-winners POISTETTU. Voittaja-analyysit ovat
363
+ // keskeneräinen ominaisuus jota ei tarjota API:n kautta (§705); palvelin ei
364
+ // enää lue kenttää lainkaan.
365
+ // 1.2.0: product-showcasen lisäkulmakuvat. API on hyväksynyt kentän §556:sta
366
+ // asti ja MCP-skeemassa se on ollut alusta, mutta CLI:stä se puuttui.
367
+ // §711: SaaS UI Ad — kuvakaappaukset pilkulla eroteltuna, kuten
368
+ // --product-images. Katto on kestokohtainen ja tulee palvelimelta;
369
+ // `facelessad tools --tool saas-ui-ad` tulostaa sen screenshots-rivillä
370
+ // (1.2.0 asti rivi puuttui, vaikka tämä kommentti lupasi sen — raja
371
+ // selvisi vasta palvelimen too_many_screenshots -virheestä).
372
+ if (flags.screenshots !== undefined) {
373
+ const urls = String(flags.screenshots).split(',').map((s) => s.trim()).filter(Boolean);
374
+ if (urls.length) body.screenshot_urls = urls;
375
+ }
376
+ if (flags['product-images'] !== undefined) {
377
+ const urls = String(flags['product-images']).split(',').map((s) => s.trim()).filter(Boolean);
378
+ if (urls.length) body.product_image_urls = urls;
379
+ }
380
+ // 1.2.0: VIDEO BANNER. Kahdeksan kenttää jotka API on hyväksynyt §362/§380:stä
381
+ // asti ja jotka MCP-skeema on tuntenut — mutta joille ei ollut yhtään lippua,
382
+ // joten bannerin oma copy oli komentoriviltä täysin saavuttamaton ja
383
+ // facelessad.com/developers joutui ohjaamaan --file-muotoon.
384
+ const bannerTexts = {};
385
+ if (flags.headline !== undefined) bannerTexts.headline = String(flags.headline);
386
+ if (flags.subline !== undefined) bannerTexts.subline = String(flags.subline);
387
+ if (flags['cta-text'] !== undefined) bannerTexts.cta_text = String(flags['cta-text']);
388
+ if (Object.keys(bannerTexts).length) body.texts = { ...(body.texts || {}), ...bannerTexts };
389
+ set('text_mode', flags['text-mode'] !== undefined ? String(flags['text-mode']) : undefined);
390
+ set('background_image_url', flags['background-image'] !== undefined ? String(flags['background-image']) : undefined);
391
+ set('badge_image_url', flags['badge-image'] !== undefined ? String(flags['badge-image']) : undefined);
392
+ set('badge', flags.badge !== undefined ? String(flags.badge) : undefined);
393
+ set('photo_query', flags['photo-query'] !== undefined ? String(flags['photo-query']) : undefined);
394
+ // §708: character. --speakers 2 tekee dialogin; rakenteen on oltava
395
+ // kahden puhujan poolista (facelessad tools --tool character näyttää
396
+ // molemmat poolit). --voice-right / --gender-right ohjaa [R]-puhujaa;
397
+ // jätettynä pois palvelin poimii erottuvan äänen itse.
398
+ if (flags.speakers !== undefined) {
399
+ const sp = String(flags.speakers).trim().toLowerCase();
400
+ const n = { '1': 1, 'one': 1, '2': 2, 'two': 2 }[sp];
401
+ if (!n) die('--speakers must be 1 (one narrator) or 2 (a dialogue between two characters)', 'invalid_speakers');
402
+ body.speakers = n;
403
+ }
404
+ if (flags['voice-right'] !== undefined || flags['gender-right'] !== undefined) {
405
+ // §710: toinen ääni on olemassa vain kahden puhujan ajossa. Palvelin
406
+ // hylkää sen muuten (voice_right_without_two_speakers), mutta virhe
407
+ // kannattaa antaa tässä: komento pysähtyy ennen verkkokutsua eikä
408
+ // käyttäjä jää odottamaan vastausta pyyntöön joka ei voi onnistua.
409
+ // Huom: --file voi tuoda speakersin rungosta, joten tarkistus katsoo
410
+ // lopullista arvoa eikä pelkkää lippua.
411
+ if (body.speakers !== 2) {
412
+ die('--voice-right and --gender-right are the second speaker\'s voice — they need --speakers 2. '
413
+ + 'With one narrator, use --voice / --gender.', 'voice_right_without_two_speakers');
414
+ }
415
+ body.voice_right = {};
416
+ if (typeof flags['voice-right'] === 'string') body.voice_right.id = flags['voice-right'];
417
+ if (flags['gender-right'] !== undefined) body.voice_right.gender = String(flags['gender-right']);
418
+ }
253
419
  if (flags.voice !== undefined || flags.gender !== undefined) {
254
420
  if (typeof flags.voice === 'string' && flags.gender === undefined) {
255
421
  body.voice = flags.voice;
@@ -311,8 +477,72 @@ const commands = {
311
477
  if (!t) die('Unknown tool "' + flags.tool + '". Valid: ' + (d.tools || []).map((x) => x.id).join(', '), 'unknown_tool');
312
478
  const lines = [bold(t.id) + ' — ' + (t.name || '')];
313
479
  if (t.styles?.length) lines.push(' styles: ' + t.styles.map((s) => s.id).join(', '));
314
- if (t.adStructures?.length) lines.push(' ad structures: ' + t.adStructures.map((s) => s.id).join(', '));
315
- if (t.durations) lines.push(' duration: ' + t.durations.min + '–' + t.durations.max + ' s');
480
+ // 1.2.0: rekisteri kertoo rakenteet KOLMESSA muodossa (§698). Ennen tämä
481
+ // tulosti vain adStructures-listan, joka on oletusryhmän pooli eli
482
+ // animated-adilla 17 rakennetta 46:sta ja motion-graphicsilla 6 sadasta
483
+ // yhdeksästätoista. Loput olivat näkyvissä vain --json-tulosteessa.
484
+ if (t.adStructureGroups?.length) {
485
+ const total = t.adStructureGroups.reduce((n, g) => n + (g.structures?.length || 0), 0);
486
+ lines.push(' ad structures: ' + total + ' in ' + t.adStructureGroups.length + ' groups');
487
+ for (const g of t.adStructureGroups) {
488
+ lines.push(' ' + dim(g.name || g.id) + ': ' + (g.structures || []).map((s) => s.id).join(', '));
489
+ }
490
+ } else if (t.adStructures?.length) {
491
+ lines.push(' ad structures: ' + t.adStructures.map((s) => s.id).join(', '));
492
+ }
493
+ // Tyylit joilla on OMA rakennepooli — valinta riippuu tyylistä, ja
494
+ // yleisen listan rakenne hylätään niillä (structure_style_mismatch).
495
+ // §708: characterin rakenteet ovat kaksi poolia puhujamäärän mukaan.
496
+ // Yhtenä listana ne näyttäisivät vaihtoehdoilta jotka kaikki toimivat
497
+ // millä tahansa --speakers-arvolla, ja API hylkää ristiriidan.
498
+ if (t.speakerStructures) {
499
+ for (const [key, n] of [['one', '--speakers 1'], ['two', '--speakers 2']]) {
500
+ const pool = t.speakerStructures[key] || [];
501
+ if (pool.length) lines.push(' ' + dim(n) + ': ' + pool.map((x) => x.id).join(', '));
502
+ }
503
+ }
504
+ for (const [sid, pool] of Object.entries(t.structuresByStyle || {})) {
505
+ lines.push(' ' + dim('with style ' + sid) + ': ' + (pool || []).map((s) => s.id).join(', '));
506
+ }
507
+ // §706: kolme vaihtoehtoa, ei väli. Rivi luki ennen "duration: 10–60 s",
508
+ // mikä lupasi 51 arvoa joista syntyi kolme videota — ja on nyt suoraan
509
+ // väärin, koska palvelin hylkää välistä poimitun luvun.
510
+ if (t.durations?.options?.length) {
511
+ // Bannerin id:t OVAT sekunnit ("5"/"10"/"15"), joten "5 (5 s)" olisi
512
+ // kohinaa; muilla id on nimi (short/medium/long) ja kuuluu näkyviin.
513
+ lines.push(' duration: ' + t.durations.options
514
+ .map((o) => (String(o.id) === String(o.seconds) ? o.seconds + ' s' : o.id + ' (' + o.seconds + ' s)'))
515
+ .join(' · '));
516
+ } else if (t.durations) {
517
+ lines.push(' duration: ' + t.durations.min + '–' + t.durations.max + ' s');
518
+ }
519
+ /**
520
+ * §711/1.2.0 — KUVAKAAPPAUSTEN KATTO NÄKYVIIN. `--screenshots`in
521
+ * kommentti lupasi että tämä komento näyttää rajan ("katto on
522
+ * kestokohtainen ja tulee palvelimelta"), mutta yksikään rivi ei
523
+ * tulostanut sitä. Käyttäjän ainoa tapa saada raja selville oli lähettää
524
+ * liikaa kuvia ja lukea `too_many_screenshots` — eli arvata ensin.
525
+ *
526
+ * Rekisteri kantaa luvut kestokohtaisesti (`screenshots.max`), ja
527
+ * järjestys luetaan `durations.options`ista eikä objektin avainten
528
+ * järjestyksestä, jotta rivi vastaa yllä olevaa duration-riviä.
529
+ */
530
+ // §725: kertoo että tämä työkalu komposoi tuotekuvan kohtauksiinsa.
531
+ if (t.supports?.productInScenes) {
532
+ lines.push(' product photo: composed into scenes' + dim(' (--product-image <url>)'));
533
+ }
534
+ const shotMax = t.screenshots?.max;
535
+ if (shotMax && Object.keys(shotMax).length) {
536
+ const order = t.durations?.options?.length
537
+ ? t.durations.options.map((o) => String(o.id))
538
+ : Object.keys(shotMax);
539
+ const parts = order
540
+ .filter((id) => shotMax[id] !== undefined)
541
+ .map((id) => id + ' ' + shotMax[id]);
542
+ if (parts.length) {
543
+ lines.push(' screenshots: ' + parts.join(' · ') + dim(' (--screenshots url1,url2)'));
544
+ }
545
+ }
316
546
  out({ ok: true, tool: t }, lines.join('\n'));
317
547
  return;
318
548
  }
@@ -323,7 +553,7 @@ const commands = {
323
553
  const d = await call('GET', '/api/v1/brand-kits');
324
554
  out(d, (d.brandKits || []).map((k) =>
325
555
  ' ' + String(k.id).padEnd(6) + (k.name || '').padEnd(24)
326
- + dim(k.filled + '/32 answered'
556
+ + dim(k.filled + '/' + (k.questions ?? 31) + ' answered'
327
557
  + (k.isDefault ? ' · default' : '')
328
558
  + (k.hasColor ? ' · colour' : '')
329
559
  + (k.hasLogo ? ' · logo' : ''))
@@ -334,12 +564,18 @@ const commands = {
334
564
  const q = new URLSearchParams();
335
565
  if (flags.language) q.set('language', String(flags.language));
336
566
  if (flags.gender) q.set('gender', String(flags.gender));
337
- const d = await call('GET', '/api/v1/voices' + (q.size ? '?' + q : ''));
567
+ const d = await call('GET', '/api/v1/voices' + qs(q));
338
568
  out(d, (d.voices || []).map((v) => ' ' + v.id.padEnd(24) + (v.name || '').padEnd(16) + dim((v.gender || '') + ' ' + (v.language || ''))).join('\n'));
339
569
  },
340
570
 
341
571
  async estimate() {
342
572
  const body = buildBody();
573
+ // §712: sama paikallinen tarkistus kuin `create`illa. Ilman sitä
574
+ // `facelessad estimate --url …` lähti verkkoon ja palasi palvelimen
575
+ // `unknown_tool ""` -virheellä, joka kertoo puuttuvasta työkalusta
576
+ // kiertoteitse. Molemmat komennot ottavat saman rungon, joten niiden on
577
+ // myös hylättävä sama puute samalla tavalla.
578
+ if (!body.tool) die('--tool is required. See: facelessad tools', 'missing_tool');
343
579
  if (flags['dry-run'] === true) { out({ ok: true, body }, JSON.stringify(body, null, 2)); return; }
344
580
  const d = await call('POST', '/api/v1/estimate', body);
345
581
  out(d, 'Estimate: up to ~' + d.estimate + ' credits (balance: ' + d.balance + ')\n' + dim(d.note || ''));
@@ -370,7 +606,7 @@ const commands = {
370
606
  const lim = num('limit', flags.limit), off = num('offset', flags.offset);
371
607
  if (lim !== undefined) q.set('limit', String(lim));
372
608
  if (off !== undefined) q.set('offset', String(off));
373
- const d = await call('GET', '/api/v1/videos' + (q.size ? '?' + q : ''));
609
+ const d = await call('GET', '/api/v1/videos' + qs(q));
374
610
  out(d, (d.videos || []).map((v) => ' ' + String(v.id).padEnd(8) + v.status.padEnd(10) + (v.tool || '').padEnd(18) + dim(v.name || '')).join('\n') || ' (no videos yet)');
375
611
  },
376
612
 
@@ -382,8 +618,18 @@ const commands = {
382
618
  const file = String(flags.out || ('facelessad-' + id + '.mp4'));
383
619
  const res = await fetch(d.url);
384
620
  if (!res.ok) die('Download failed: HTTP ' + res.status, 'download_failed');
385
- fs.writeFileSync(file, Buffer.from(await res.arrayBuffer()));
386
- out({ ok: true, file }, 'Saved ' + file);
621
+ const buf = Buffer.from(await res.arrayBuffer());
622
+ // 1.1.1: tyhjä tiedosto tulosti ennen saman "Saved" kuin onnistunut lataus.
623
+ if (!buf.length) die('Download produced an empty file — the link may have expired. Run `facelessad status ' + id + '` and try again.', 'empty_download');
624
+ fs.writeFileSync(file, buf);
625
+ // 1.1.1: pelkkä tiedostonimi ei kertonut MIHIN tallennettiin. Tiedosto
626
+ // menee työhakemistoon, ei Downloadsiin, ja käyttäjä jäi etsimään sitä
627
+ // (omistajan raportti 12.8.2026). Absoluuttinen polku ja koko ääneen.
628
+ const full = path.resolve(file);
629
+ const mb = buf.length / (1024 * 1024);
630
+ const size = mb >= 1 ? mb.toFixed(1) + ' MB' : Math.max(1, Math.round(buf.length / 1024)) + ' kB';
631
+ out({ ok: true, file, path: full, bytes: buf.length },
632
+ 'Saved ' + full + dim(' (' + size + ')'));
387
633
  },
388
634
 
389
635
  async help() {
@@ -399,18 +645,35 @@ Commands:
399
645
  create --tool <id> ... Create a video (returns an id immediately)
400
646
  status <id> [--wait] Status; --wait polls until done
401
647
  list [--limit --offset] Your videos, newest first
402
- download <id> [--out file] Save the finished mp4
648
+ download <id> [--out file] Save the finished mp4 into the current directory
403
649
 
404
650
  Create flags:
405
- --tool --url --text --duration --aspect --language --style --style-hint
651
+ --tool --url --text --aspect --language --style --style-hint
652
+ --duration short|medium|long (or the seconds for that length; see: facelessad tools --tool <id>)
406
653
  --custom-style "<look you want>" (with --style custom; see facelessad tools)
407
654
  --custom-graphics-style "<...>" (product-showcase only)
408
655
  --no-refine-style (use your text verbatim, don't expand it)
409
- --structure --hook --video-mode --brand-color --brand-name --cta --name
410
- --voice --gender --no-voice --music --sfx --captions --product-image
411
- --no-brand-kit --no-winners --brand-kit <id> (see: facelessad brands)
656
+ --structure --hook --brand-color --brand-name --cta --name
657
+ --video-mode continuous|cuts (animated-ad and music-video only)
658
+ --voice --gender --no-voice --voice-over --music --sfx --captions
659
+ (leave --voice out and the voice is cast from the finished script;
660
+ --gender narrows the casting pool, --voice <id> skips casting)
661
+ --product-image <url> (product-showcase: required, every clip is animated
662
+ from it. animated-ad / crude: optional, composed into the scenes where the
663
+ product appears — see: facelessad tools --tool <id>)
664
+ --speakers 1|2 (character: one narrator, or a two-character dialogue)
665
+ --voice-right <id> --gender-right female|male (character with --speakers 2)
666
+ --product-images url1,url2 (product-showcase only: extra angles of the product)
667
+ --screenshots url1,url2 (saas-ui-ad: screenshots of your app; 1/3/4 by duration)
668
+ --no-brand-kit --brand-kit <id> (see: facelessad brands; not on video-banner)
412
669
  --file body.json (base body; flags override) --dry-run (print, don't send)
413
670
 
671
+ Video Banner flags (silent looping banner — no voice, no script):
672
+ --headline "..." --subline "..." --cta-text "..."
673
+ --text-mode simple|full --photo-query "nordic office"
674
+ --background-image <url> --badge none|random_face --badge-image <url>
675
+ Leave all three texts out and they are written for you from the materials.
676
+
414
677
  --wait stops watching after 6 hours; --timeout <seconds> changes that and
415
678
  --timeout 0 waits with no limit. Stopping the wait never cancels the build.
416
679
  A video in "draft" is not building and is never waited on. For overnight
@@ -419,7 +682,8 @@ runs a webhook beats leaving a terminal open — see ${API}/developers.
419
682
  Accepted spellings: --brand = --brand-name, --color = --brand-color,
420
683
  --voice-id = --voice, --voice-gender = --gender, --no-voice-over = --no-voice,
421
684
  --no-captions = --captions false, --no-music = --music false, -o = --out.
422
- An unknown flag is an error, not something quietly ignored.
685
+ An unknown flag is an error, not something quietly ignored — and so is a
686
+ flag that needs a value but was given without one.
423
687
 
424
688
  Every command accepts --json. FACELESSAD_API_KEY wins over the saved key
425
689
  (use it in CI); long builds are better served by a webhook than --wait —
@@ -543,4 +807,11 @@ if (unknownFlags.length && cmd !== 'help' && cmd !== 'version') {
543
807
  + unknownFlags.join(' ')
544
808
  + '. Run: facelessad help', 'unknown_flag');
545
809
  }
810
+ // 1.2.0: sama portti tunnetuille lipuille jotka jäivät ilman arvoa. Ilman
811
+ // tätä arvoksi jää `true` ja siitä tulee merkkijono "true" — tai luku 1.
812
+ if (missingValues.length && cmd !== 'help' && cmd !== 'version') {
813
+ die('Missing value for ' + missingValues.join(' ')
814
+ + '. ' + (missingValues.length > 1 ? 'These flags need one' : 'That flag needs one')
815
+ + ' — without it the value would be the word "true". Run: facelessad help', 'missing_value');
816
+ }
546
817
  fn().catch((e) => die(e.message || String(e), 'unexpected'));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@facelessad/cli",
3
- "version": "1.1.0",
3
+ "version": "1.2.0",
4
4
  "description": "Create faceless video ads from your terminal or build scripts — the FacelessAd command line.",
5
5
  "license": "MIT",
6
6
  "type": "module",