@remixmate/cli 0.9.14 → 0.9.15
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/dist/billing.d.ts +44 -0
- package/dist/billing.js +76 -0
- package/dist/http.d.ts +3 -0
- package/dist/http.js +4 -0
- package/dist/manifest.json +2 -2
- package/dist/runner.js +9 -0
- package/package.json +1 -1
- package/skills/gen-digital-human/SKILL.md +12 -0
- package/skills/gen-image/SKILL.md +12 -0
- package/skills/gen-video/SKILL.md +12 -0
- package/skills/gen-voice/SKILL.md +12 -0
- package/skills/prepare-video-assets/SKILL.md +12 -0
- package/skills/render-video/SKILL.md +12 -0
- package/skills/render-video/scripts/render_video.py +63 -0
- package/skills/template-registry/scripts/render_job_client.py +27 -0
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Billing capture — makes credit consumption visible to whoever ran the skill.
|
|
3
|
+
*
|
|
4
|
+
* Credits are deducted server-side and used to be invisible here: a run printed
|
|
5
|
+
* its image/video URL and nothing else, so the first time a user noticed the
|
|
6
|
+
* credit system at all was the `insufficient_credits` error after the balance
|
|
7
|
+
* had already run out. ab-api now attaches a `billing` object to the envelope of
|
|
8
|
+
* every response that charged something (see core.Success / credits_billing.go);
|
|
9
|
+
* this module accumulates those across all the calls a single skill run makes —
|
|
10
|
+
* a gen-image run polls, a gen-video run polls, and each may charge once — and
|
|
11
|
+
* renders one footer at the end.
|
|
12
|
+
*
|
|
13
|
+
* Two outputs, two audiences, both on stdout:
|
|
14
|
+
* - a human-readable footer, which is what the agent relays to the user.
|
|
15
|
+
* - one `__progress__` line (phase `billing`) — the existing machine protocol,
|
|
16
|
+
* already parsed by ab-agent's executor and skipped by the line-scanning
|
|
17
|
+
* parsers in render_video.py, so a nested pipeline caller can aggregate a
|
|
18
|
+
* whole run's spend without a new stdout contract.
|
|
19
|
+
*
|
|
20
|
+
* Both go to stdout rather than stderr (where the auth footer lives) because
|
|
21
|
+
* ab-agent hands the LLM `result.stdout || result.stderr` (mcp-tools.ts) — a
|
|
22
|
+
* stderr-only footer would be invisible in the hosted agent, visible only when
|
|
23
|
+
* a local host like codex shows both streams. Adding stdout lines is safe:
|
|
24
|
+
* emitProgress already writes NDJSON there, so no consumer can be treating
|
|
25
|
+
* stdout as a single JSON document.
|
|
26
|
+
*/
|
|
27
|
+
export interface BillingItem {
|
|
28
|
+
credits: number;
|
|
29
|
+
bizType?: string;
|
|
30
|
+
detail?: string;
|
|
31
|
+
}
|
|
32
|
+
/** The `billing` object ab-api attaches to a charged response. */
|
|
33
|
+
export interface Billing {
|
|
34
|
+
credits: number;
|
|
35
|
+
balance: number;
|
|
36
|
+
items?: BillingItem[];
|
|
37
|
+
}
|
|
38
|
+
/** Accumulate one response's billing object; no-ops on responses that charged nothing. */
|
|
39
|
+
export declare function recordBilling(billing: Billing | undefined | null): void;
|
|
40
|
+
/** Everything charged so far in this process, or null when nothing was. */
|
|
41
|
+
export declare function billingSummary(): Billing | null;
|
|
42
|
+
export declare function emitBillingFooter(): void;
|
|
43
|
+
/** Test seam — resets the accumulator between runs in-process. */
|
|
44
|
+
export declare function resetBilling(): void;
|
package/dist/billing.js
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Billing capture — makes credit consumption visible to whoever ran the skill.
|
|
3
|
+
*
|
|
4
|
+
* Credits are deducted server-side and used to be invisible here: a run printed
|
|
5
|
+
* its image/video URL and nothing else, so the first time a user noticed the
|
|
6
|
+
* credit system at all was the `insufficient_credits` error after the balance
|
|
7
|
+
* had already run out. ab-api now attaches a `billing` object to the envelope of
|
|
8
|
+
* every response that charged something (see core.Success / credits_billing.go);
|
|
9
|
+
* this module accumulates those across all the calls a single skill run makes —
|
|
10
|
+
* a gen-image run polls, a gen-video run polls, and each may charge once — and
|
|
11
|
+
* renders one footer at the end.
|
|
12
|
+
*
|
|
13
|
+
* Two outputs, two audiences, both on stdout:
|
|
14
|
+
* - a human-readable footer, which is what the agent relays to the user.
|
|
15
|
+
* - one `__progress__` line (phase `billing`) — the existing machine protocol,
|
|
16
|
+
* already parsed by ab-agent's executor and skipped by the line-scanning
|
|
17
|
+
* parsers in render_video.py, so a nested pipeline caller can aggregate a
|
|
18
|
+
* whole run's spend without a new stdout contract.
|
|
19
|
+
*
|
|
20
|
+
* Both go to stdout rather than stderr (where the auth footer lives) because
|
|
21
|
+
* ab-agent hands the LLM `result.stdout || result.stderr` (mcp-tools.ts) — a
|
|
22
|
+
* stderr-only footer would be invisible in the hosted agent, visible only when
|
|
23
|
+
* a local host like codex shows both streams. Adding stdout lines is safe:
|
|
24
|
+
* emitProgress already writes NDJSON there, so no consumer can be treating
|
|
25
|
+
* stdout as a single JSON document.
|
|
26
|
+
*/
|
|
27
|
+
import { emitProgress } from './progress.js';
|
|
28
|
+
let creditsCharged = 0;
|
|
29
|
+
let latestBalance = null;
|
|
30
|
+
const chargedItems = [];
|
|
31
|
+
/** Accumulate one response's billing object; no-ops on responses that charged nothing. */
|
|
32
|
+
export function recordBilling(billing) {
|
|
33
|
+
if (!billing || typeof billing.credits !== 'number' || billing.credits <= 0)
|
|
34
|
+
return;
|
|
35
|
+
creditsCharged += billing.credits;
|
|
36
|
+
if (typeof billing.balance === 'number')
|
|
37
|
+
latestBalance = billing.balance;
|
|
38
|
+
for (const item of billing.items ?? []) {
|
|
39
|
+
if (item && typeof item.credits === 'number' && item.credits > 0)
|
|
40
|
+
chargedItems.push(item);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
/** Everything charged so far in this process, or null when nothing was. */
|
|
44
|
+
export function billingSummary() {
|
|
45
|
+
if (creditsCharged <= 0)
|
|
46
|
+
return null;
|
|
47
|
+
return { credits: creditsCharged, balance: latestBalance ?? 0, items: [...chargedItems] };
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Emit the footer for what this process charged. Safe to call on the error path
|
|
51
|
+
* too: a run can fail after a successful (already billed) generation step, and
|
|
52
|
+
* that spend still needs to be reported.
|
|
53
|
+
*
|
|
54
|
+
* Idempotent — only the first call prints, so a handler and the dispatcher can
|
|
55
|
+
* both reach for it without doubling the number the user sees.
|
|
56
|
+
*/
|
|
57
|
+
let emitted = false;
|
|
58
|
+
export function emitBillingFooter() {
|
|
59
|
+
if (emitted)
|
|
60
|
+
return;
|
|
61
|
+
const summary = billingSummary();
|
|
62
|
+
if (!summary)
|
|
63
|
+
return;
|
|
64
|
+
emitted = true;
|
|
65
|
+
emitProgress({ phase: 'billing', credits: summary.credits, balance: summary.balance, items: summary.items });
|
|
66
|
+
const balance = summary.balance.toLocaleString('en-US');
|
|
67
|
+
const credits = summary.credits.toLocaleString('en-US');
|
|
68
|
+
process.stdout.write(`\n💳 Charged ${credits} credits · balance ${balance}\n`);
|
|
69
|
+
}
|
|
70
|
+
/** Test seam — resets the accumulator between runs in-process. */
|
|
71
|
+
export function resetBilling() {
|
|
72
|
+
creditsCharged = 0;
|
|
73
|
+
latestBalance = null;
|
|
74
|
+
chargedItems.length = 0;
|
|
75
|
+
emitted = false;
|
|
76
|
+
}
|
package/dist/http.d.ts
CHANGED
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
* 3. process.env.MM_BACKEND_API_URL — ab-agent's convention (back-compat)
|
|
19
19
|
* 4. https://api.remixmate.com/api — production default (zero-config)
|
|
20
20
|
*/
|
|
21
|
+
import { type Billing } from './billing.js';
|
|
21
22
|
export { SkillError, EXIT } from './errors.js';
|
|
22
23
|
/**
|
|
23
24
|
* Resolve the ab-api base URL the same way for authenticated and device-flow calls.
|
|
@@ -49,6 +50,8 @@ export interface MmResponse<T = unknown> {
|
|
|
49
50
|
code: number;
|
|
50
51
|
msg?: string;
|
|
51
52
|
data?: T;
|
|
53
|
+
/** Present only on responses that charged credits — see billing.ts. */
|
|
54
|
+
billing?: Billing;
|
|
52
55
|
}
|
|
53
56
|
/**
|
|
54
57
|
* POST JSON to ab-api and return the parsed business payload.
|
package/dist/http.js
CHANGED
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
* 3. process.env.MM_BACKEND_API_URL — ab-agent's convention (back-compat)
|
|
19
19
|
* 4. https://api.remixmate.com/api — production default (zero-config)
|
|
20
20
|
*/
|
|
21
|
+
import { recordBilling } from './billing.js';
|
|
21
22
|
import { resolvePrivToken, NOT_AUTHENTICATED_HINT } from './auth/resolve.js';
|
|
22
23
|
import { attemptAutoLogin } from './auth/auto-login.js';
|
|
23
24
|
import { EXIT, SkillError } from './errors.js';
|
|
@@ -120,6 +121,9 @@ export async function mmPost(ctx, pathOrUrl, body, opts = {}) {
|
|
|
120
121
|
catch {
|
|
121
122
|
throw new SkillError(`❌ failed to parse response, body is not JSON: ${text.slice(0, 200)}`);
|
|
122
123
|
}
|
|
124
|
+
// Before the code check: a charged response is always code=0 today, but a
|
|
125
|
+
// partial-failure envelope that still billed must not lose its billing line.
|
|
126
|
+
recordBilling(parsed.billing);
|
|
123
127
|
if (parsed.code !== 0) {
|
|
124
128
|
// ab-api reports auth failures in the envelope (HTTP 200 + code=401), so the
|
|
125
129
|
// business-code path needs the same 401 → "re-authorize" mapping as above.
|
package/dist/manifest.json
CHANGED
package/dist/runner.js
CHANGED
|
@@ -15,6 +15,7 @@ import { findSkill, SKILLS_DIR } from './registry.js';
|
|
|
15
15
|
import { HANDLERS } from './handlers/index.js';
|
|
16
16
|
import { EXIT, SkillError } from './errors.js';
|
|
17
17
|
import { authChildEnv, ensureAuth } from './auth/ensure.js';
|
|
18
|
+
import { emitBillingFooter } from './billing.js';
|
|
18
19
|
/** Read the token override accepted by both the TS handlers and the Python skills. */
|
|
19
20
|
function tokenFlag(args) {
|
|
20
21
|
const value = args.token ?? args.priv_token;
|
|
@@ -34,6 +35,8 @@ export async function runSkill(skillName, opts) {
|
|
|
34
35
|
});
|
|
35
36
|
switch (skill.entry.type) {
|
|
36
37
|
case 'python':
|
|
38
|
+
// Python children talk to ab-api themselves and print their own billing
|
|
39
|
+
// footer; nothing was charged through this process.
|
|
37
40
|
return await runPython(skill, opts.rawArgs, auth);
|
|
38
41
|
case 'http':
|
|
39
42
|
case 'builtin':
|
|
@@ -48,6 +51,12 @@ export async function runSkill(skillName, opts) {
|
|
|
48
51
|
process.stderr.write(`❌ unexpected error: ${err.message}\n`);
|
|
49
52
|
return EXIT.ERROR;
|
|
50
53
|
}
|
|
54
|
+
finally {
|
|
55
|
+
// In `finally` because a run can fail *after* a billed step (e.g. the image
|
|
56
|
+
// generated and was charged, then the upload timed out) — spend gets
|
|
57
|
+
// reported either way. No-ops when nothing was charged.
|
|
58
|
+
emitBillingFooter();
|
|
59
|
+
}
|
|
51
60
|
}
|
|
52
61
|
async function runPython(skill, rawArgs, auth) {
|
|
53
62
|
if (!skill.scriptAbsolutePath) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@remixmate/cli",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.15",
|
|
4
4
|
"description": "AI media generation skills for Claude Code / Codex — 12 skills covering image, video, voice, digital human, web screenshot, web recording, script, template registry, rendering, Jianying export, and video deconstruction.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -175,6 +175,18 @@ remixmate gen-digital-human --check-status --generation-id 123
|
|
|
175
175
|
- Keep individual jobs under ~500 characters.
|
|
176
176
|
- Tone and style of the script affect the perceived voice.
|
|
177
177
|
|
|
178
|
+
## Credits
|
|
179
|
+
|
|
180
|
+
Every run charges credits. The CLI prints a footer on stdout when it does:
|
|
181
|
+
|
|
182
|
+
```
|
|
183
|
+
💳 Charged 31 credits · balance 1,240
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
Relay it to the user whenever it appears — it is the only signal they get about what a
|
|
187
|
+
generation cost, and the balance is the only warning before a run fails with
|
|
188
|
+
`insufficient_credits`. Do not drop it from your summary.
|
|
189
|
+
|
|
178
190
|
## Error handling
|
|
179
191
|
|
|
180
192
|
- **401** / **token missing** (non-OpenClaw): set `PRIV_TOKEN`.
|
|
@@ -122,6 +122,18 @@ remixmate gen-image \
|
|
|
122
122
|
| `--api-base-url` | Override API root | see above |
|
|
123
123
|
| `--priv-token` | Override token | see above |
|
|
124
124
|
|
|
125
|
+
## Credits
|
|
126
|
+
|
|
127
|
+
Every run charges credits. The CLI prints a footer on stdout when it does:
|
|
128
|
+
|
|
129
|
+
```
|
|
130
|
+
💳 Charged 31 credits · balance 1,240
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
Relay it to the user whenever it appears — it is the only signal they get about what a
|
|
134
|
+
generation cost, and the balance is the only warning before a run fails with
|
|
135
|
+
`insufficient_credits`. Do not drop it from your summary.
|
|
136
|
+
|
|
125
137
|
## Error handling
|
|
126
138
|
|
|
127
139
|
- **401** / **token missing** (non-OpenClaw): set `PRIV_TOKEN`.
|
|
@@ -150,6 +150,18 @@ remixmate gen-video \
|
|
|
150
150
|
- Describe motion explicitly.
|
|
151
151
|
- Example: `"At sunrise, an aerial drone shot of a futuristic city, golden light on glass facades, mist swirling, 4K ultra-clear"`.
|
|
152
152
|
|
|
153
|
+
## Credits
|
|
154
|
+
|
|
155
|
+
Every run charges credits. The CLI prints a footer on stdout when it does:
|
|
156
|
+
|
|
157
|
+
```
|
|
158
|
+
💳 Charged 31 credits · balance 1,240
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
Relay it to the user whenever it appears — it is the only signal they get about what a
|
|
162
|
+
generation cost, and the balance is the only warning before a run fails with
|
|
163
|
+
`insufficient_credits`. Do not drop it from your summary.
|
|
164
|
+
|
|
153
165
|
## Error handling
|
|
154
166
|
|
|
155
167
|
- **401** / **token missing** (non-OpenClaw): set `PRIV_TOKEN`.
|
|
@@ -95,6 +95,18 @@ remixmate gen-voice --text "<text-to-synthesize>" --json-output
|
|
|
95
95
|
| Voice-over / narration | 1.0–1.2 |
|
|
96
96
|
| Fast announcement | 1.2–1.5 |
|
|
97
97
|
|
|
98
|
+
## Credits
|
|
99
|
+
|
|
100
|
+
Every run charges credits. The CLI prints a footer on stdout when it does:
|
|
101
|
+
|
|
102
|
+
```
|
|
103
|
+
💳 Charged 31 credits · balance 1,240
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
Relay it to the user whenever it appears — it is the only signal they get about what a
|
|
107
|
+
generation cost, and the balance is the only warning before a run fails with
|
|
108
|
+
`insufficient_credits`. Do not drop it from your summary.
|
|
109
|
+
|
|
98
110
|
## Error handling
|
|
99
111
|
|
|
100
112
|
- **401** / **token missing** (non-OpenClaw): set `PRIV_TOKEN`.
|
|
@@ -195,6 +195,18 @@ The Asset Resolver handles each `AssetRef` in this order:
|
|
|
195
195
|
4. **Parallel generation**: assets of the same type are generated in parallel; different types are sequenced by dependency.
|
|
196
196
|
5. **Cache reuse**: assets with the same payload are checked against `asset-cache-dir` to avoid duplicate generation.
|
|
197
197
|
|
|
198
|
+
## Credits
|
|
199
|
+
|
|
200
|
+
Every run charges credits. The CLI prints a footer on stdout when it does:
|
|
201
|
+
|
|
202
|
+
```
|
|
203
|
+
💳 Charged 31 credits · balance 1,240
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
Relay it to the user whenever it appears — it is the only signal they get about what a
|
|
207
|
+
generation cost, and the balance is the only warning before a run fails with
|
|
208
|
+
`insufficient_credits`. Do not drop it from your summary.
|
|
209
|
+
|
|
198
210
|
## Error handling
|
|
199
211
|
|
|
200
212
|
- **DSL validation failed**: pre-validate with `gen-script --validate`.
|
|
@@ -210,6 +210,18 @@ The Remotion render project lives at the monorepo root under `<monorepo-root>/re
|
|
|
210
210
|
- Production (default): the client uses `https://api-render.remixmate.com` when `REMOTION_RENDER_API_URL` is unset — no config needed.
|
|
211
211
|
- Local debugging: in the renderer directory run `npm install && npm run server:dev`, then point the client at it with `REMOTION_RENDER_API_URL=http://localhost:3000`.
|
|
212
212
|
|
|
213
|
+
## Credits
|
|
214
|
+
|
|
215
|
+
Every run charges credits. The CLI prints a footer on stdout when it does:
|
|
216
|
+
|
|
217
|
+
```
|
|
218
|
+
💳 Charged 31 credits · balance 1,240
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
Relay it to the user whenever it appears — it is the only signal they get about what a
|
|
222
|
+
generation cost, and the balance is the only warning before a run fails with
|
|
223
|
+
`insufficient_credits`. Do not drop it from your summary.
|
|
224
|
+
|
|
213
225
|
## Error handling
|
|
214
226
|
|
|
215
227
|
- **`job_id` missing or not assets-ready**: the script exits non-zero with a message like "render job N is not in assets-ready state". The agent should call `prepare_video_assets` again with the updated DSL to regenerate, then re-call this skill with the new `job_id`.
|
|
@@ -24,6 +24,7 @@
|
|
|
24
24
|
"""
|
|
25
25
|
|
|
26
26
|
import argparse
|
|
27
|
+
import atexit
|
|
27
28
|
import builtins
|
|
28
29
|
import json
|
|
29
30
|
import os
|
|
@@ -356,6 +357,62 @@ def LogPrint(*args, sep=" ", end="\n", file=None, flush=False):
|
|
|
356
357
|
builtins.print(f"[{stamp}] {message}", end=end, file=file, flush=flush)
|
|
357
358
|
|
|
358
359
|
|
|
360
|
+
# ─── 扣费汇总 ────────────────────────────────────────────────────────────────
|
|
361
|
+
# 一条视频管线的花费分散在多处:每个 gen-image / gen-voice / gen-video /
|
|
362
|
+
# gen-digital-human 子进程各扣一次,渲染再通过 saveManifest 扣一次。子进程是
|
|
363
|
+
# capture_output 起的,它们自己打的 💳 页脚会被吞掉,所以这里把子进程 stdout 里的
|
|
364
|
+
# billing 事件抓出来累加,最后统一报一次总账——否则用户跑完一整条管线,只知道视频
|
|
365
|
+
# 好了,不知道花了多少积分。
|
|
366
|
+
_BILLING: dict = {"credits": 0, "balance": None}
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
def absorb_child_billing(stdout: str) -> None:
|
|
370
|
+
"""从子 skill 的 stdout 里收集 `__progress__` billing 事件(见 CLI src/billing.ts)。"""
|
|
371
|
+
for line in (stdout or "").splitlines():
|
|
372
|
+
line = line.strip()
|
|
373
|
+
if not line.startswith("{") or "__progress__" not in line:
|
|
374
|
+
continue
|
|
375
|
+
try:
|
|
376
|
+
event = json.loads(line)
|
|
377
|
+
except json.JSONDecodeError:
|
|
378
|
+
continue
|
|
379
|
+
if not isinstance(event, dict) or event.get("phase") != "billing":
|
|
380
|
+
continue
|
|
381
|
+
credits = event.get("credits")
|
|
382
|
+
if not isinstance(credits, (int, float)) or credits <= 0:
|
|
383
|
+
continue
|
|
384
|
+
_BILLING["credits"] += int(credits)
|
|
385
|
+
balance = event.get("balance")
|
|
386
|
+
if isinstance(balance, (int, float)):
|
|
387
|
+
_BILLING["balance"] = int(balance)
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
def print_billing_footer() -> None:
|
|
391
|
+
"""收尾时报一次总账。通过 atexit 注册,所以 sys.exit / 中途失败也会打印
|
|
392
|
+
——已经发生的扣费不该因为后面某一步失败就不告知用户。
|
|
393
|
+
|
|
394
|
+
走 stdout 而不是 stderr:ab-agent 交给 LLM 的是 `result.stdout || result.stderr`
|
|
395
|
+
(mcp-tools.ts),只写 stderr 在托管 agent 里等于不可见。
|
|
396
|
+
"""
|
|
397
|
+
credits = _BILLING["credits"]
|
|
398
|
+
balance = _BILLING["balance"]
|
|
399
|
+
try:
|
|
400
|
+
import render_job_client # type: ignore
|
|
401
|
+
|
|
402
|
+
job_billing = render_job_client.billing_summary()
|
|
403
|
+
if job_billing.get("credits"):
|
|
404
|
+
credits += int(job_billing["credits"])
|
|
405
|
+
# saveManifest(渲染扣费)在管线里排最后,它带回的余额最新。
|
|
406
|
+
if job_billing.get("balance") is not None:
|
|
407
|
+
balance = int(job_billing["balance"])
|
|
408
|
+
except Exception: # noqa: BLE001 — 报账失败绝不能影响已完成的渲染
|
|
409
|
+
pass
|
|
410
|
+
if credits <= 0:
|
|
411
|
+
return
|
|
412
|
+
suffix = f" · balance {balance:,}" if balance is not None else ""
|
|
413
|
+
builtins.print(f"\n💳 Charged {credits:,} credits{suffix}", flush=True)
|
|
414
|
+
|
|
415
|
+
|
|
359
416
|
def sync_chrome_headless_vendor(renderer_dir: str, render_plan: dict) -> None:
|
|
360
417
|
"""Chrome Headless vendor 同步——实现已抽到独立模块 ``_chrome_vendor``。
|
|
361
418
|
|
|
@@ -758,6 +815,7 @@ def resolve_asset_image(asset: dict, private_token: str, timeout: int) -> dict:
|
|
|
758
815
|
|
|
759
816
|
try:
|
|
760
817
|
result = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout, stdin=subprocess.DEVNULL)
|
|
818
|
+
absorb_child_billing(result.stdout)
|
|
761
819
|
if result.returncode == 0:
|
|
762
820
|
for line in reversed(result.stdout.strip().split("\n")):
|
|
763
821
|
line = line.strip()
|
|
@@ -809,6 +867,7 @@ def resolve_asset_audio(asset: dict, private_token: str, timeout: int) -> dict:
|
|
|
809
867
|
|
|
810
868
|
try:
|
|
811
869
|
result = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout, stdin=subprocess.DEVNULL)
|
|
870
|
+
absorb_child_billing(result.stdout)
|
|
812
871
|
if result.returncode == 0:
|
|
813
872
|
for line in reversed(result.stdout.strip().split("\n")):
|
|
814
873
|
line = line.strip()
|
|
@@ -854,6 +913,7 @@ def resolve_asset_video(asset: dict, private_token: str, timeout: int) -> dict:
|
|
|
854
913
|
|
|
855
914
|
try:
|
|
856
915
|
result = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout, stdin=subprocess.DEVNULL)
|
|
916
|
+
absorb_child_billing(result.stdout)
|
|
857
917
|
if result.returncode == 0:
|
|
858
918
|
for line in reversed(result.stdout.strip().split("\n")):
|
|
859
919
|
line = line.strip()
|
|
@@ -893,6 +953,7 @@ def resolve_asset_digital_human(asset: dict, private_token: str, timeout: int) -
|
|
|
893
953
|
dh_timeout = max(timeout, 660)
|
|
894
954
|
try:
|
|
895
955
|
result = subprocess.run(cmd, capture_output=True, text=True, timeout=dh_timeout, stdin=subprocess.DEVNULL)
|
|
956
|
+
absorb_child_billing(result.stdout)
|
|
896
957
|
if result.returncode == 0:
|
|
897
958
|
for line in reversed(result.stdout.strip().split("\n")):
|
|
898
959
|
line = line.strip()
|
|
@@ -1892,6 +1953,8 @@ def _log_render_plan_summary(render_plan: dict) -> None:
|
|
|
1892
1953
|
|
|
1893
1954
|
|
|
1894
1955
|
def main():
|
|
1956
|
+
# 无论正常结束、sys.exit 还是中途失败,都在最后报一次积分账(无扣费时静默)。
|
|
1957
|
+
atexit.register(print_billing_footer)
|
|
1895
1958
|
parser = argparse.ArgumentParser(
|
|
1896
1959
|
description="Video render tool — DSL + TemplateBinding → Remotion video.",
|
|
1897
1960
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
@@ -37,6 +37,31 @@ def _make_headers(priv_token: str) -> dict:
|
|
|
37
37
|
return headers
|
|
38
38
|
|
|
39
39
|
|
|
40
|
+
# ─── 扣费回传 ────────────────────────────────────────────────────────────────
|
|
41
|
+
# ab-api 会在扣了积分的成功响应上挂 billing 字段(见 core.Success)。渲染积分是在
|
|
42
|
+
# /renderJob/saveManifest 里扣的,所以这里是渲染花费唯一的可见入口——累加起来交给
|
|
43
|
+
# render_video.py 在收尾时统一告知用户。
|
|
44
|
+
_BILLING: dict = {"credits": 0, "balance": None}
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _record_billing(result: dict) -> None:
|
|
48
|
+
billing = result.get("billing")
|
|
49
|
+
if not isinstance(billing, dict):
|
|
50
|
+
return
|
|
51
|
+
credits = billing.get("credits")
|
|
52
|
+
if not isinstance(credits, (int, float)) or credits <= 0:
|
|
53
|
+
return
|
|
54
|
+
_BILLING["credits"] += int(credits)
|
|
55
|
+
balance = billing.get("balance")
|
|
56
|
+
if isinstance(balance, (int, float)):
|
|
57
|
+
_BILLING["balance"] = int(balance)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def billing_summary() -> dict:
|
|
61
|
+
"""本进程通过本模块产生的累计扣费:{"credits": int, "balance": int|None}。"""
|
|
62
|
+
return dict(_BILLING)
|
|
63
|
+
|
|
64
|
+
|
|
40
65
|
def _post(path: str, payload: dict, priv_token: str, timeout: int = 30) -> dict:
|
|
41
66
|
"""发送 POST 请求并返回响应 data 字段;失败时抛出 RuntimeError。"""
|
|
42
67
|
url = f"{_api_base()}{path}"
|
|
@@ -50,6 +75,8 @@ def _post(path: str, payload: dict, priv_token: str, timeout: int = 30) -> dict:
|
|
|
50
75
|
except urllib.error.URLError as e:
|
|
51
76
|
raise RuntimeError(f"network request failed ({path}): {e.reason}") from e
|
|
52
77
|
|
|
78
|
+
_record_billing(result)
|
|
79
|
+
|
|
53
80
|
if result.get("code") != 0:
|
|
54
81
|
msg = result.get("msg") or result.get("message") or "unknown error"
|
|
55
82
|
raise RuntimeError(f"API returned an error [{path}]: {msg}")
|