@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.
- package/README.md +87 -3
- package/index.js +291 -20
- 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`
|
|
88
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
245
|
-
if (flags.sfx !== undefined) body.sfx =
|
|
246
|
-
if (flags.captions !== undefined) body.captions =
|
|
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
|
-
|
|
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
|
-
|
|
315
|
-
|
|
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 + '/
|
|
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
|
|
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
|
|
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
|
-
|
|
386
|
-
|
|
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 --
|
|
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 --
|
|
410
|
-
--
|
|
411
|
-
--
|
|
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'));
|