shotfable 0.1.0a1__tar.gz
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.
- shotfable-0.1.0a1/MANIFEST.in +2 -0
- shotfable-0.1.0a1/PKG-INFO +55 -0
- shotfable-0.1.0a1/README.md +39 -0
- shotfable-0.1.0a1/pyproject.toml +36 -0
- shotfable-0.1.0a1/setup.cfg +4 -0
- shotfable-0.1.0a1/shotfable/SKILL.md +90 -0
- shotfable-0.1.0a1/shotfable/__init__.py +7 -0
- shotfable-0.1.0a1/shotfable/__main__.py +6 -0
- shotfable-0.1.0a1/shotfable/config.py +158 -0
- shotfable-0.1.0a1/shotfable/envelope.py +169 -0
- shotfable-0.1.0a1/shotfable/http.py +282 -0
- shotfable-0.1.0a1/shotfable/main.py +1005 -0
- shotfable-0.1.0a1/shotfable.egg-info/PKG-INFO +55 -0
- shotfable-0.1.0a1/shotfable.egg-info/SOURCES.txt +15 -0
- shotfable-0.1.0a1/shotfable.egg-info/dependency_links.txt +1 -0
- shotfable-0.1.0a1/shotfable.egg-info/entry_points.txt +2 -0
- shotfable-0.1.0a1/shotfable.egg-info/top_level.txt +1 -0
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: shotfable
|
|
3
|
+
Version: 0.1.0a1
|
|
4
|
+
Summary: Official command-line client for the ShotFable API (preview): one sentence in, a finished short film out.
|
|
5
|
+
Author: ShotFable
|
|
6
|
+
License: Proprietary (preview)
|
|
7
|
+
Project-URL: Homepage, https://shotfable.com
|
|
8
|
+
Project-URL: Documentation, https://shotfable.com/docs/cli
|
|
9
|
+
Keywords: shotfable,text-to-video,video,cli,ai
|
|
10
|
+
Classifier: Development Status :: 3 - Alpha
|
|
11
|
+
Classifier: Environment :: Console
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Topic :: Multimedia :: Video
|
|
14
|
+
Requires-Python: >=3.11
|
|
15
|
+
Description-Content-Type: text/markdown
|
|
16
|
+
|
|
17
|
+
# shotfable — ShotFable command-line client (pre-release)
|
|
18
|
+
|
|
19
|
+
> **The production API is not open yet.** `api.shotfable.com` opens later; no API keys are issued
|
|
20
|
+
> and nothing is billed today. Until then, point the CLI at a test environment with
|
|
21
|
+
> `--api-base <URL>` (or `SHOTFABLE_API_BASE=<URL>`).
|
|
22
|
+
>
|
|
23
|
+
> **生产 API 即将开放,目前不发放密钥、不收费。** 现在可以用 `--api-base <URL>`(或环境变量
|
|
24
|
+
> `SHOTFABLE_API_BASE`)指向测试环境试用。
|
|
25
|
+
|
|
26
|
+
ShotFable turns one sentence into a finished short film: consistent characters, stable voices,
|
|
27
|
+
clean video plus subtitles. `shotfable` is its official command-line client: one command per API
|
|
28
|
+
operation, one JSON envelope on stdout, stable exit codes, automatic idempotency keys. Python 3.11+,
|
|
29
|
+
standard library only.
|
|
30
|
+
|
|
31
|
+
## Install
|
|
32
|
+
|
|
33
|
+
```sh
|
|
34
|
+
uv tool install shotfable # or: pipx install shotfable
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## Quick start (against a test environment)
|
|
38
|
+
|
|
39
|
+
```sh
|
|
40
|
+
export SHOTFABLE_API_BASE=https://<test-environment>/v1
|
|
41
|
+
printf '%s' "$KEY" | shotfable auth login # the key is read from stdin, never from argv
|
|
42
|
+
shotfable film quote --idea "A lighthouse keeper's last night on the island"
|
|
43
|
+
shotfable commands # every command → API operation
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Without `--api-base`, commands target `https://api.shotfable.com/v1` and fail with a
|
|
47
|
+
`network_error` envelope until the production API opens.
|
|
48
|
+
|
|
49
|
+
## For agents
|
|
50
|
+
|
|
51
|
+
Every run prints exactly one JSON envelope on stdout; progress events go to stderr. Exit codes,
|
|
52
|
+
recovery rules and the full command list ship inside the package: `shotfable skill path` prints the
|
|
53
|
+
bundled `SKILL.md`.
|
|
54
|
+
|
|
55
|
+
Docs: https://shotfable.com/docs/cli · API reference: https://shotfable.com/docs/api
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# shotfable — ShotFable command-line client (pre-release)
|
|
2
|
+
|
|
3
|
+
> **The production API is not open yet.** `api.shotfable.com` opens later; no API keys are issued
|
|
4
|
+
> and nothing is billed today. Until then, point the CLI at a test environment with
|
|
5
|
+
> `--api-base <URL>` (or `SHOTFABLE_API_BASE=<URL>`).
|
|
6
|
+
>
|
|
7
|
+
> **生产 API 即将开放,目前不发放密钥、不收费。** 现在可以用 `--api-base <URL>`(或环境变量
|
|
8
|
+
> `SHOTFABLE_API_BASE`)指向测试环境试用。
|
|
9
|
+
|
|
10
|
+
ShotFable turns one sentence into a finished short film: consistent characters, stable voices,
|
|
11
|
+
clean video plus subtitles. `shotfable` is its official command-line client: one command per API
|
|
12
|
+
operation, one JSON envelope on stdout, stable exit codes, automatic idempotency keys. Python 3.11+,
|
|
13
|
+
standard library only.
|
|
14
|
+
|
|
15
|
+
## Install
|
|
16
|
+
|
|
17
|
+
```sh
|
|
18
|
+
uv tool install shotfable # or: pipx install shotfable
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## Quick start (against a test environment)
|
|
22
|
+
|
|
23
|
+
```sh
|
|
24
|
+
export SHOTFABLE_API_BASE=https://<test-environment>/v1
|
|
25
|
+
printf '%s' "$KEY" | shotfable auth login # the key is read from stdin, never from argv
|
|
26
|
+
shotfable film quote --idea "A lighthouse keeper's last night on the island"
|
|
27
|
+
shotfable commands # every command → API operation
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
Without `--api-base`, commands target `https://api.shotfable.com/v1` and fail with a
|
|
31
|
+
`network_error` envelope until the production API opens.
|
|
32
|
+
|
|
33
|
+
## For agents
|
|
34
|
+
|
|
35
|
+
Every run prints exactly one JSON envelope on stdout; progress events go to stderr. Exit codes,
|
|
36
|
+
recovery rules and the full command list ship inside the package: `shotfable skill path` prints the
|
|
37
|
+
bundled `SKILL.md`.
|
|
38
|
+
|
|
39
|
+
Docs: https://shotfable.com/docs/cli · API reference: https://shotfable.com/docs/api
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "shotfable"
|
|
7
|
+
dynamic = ["version"]
|
|
8
|
+
description = "Official command-line client for the ShotFable API (preview): one sentence in, a finished short film out."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.11"
|
|
11
|
+
license = { text = "Proprietary (preview)" }
|
|
12
|
+
dependencies = []
|
|
13
|
+
authors = [{ name = "ShotFable" }]
|
|
14
|
+
keywords = ["shotfable", "text-to-video", "video", "cli", "ai"]
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Development Status :: 3 - Alpha",
|
|
17
|
+
"Environment :: Console",
|
|
18
|
+
"Programming Language :: Python :: 3",
|
|
19
|
+
"Topic :: Multimedia :: Video",
|
|
20
|
+
]
|
|
21
|
+
|
|
22
|
+
[project.urls]
|
|
23
|
+
Homepage = "https://shotfable.com"
|
|
24
|
+
Documentation = "https://shotfable.com/docs/cli"
|
|
25
|
+
|
|
26
|
+
[project.scripts]
|
|
27
|
+
shotfable = "shotfable.main:main"
|
|
28
|
+
|
|
29
|
+
[tool.setuptools]
|
|
30
|
+
packages = ["shotfable"]
|
|
31
|
+
|
|
32
|
+
[tool.setuptools.package-data]
|
|
33
|
+
shotfable = ["SKILL.md"]
|
|
34
|
+
|
|
35
|
+
[tool.setuptools.dynamic]
|
|
36
|
+
version = { attr = "shotfable.__version__" }
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: shotfable
|
|
3
|
+
description: Make short films from one sentence with the ShotFable API through the `shotfable` CLI — quote, create, wait, approve checkpoints (humans only), retake shots, and download the clean video + SRT. Use when a user asks to generate a ShotFable film, check a film's status, recover an interrupted run, or call any ShotFable API operation from a terminal.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# ShotFable CLI (`shotfable`)
|
|
7
|
+
|
|
8
|
+
`shotfable` is the official client of the ShotFable API: one command per API operation, the same API key,
|
|
9
|
+
the same Idempotency-Key rules. Locate this file with `shotfable skill path`; list every command with
|
|
10
|
+
`shotfable commands` (machine-readable: command → operationId, method, path, args).
|
|
11
|
+
|
|
12
|
+
## Output contract
|
|
13
|
+
|
|
14
|
+
- **stdout = exactly one JSON envelope.** Never merge stdout and stderr (`2>&1`).
|
|
15
|
+
`{"ok": bool, "code": 0 | "<error_type>", "message": str, "request_id": str|null, "data": {...},
|
|
16
|
+
"error": {"type", "retryable", "user_action_required", "hint"}}` — `error` only when `ok` is false.
|
|
17
|
+
- **stderr = events**, one per line: `[HH:MM:SS] shotfable_event={json}`. Types include
|
|
18
|
+
`idempotency_key`, `film_created`, `film_status`, `film_event`, `job_status`, `file_ready`, `poll_retry`.
|
|
19
|
+
Recovery handles (film id, Idempotency-Key, a `recover`/`retry` command) are written **before** the
|
|
20
|
+
long part starts. If you persist logs, save stderr separately.
|
|
21
|
+
- The envelope is authoritative; stderr events never replace it.
|
|
22
|
+
|
|
23
|
+
## Exit codes
|
|
24
|
+
|
|
25
|
+
| exit | code(s) | what to do |
|
|
26
|
+
|---|---|---|
|
|
27
|
+
| 0 | ok | continue |
|
|
28
|
+
| 1 | internal, film_cancelled | keep `request_id`; do not loop |
|
|
29
|
+
| 2 | invalid_request (also usage errors, Idempotency-Key conflict) | fix the request; unknown fields are rejected |
|
|
30
|
+
| 3 | auth_required | ask the user to set a key (see Auth) |
|
|
31
|
+
| 4 | not_found | check the id (`shotfable film list`) |
|
|
32
|
+
| 5 | policy_violation, review_failed | revise the idea/media with the user; not billed |
|
|
33
|
+
| 6 | budget_exceeded, quota_exceeded, region_not_supported | needs the user (top up, wait, region); `shotfable account` |
|
|
34
|
+
| 7 | upstream_unavailable, network_error | retryable — same command, **same Idempotency-Key** |
|
|
35
|
+
| 8 | wait_timeout | only the local wait stopped; run the `hint` command (`shotfable film wait <id>`) |
|
|
36
|
+
| 9 | checkpoint_waiting | **stop**: a human must review and approve |
|
|
37
|
+
| 130 | interrupted | the backend may still run; run the `hint` command |
|
|
38
|
+
|
|
39
|
+
Rules: `error.retryable=true` → retry/recover the same operation. `error.user_action_required` set →
|
|
40
|
+
stop automatic handling and tell the user exactly that action. Always keep `request_id`.
|
|
41
|
+
|
|
42
|
+
## Typical flow
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
shotfable account # region + credits before spending
|
|
46
|
+
shotfable film quote --idea "…" --duration 60 --style cinematic-real --quality draft
|
|
47
|
+
shotfable film create --idea "…" --duration 60 --style cinematic-real --quality draft \
|
|
48
|
+
--wait --timeout 3600 --download ./out # clean video + SRT (no burned subtitles)
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
- `film create` without `--wait` returns at once (202) with the film id; follow with `shotfable film wait <id>`.
|
|
52
|
+
- Multiple deliveries: repeat `--aspect` (`--aspect 16:9 --aspect 9:16`).
|
|
53
|
+
- Burned-in subtitles are a billed add-on: only pass `--burn-subtitles` when the user asked for it.
|
|
54
|
+
- Voice: `--voice-source auto|expressive|standard` (product aliases; `expressive` is not offered in `cn`).
|
|
55
|
+
- Full request bodies: `--body req.json` or `--body -`; explicit flags override body fields.
|
|
56
|
+
- Atomic stages (`script|storyboard|review|character|keyframes|shot|music|compose|qa create`,
|
|
57
|
+
`voice lines`, `lipsync create`) return a job; add `--wait` or use `shotfable job wait <job_id>`.
|
|
58
|
+
- Anything else: `shotfable api <METHOD> <path> [--body file] [--query k=v]`.
|
|
59
|
+
|
|
60
|
+
## Idempotency and recovery (never create duplicates)
|
|
61
|
+
|
|
62
|
+
- Every write prints its `Idempotency-Key` to stderr **before** sending. On `network_error`, a 5xx, or
|
|
63
|
+
an interruption during a write, the result is unknown: rerun **the same command with
|
|
64
|
+
`--idempotency-key <that key>`** (the `retry` field of the event / the error hint). Same key + same body
|
|
65
|
+
returns the original response; never retry a write with a fresh key.
|
|
66
|
+
- Exactly one process polls a film. While a `--wait` process is alive, do not start a second
|
|
67
|
+
`film wait` or `film events` for the same film; do not re-create the film because it is slow.
|
|
68
|
+
- After exit 8 or 130, run the hint (`shotfable film wait <id>`); after a lost process, `shotfable film get <id>`.
|
|
69
|
+
|
|
70
|
+
## Checkpoints are human decisions
|
|
71
|
+
|
|
72
|
+
With `--mode checkpoint` the film pauses at `storyboard`, `bible` and `final`; the wait exits 9 with
|
|
73
|
+
`data.checkpoint`. Show the user the artifacts (`shotfable film artifacts <id>`), and only after the
|
|
74
|
+
user's explicit decision run `shotfable film approve <id> <checkpoint> [--comment …] --wait`. Never
|
|
75
|
+
approve on the user's behalf, and never treat "the user said continue earlier" as approval of a later checkpoint.
|
|
76
|
+
|
|
77
|
+
## Auth and secrets
|
|
78
|
+
|
|
79
|
+
- Key precedence: `--api-key` > `SHOTFABLE_API_KEY` > stored profile key.
|
|
80
|
+
- Store a key: `printf '%s\n' "$SHOTFABLE_API_KEY" | shotfable auth login` (stdin; avoid `--token` on argv).
|
|
81
|
+
`shotfable auth status` shows only a masked key (`rs_live_…abcd`), region and balance.
|
|
82
|
+
- Keys live in `${XDG_CONFIG_HOME:-~/.config}/shotfable/credentials.json` (0600). **Never print, echo,
|
|
83
|
+
log, or paste the API key**, never put it in a file you create, and never send it anywhere but the
|
|
84
|
+
API base (the CLI refuses to send it to other hosts, including media download hosts).
|
|
85
|
+
|
|
86
|
+
## Downloads
|
|
87
|
+
|
|
88
|
+
`shotfable film download <id> --dir D [--aspect 16:9] [--what video,srt,burned,all]` writes
|
|
89
|
+
`<id>_<aspect>.mp4`, `.srt`, `_burned.mp4`, checks every file is non-empty and matches `sha256` when the
|
|
90
|
+
API provides it, and lists absolute paths in `data.files`. Hand the files to the user once.
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
"""Local state: profiles (config.json) and API keys (credentials.json).
|
|
2
|
+
|
|
3
|
+
Location: ${XDG_CONFIG_HOME:-~/.config}/shotfable/ directory 0700, credentials.json 0600.
|
|
4
|
+
Key precedence: --api-key > SHOTFABLE_API_KEY > the profile's entry in credentials.json.
|
|
5
|
+
Base precedence: --api-base > SHOTFABLE_API_BASE > the profile's api_base > DEFAULT_API_BASE.
|
|
6
|
+
"""
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
import re
|
|
10
|
+
import tempfile
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
from .envelope import CliError
|
|
14
|
+
|
|
15
|
+
DEFAULT_API_BASE = "https://api.shotfable.com/v1" # matches openapi servers[0]; production API not serving yet
|
|
16
|
+
DEFAULT_PROFILE = "default"
|
|
17
|
+
KEY_RE = re.compile(r"^rs_(live|test)_[A-Za-z0-9]{8,128}$")
|
|
18
|
+
PROFILE_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.-]{0,63}$")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def config_dir() -> Path:
|
|
22
|
+
base = os.environ.get("XDG_CONFIG_HOME") or os.path.join(os.path.expanduser("~"), ".config")
|
|
23
|
+
return Path(base) / "shotfable"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def mask_key(key):
|
|
27
|
+
"""rs_live_…abcd — never more than the prefix and the last four characters."""
|
|
28
|
+
if not key:
|
|
29
|
+
return None
|
|
30
|
+
m = re.match(r"^(rs_(?:live|test)_)", key)
|
|
31
|
+
prefix = m.group(1) if m else ""
|
|
32
|
+
tail = key[-4:] if len(key) >= len(prefix) + 8 else ""
|
|
33
|
+
return f"{prefix}…{tail}"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _read_json(path: Path):
|
|
37
|
+
try:
|
|
38
|
+
return json.loads(path.read_text(encoding="utf-8"))
|
|
39
|
+
except FileNotFoundError:
|
|
40
|
+
return {}
|
|
41
|
+
except (OSError, ValueError):
|
|
42
|
+
raise CliError("internal", f"cannot read {path.name} in the shotfable config directory (corrupt JSON?)",
|
|
43
|
+
hint=f"Inspect or remove {path}")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _write_json_private(path: Path, data):
|
|
47
|
+
d = path.parent
|
|
48
|
+
d.mkdir(parents=True, exist_ok=True)
|
|
49
|
+
os.chmod(d, 0o700)
|
|
50
|
+
fd, tmp = tempfile.mkstemp(prefix=f".{path.name}.", dir=str(d))
|
|
51
|
+
try:
|
|
52
|
+
os.fchmod(fd, 0o600)
|
|
53
|
+
with os.fdopen(fd, "w", encoding="utf-8") as fh:
|
|
54
|
+
json.dump(data, fh, indent=2, sort_keys=True)
|
|
55
|
+
fh.write("\n")
|
|
56
|
+
os.replace(tmp, path)
|
|
57
|
+
except BaseException:
|
|
58
|
+
try:
|
|
59
|
+
os.unlink(tmp)
|
|
60
|
+
except OSError:
|
|
61
|
+
pass
|
|
62
|
+
raise
|
|
63
|
+
os.chmod(path, 0o600)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class Store:
|
|
67
|
+
def __init__(self, root: Path = None):
|
|
68
|
+
self.root = root or config_dir()
|
|
69
|
+
self.config_path = self.root / "config.json"
|
|
70
|
+
self.cred_path = self.root / "credentials.json"
|
|
71
|
+
|
|
72
|
+
# ---------------------------------------------------------------- config
|
|
73
|
+
def config(self):
|
|
74
|
+
c = _read_json(self.config_path)
|
|
75
|
+
c.setdefault("current_profile", DEFAULT_PROFILE)
|
|
76
|
+
c.setdefault("profiles", {})
|
|
77
|
+
return c
|
|
78
|
+
|
|
79
|
+
def save_config(self, c):
|
|
80
|
+
_write_json_private(self.config_path, c)
|
|
81
|
+
|
|
82
|
+
def current_profile(self, override=None):
|
|
83
|
+
name = override or os.environ.get("SHOTFABLE_PROFILE") or self.config().get("current_profile") or DEFAULT_PROFILE
|
|
84
|
+
if not PROFILE_RE.match(name):
|
|
85
|
+
raise CliError("invalid_request", f"invalid profile name {name!r}",
|
|
86
|
+
hint="Profile names: letters, digits, '.', '_' or '-' (max 64).")
|
|
87
|
+
return name
|
|
88
|
+
|
|
89
|
+
def profile_settings(self, name):
|
|
90
|
+
return dict(self.config()["profiles"].get(name, {}))
|
|
91
|
+
|
|
92
|
+
def set_profile(self, name, api_base=None):
|
|
93
|
+
c = self.config()
|
|
94
|
+
p = c["profiles"].setdefault(name, {})
|
|
95
|
+
if api_base:
|
|
96
|
+
p["api_base"] = api_base
|
|
97
|
+
self.save_config(c)
|
|
98
|
+
|
|
99
|
+
def use_profile(self, name):
|
|
100
|
+
c = self.config()
|
|
101
|
+
c["current_profile"] = name
|
|
102
|
+
c["profiles"].setdefault(name, {})
|
|
103
|
+
self.save_config(c)
|
|
104
|
+
|
|
105
|
+
def remove_profile(self, name):
|
|
106
|
+
c = self.config()
|
|
107
|
+
existed = c["profiles"].pop(name, None) is not None
|
|
108
|
+
if c.get("current_profile") == name:
|
|
109
|
+
c["current_profile"] = DEFAULT_PROFILE
|
|
110
|
+
self.save_config(c)
|
|
111
|
+
removed_key = self.delete_key(name)
|
|
112
|
+
return existed or removed_key
|
|
113
|
+
|
|
114
|
+
# ---------------------------------------------------------------- credentials
|
|
115
|
+
def stored_key(self, profile):
|
|
116
|
+
if not self.cred_path.exists():
|
|
117
|
+
return None
|
|
118
|
+
return (_read_json(self.cred_path).get("profiles", {}).get(profile) or {}).get("api_key")
|
|
119
|
+
|
|
120
|
+
def save_key(self, profile, key):
|
|
121
|
+
creds = _read_json(self.cred_path) if self.cred_path.exists() else {}
|
|
122
|
+
creds.setdefault("profiles", {})[profile] = {"api_key": key}
|
|
123
|
+
_write_json_private(self.cred_path, creds)
|
|
124
|
+
|
|
125
|
+
def delete_key(self, profile):
|
|
126
|
+
if not self.cred_path.exists():
|
|
127
|
+
return False
|
|
128
|
+
creds = _read_json(self.cred_path)
|
|
129
|
+
existed = creds.get("profiles", {}).pop(profile, None) is not None
|
|
130
|
+
_write_json_private(self.cred_path, creds)
|
|
131
|
+
return existed
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def resolve_api_base(store: Store, profile, flag=None):
|
|
135
|
+
base = flag or os.environ.get("SHOTFABLE_API_BASE") or store.profile_settings(profile).get("api_base") or DEFAULT_API_BASE
|
|
136
|
+
base = base.rstrip("/")
|
|
137
|
+
if not re.match(r"^https?://[^\s/]+", base):
|
|
138
|
+
raise CliError("invalid_request", "api base must be an http(s) URL", hint="e.g. --api-base https://api.shotfable.com/v1")
|
|
139
|
+
return base
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def resolve_api_key(store: Store, profile, flag=None):
|
|
143
|
+
"""Returns (key, source) — source is 'flag' | 'env' | 'profile' | None."""
|
|
144
|
+
if flag:
|
|
145
|
+
return flag, "flag"
|
|
146
|
+
env = os.environ.get("SHOTFABLE_API_KEY")
|
|
147
|
+
if env:
|
|
148
|
+
return env.strip(), "env"
|
|
149
|
+
k = store.stored_key(profile)
|
|
150
|
+
if k:
|
|
151
|
+
return k, "profile"
|
|
152
|
+
return None, None
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def validate_key_shape(key):
|
|
156
|
+
if not KEY_RE.match(key or ""):
|
|
157
|
+
raise CliError("invalid_request", "that does not look like a ShotFable API key (expected rs_live_… or rs_test_…)",
|
|
158
|
+
hint="Paste the key exactly as issued; it is never printed back.")
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
"""Output contract: exactly one JSON envelope on stdout; events and recovery handles on stderr.
|
|
2
|
+
|
|
3
|
+
Envelope:
|
|
4
|
+
{"ok": bool, "code": 0 | "<error_type>", "message": str, "request_id": str | null,
|
|
5
|
+
"data": {...}, "error": {"type", "retryable", "user_action_required", "hint"}} # error only on failure
|
|
6
|
+
Event (stderr, one line): [HH:MM:SS] shotfable_event={json}
|
|
7
|
+
"""
|
|
8
|
+
import json
|
|
9
|
+
import sys
|
|
10
|
+
import time
|
|
11
|
+
|
|
12
|
+
# Stable, documented exit codes. Never renumber; only append.
|
|
13
|
+
EXIT_CODES = {
|
|
14
|
+
"ok": 0,
|
|
15
|
+
"internal": 1,
|
|
16
|
+
"film_cancelled": 1,
|
|
17
|
+
"invalid_request": 2,
|
|
18
|
+
"auth_required": 3,
|
|
19
|
+
"not_found": 4,
|
|
20
|
+
"policy_violation": 5,
|
|
21
|
+
"review_failed": 5,
|
|
22
|
+
"budget_exceeded": 6,
|
|
23
|
+
"quota_exceeded": 6,
|
|
24
|
+
"region_not_supported": 6,
|
|
25
|
+
"upstream_unavailable": 7,
|
|
26
|
+
"network_error": 7,
|
|
27
|
+
"wait_timeout": 8,
|
|
28
|
+
"checkpoint_waiting": 9,
|
|
29
|
+
"interrupted": 130,
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
# The eight server error types (PRODUCT_SPEC §7.1) + the CLI-local ones.
|
|
33
|
+
SERVER_ERROR_TYPES = ("invalid_request", "policy_violation", "review_failed", "budget_exceeded",
|
|
34
|
+
"quota_exceeded", "region_not_supported", "upstream_unavailable", "internal")
|
|
35
|
+
LOCAL_ERROR_TYPES = ("auth_required", "not_found", "network_error", "wait_timeout",
|
|
36
|
+
"checkpoint_waiting", "interrupted", "film_cancelled")
|
|
37
|
+
|
|
38
|
+
RETRYABLE = {"quota_exceeded", "upstream_unavailable", "network_error", "wait_timeout", "interrupted"}
|
|
39
|
+
|
|
40
|
+
USER_ACTION = {
|
|
41
|
+
"auth_required": "provide_api_key",
|
|
42
|
+
"policy_violation": "revise_input",
|
|
43
|
+
"review_failed": "revise_input",
|
|
44
|
+
"budget_exceeded": "top_up_credits",
|
|
45
|
+
"region_not_supported": "change_request_for_region",
|
|
46
|
+
"checkpoint_waiting": "approve_checkpoint",
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
DEFAULT_HINTS = {
|
|
50
|
+
"internal": "Unexpected error. Keep the request_id for support.",
|
|
51
|
+
"invalid_request": "Fix the request (see message); the server runs in strict mode and rejects unknown fields.",
|
|
52
|
+
"auth_required": "Set an API key: `shotfable auth login` (reads the key from stdin), SHOTFABLE_API_KEY, or --api-key.",
|
|
53
|
+
"not_found": "No such resource for this account. List films with `shotfable film list`.",
|
|
54
|
+
"policy_violation": "The input was refused by content policy and was not billed. Change the idea or media and try again.",
|
|
55
|
+
"review_failed": "The script/storyboard did not pass review. Revise the input and create again.",
|
|
56
|
+
"budget_exceeded": "Not enough credits or over budget_cap. Check `shotfable account`, then top up or raise --budget-cap.",
|
|
57
|
+
"quota_exceeded": "Rate or concurrency limit reached. Wait (Retry-After) and retry the same command.",
|
|
58
|
+
"region_not_supported": "Your account region cannot serve this request. Check `shotfable account` for your region.",
|
|
59
|
+
"upstream_unavailable": "Generation is temporarily unavailable; nothing was billed. Retry later with the same Idempotency-Key.",
|
|
60
|
+
"network_error": "Network problem. Reads can simply be retried; for writes the result is unknown — retry with the same Idempotency-Key, never a new one.",
|
|
61
|
+
"wait_timeout": "Only the local wait stopped; the film keeps running. Resume waiting with `shotfable film wait <id>`.",
|
|
62
|
+
"checkpoint_waiting": "A human must review and approve the checkpoint before the film continues.",
|
|
63
|
+
"interrupted": "Interrupted locally; the backend may still be running. Recover with the hint command.",
|
|
64
|
+
"film_cancelled": "The film was cancelled. Create a new film if you still need it.",
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def exit_code_for(code):
|
|
69
|
+
if code == 0:
|
|
70
|
+
return 0
|
|
71
|
+
return EXIT_CODES.get(code, 1)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class CliError(Exception):
|
|
75
|
+
"""Any failure that should become an error envelope."""
|
|
76
|
+
|
|
77
|
+
def __init__(self, code, message, *, hint=None, retryable=None, user_action_required=None,
|
|
78
|
+
request_id=None, data=None):
|
|
79
|
+
super().__init__(message)
|
|
80
|
+
self.code = code
|
|
81
|
+
self.message = message
|
|
82
|
+
self.hint = hint or DEFAULT_HINTS.get(code, DEFAULT_HINTS["internal"])
|
|
83
|
+
self.retryable = (code in RETRYABLE) if retryable is None else retryable
|
|
84
|
+
self.user_action_required = (user_action_required if user_action_required is not None
|
|
85
|
+
else USER_ACTION.get(code))
|
|
86
|
+
self.request_id = request_id
|
|
87
|
+
self.data = data or {}
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def success(data, message="ok", request_id=None):
|
|
91
|
+
return {"ok": True, "code": 0, "message": message, "request_id": request_id, "data": data}
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def failure(err: CliError):
|
|
95
|
+
return {
|
|
96
|
+
"ok": False,
|
|
97
|
+
"code": err.code,
|
|
98
|
+
"message": err.message,
|
|
99
|
+
"request_id": err.request_id,
|
|
100
|
+
"data": err.data,
|
|
101
|
+
"error": {
|
|
102
|
+
"type": err.code,
|
|
103
|
+
"retryable": bool(err.retryable),
|
|
104
|
+
"user_action_required": err.user_action_required,
|
|
105
|
+
"hint": err.hint,
|
|
106
|
+
},
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
class Output:
|
|
111
|
+
"""Owns stdout (one envelope) and stderr (events). Redacts known secrets from everything it writes."""
|
|
112
|
+
|
|
113
|
+
def __init__(self, stdout=None, stderr=None):
|
|
114
|
+
self.stdout = stdout or sys.stdout
|
|
115
|
+
self.stderr = stderr or sys.stderr
|
|
116
|
+
self.pretty = False
|
|
117
|
+
self._secrets = []
|
|
118
|
+
self._emitted = False
|
|
119
|
+
|
|
120
|
+
def add_secret(self, value):
|
|
121
|
+
if value and value not in self._secrets:
|
|
122
|
+
self._secrets.append(value)
|
|
123
|
+
|
|
124
|
+
def redact(self, text):
|
|
125
|
+
for s in self._secrets:
|
|
126
|
+
if s and s in text:
|
|
127
|
+
text = text.replace(s, "[redacted]")
|
|
128
|
+
return text
|
|
129
|
+
|
|
130
|
+
def event(self, kind, **fields):
|
|
131
|
+
payload = {"type": kind, **fields}
|
|
132
|
+
line = (f"[{time.strftime('%H:%M:%S')}] shotfable_event="
|
|
133
|
+
+ json.dumps(payload, ensure_ascii=False, separators=(",", ":")))
|
|
134
|
+
self._err(line)
|
|
135
|
+
|
|
136
|
+
def note(self, text):
|
|
137
|
+
"""Free-text stderr line for humans (warnings). Never parsed."""
|
|
138
|
+
self._err(f"[{time.strftime('%H:%M:%S')}] {text}")
|
|
139
|
+
|
|
140
|
+
def _err(self, line):
|
|
141
|
+
try:
|
|
142
|
+
self.stderr.write(self.redact(line) + "\n")
|
|
143
|
+
self.stderr.flush()
|
|
144
|
+
except (BrokenPipeError, ValueError):
|
|
145
|
+
pass
|
|
146
|
+
|
|
147
|
+
@property
|
|
148
|
+
def emitted(self):
|
|
149
|
+
return self._emitted
|
|
150
|
+
|
|
151
|
+
def envelope(self, env):
|
|
152
|
+
if self._emitted: # the contract: exactly one envelope per invocation
|
|
153
|
+
return
|
|
154
|
+
self._emitted = True
|
|
155
|
+
text = json.dumps(env, ensure_ascii=False, indent=2 if self.pretty else None)
|
|
156
|
+
self._out(text)
|
|
157
|
+
|
|
158
|
+
def text(self, body):
|
|
159
|
+
if self._emitted:
|
|
160
|
+
return
|
|
161
|
+
self._emitted = True
|
|
162
|
+
self._out(body.rstrip("\n"))
|
|
163
|
+
|
|
164
|
+
def _out(self, text):
|
|
165
|
+
try:
|
|
166
|
+
self.stdout.write(self.redact(text) + "\n")
|
|
167
|
+
self.stdout.flush()
|
|
168
|
+
except BrokenPipeError:
|
|
169
|
+
pass
|