spacepilot 2.8.0__py3-none-any.whl

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 (207) hide show
  1. spacepilot/__init__.py +3 -0
  2. spacepilot/api/__init__.py +1 -0
  3. spacepilot/api/deps.py +50 -0
  4. spacepilot/api/routes/__init__.py +37 -0
  5. spacepilot/api/routes/assets.py +255 -0
  6. spacepilot/api/routes/audio.py +368 -0
  7. spacepilot/api/routes/billing.py +117 -0
  8. spacepilot/api/routes/checkpoints.py +52 -0
  9. spacepilot/api/routes/compute.py +79 -0
  10. spacepilot/api/routes/engines.py +70 -0
  11. spacepilot/api/routes/generate.py +622 -0
  12. spacepilot/api/routes/gpu.py +468 -0
  13. spacepilot/api/routes/health.py +53 -0
  14. spacepilot/api/routes/inference.py +597 -0
  15. spacepilot/api/routes/lora.py +67 -0
  16. spacepilot/api/routes/measurements.py +46 -0
  17. spacepilot/api/routes/recipes.py +45 -0
  18. spacepilot/api/routes/runtimes.py +152 -0
  19. spacepilot/api/routes/storyboard.py +69 -0
  20. spacepilot/api/routes/views.py +73 -0
  21. spacepilot/api/security.py +121 -0
  22. spacepilot/app.py +144 -0
  23. spacepilot/cli.py +2276 -0
  24. spacepilot/core/__init__.py +5 -0
  25. spacepilot/core/config.py +121 -0
  26. spacepilot/core/http.py +33 -0
  27. spacepilot/core/utils.py +76 -0
  28. spacepilot/daemon/__init__.py +6 -0
  29. spacepilot/daemon/__main__.py +26 -0
  30. spacepilot/daemon/api.py +291 -0
  31. spacepilot/daemon/fleet.py +506 -0
  32. spacepilot/daemon/identity.py +475 -0
  33. spacepilot/daemon/index.py +344 -0
  34. spacepilot/daemon/log.py +697 -0
  35. spacepilot/daemon/orders.py +761 -0
  36. spacepilot/daemon/peer_auth.py +338 -0
  37. spacepilot/daemon/picture.py +91 -0
  38. spacepilot/daemon/runtime.py +404 -0
  39. spacepilot/daemon/server.py +479 -0
  40. spacepilot/daemon/service.py +302 -0
  41. spacepilot/daemon/tailscale.py +164 -0
  42. spacepilot/device_probe.py +806 -0
  43. spacepilot/drivers/__init__.py +12 -0
  44. spacepilot/drivers/base.py +111 -0
  45. spacepilot/drivers/gguf_driver.py +235 -0
  46. spacepilot/drivers/kokoro_driver.py +340 -0
  47. spacepilot/drivers/kokoro_runner.py +105 -0
  48. spacepilot/drivers/local_voice_engine.py +115 -0
  49. spacepilot/drivers/mflux_driver.py +287 -0
  50. spacepilot/drivers/mlx_embed_driver.py +137 -0
  51. spacepilot/drivers/mlx_embed_runner.py +91 -0
  52. spacepilot/drivers/mlx_lm_driver.py +255 -0
  53. spacepilot/drivers/mlx_lm_runner.py +96 -0
  54. spacepilot/drivers/whisper_cpp_driver.py +195 -0
  55. spacepilot/engines/__init__.py +29 -0
  56. spacepilot/engines/base.py +156 -0
  57. spacepilot/engines/hunyuan_engine.py +162 -0
  58. spacepilot/engines/ltx_engine.py +160 -0
  59. spacepilot/engines/registry.py +102 -0
  60. spacepilot/engines/wan_engine.py +180 -0
  61. spacepilot/generate_music_templates.py +123 -0
  62. spacepilot/local_workers.py +287 -0
  63. spacepilot/ltx_worker.py +392 -0
  64. spacepilot/mcp_server.py +397 -0
  65. spacepilot/measurements.py +615 -0
  66. spacepilot/model_recommender.py +239 -0
  67. spacepilot/model_registry.py +562 -0
  68. spacepilot/paths.py +509 -0
  69. spacepilot/registry/README.md +105 -0
  70. spacepilot/registry/measurements/apple-m1-max-32gb/flux2-klein-4b-4bit/20260823T081009Z-mflux-int4-27ab38d3.yaml +22 -0
  71. spacepilot/registry/measurements/apple-m1-max-32gb/flux2-klein-4b-4bit/20260823T081009Z-mflux-int4-ec4e3c4c.yaml +23 -0
  72. spacepilot/registry/measurements/apple-m1-max-32gb/flux2-klein-4b-4bit/20260823T094901Z-mflux-int4-0650465f.yaml +20 -0
  73. spacepilot/registry/measurements/apple-m1-max-32gb/flux2-klein-4b-4bit/20260823T094901Z-mflux-int4-d37072c3.yaml +19 -0
  74. spacepilot/registry/measurements/apple-m1-max-32gb/kokoro-82m/20260823T141545Z-kokoro-onnx-fp32-c83a0b85.yaml +24 -0
  75. spacepilot/registry/measurements/apple-m1-max-32gb/kokoro-82m-onnx/20260825T192113Z-kokoro-onnx-fp32-06cca368.yaml +19 -0
  76. spacepilot/registry/measurements/apple-m1-max-32gb/kokoro-82m-onnx/20260825T194015Z-kokoro-onnx-fp32-03add528.yaml +19 -0
  77. spacepilot/registry/measurements/apple-m1-max-32gb/kokoro-82m-onnx/20260825T194021Z-kokoro-onnx-fp32-42a5b069.yaml +19 -0
  78. spacepilot/registry/measurements/apple-m1-max-32gb/minicpm5-2b-bf16/20260908T024913Z-mlx-bf16.yaml +14 -0
  79. spacepilot/registry/measurements/apple-m1-max-32gb/minicpm5-2b-mlx-4bit/20260908T024832Z-mlx-int4.yaml +14 -0
  80. spacepilot/registry/measurements/apple-m1-max-32gb/whisper-small-en/20260825T192238Z-whisper-cpp-f16-0c5ef2c1.yaml +17 -0
  81. spacepilot/registry/measurements/intel-r-core-tm-i5-6200u-cpu-2-30ghz-15gb/kokoro-82m/20260823T141135Z-kokoro-onnx-fp32-a97d23e8.yaml +24 -0
  82. spacepilot/registry/measurements/intel-r-core-tm-i5-8210y-cpu-1-60ghz-8gb/kokoro-82m/20260823T141410Z-kokoro-onnx-fp32-0f98f04b.yaml +24 -0
  83. spacepilot/registry/measurements/intel-r-core-tm-i5-8210y-cpu-1-60ghz-8gb/kokoro-82m/20260823T141519Z-kokoro-onnx-int8-a4e696b4.yaml +24 -0
  84. spacepilot/registry/models/auk.yaml +53 -0
  85. spacepilot/registry/models/aurasr.yaml +32 -0
  86. spacepilot/registry/models/bitnet-b1-58-2b4t.yaml +114 -0
  87. spacepilot/registry/models/boogu.yaml +38 -0
  88. spacepilot/registry/models/deepseek-r1-distill-qwen.yaml +30 -0
  89. spacepilot/registry/models/deepseek-v4-flash.yaml +58 -0
  90. spacepilot/registry/models/desert-ant-align.yaml +56 -0
  91. spacepilot/registry/models/desert-ant-clear.yaml +56 -0
  92. spacepilot/registry/models/desert-ant-clips.yaml +56 -0
  93. spacepilot/registry/models/desert-ant-ear.yaml +56 -0
  94. spacepilot/registry/models/desert-ant-emo.yaml +56 -0
  95. spacepilot/registry/models/desert-ant-gist.yaml +56 -0
  96. spacepilot/registry/models/desert-ant-redact.yaml +56 -0
  97. spacepilot/registry/models/desert-ant-shapes.yaml +56 -0
  98. spacepilot/registry/models/desert-ant-title.yaml +56 -0
  99. spacepilot/registry/models/desert-ant-tongue.yaml +56 -0
  100. spacepilot/registry/models/desert-ant-uhm.yaml +56 -0
  101. spacepilot/registry/models/desert-ant-voz.yaml +56 -0
  102. spacepilot/registry/models/distil-whisper.yaml +50 -0
  103. spacepilot/registry/models/edge0-35b-a3b.yaml +66 -0
  104. spacepilot/registry/models/edge0-8b-a1b.yaml +67 -0
  105. spacepilot/registry/models/ernie-image.yaml +56 -0
  106. spacepilot/registry/models/f5-tts.yaml +30 -0
  107. spacepilot/registry/models/fibo.yaml +103 -0
  108. spacepilot/registry/models/flashvsr.yaml +32 -0
  109. spacepilot/registry/models/flux.yaml +157 -0
  110. spacepilot/registry/models/flux2-klein.yaml +230 -0
  111. spacepilot/registry/models/gemma4-31b.yaml +46 -0
  112. spacepilot/registry/models/hunyuan-video.yaml +36 -0
  113. spacepilot/registry/models/ideogram.yaml +41 -0
  114. spacepilot/registry/models/kokoro.yaml +58 -0
  115. spacepilot/registry/models/lens.yaml +45 -0
  116. spacepilot/registry/models/ltx-video.yaml +83 -0
  117. spacepilot/registry/models/minicpm5-2b.yaml +64 -0
  118. spacepilot/registry/models/minimax-music.yaml +30 -0
  119. spacepilot/registry/models/moonshine.yaml +53 -0
  120. spacepilot/registry/models/muse-glimmer-coreai.yaml +57 -0
  121. spacepilot/registry/models/nemotron-3.yaml +54 -0
  122. spacepilot/registry/models/qwen-image.yaml +57 -0
  123. spacepilot/registry/models/qwen1-5-moe.yaml +46 -0
  124. spacepilot/registry/models/qwen2-5-vl.yaml +30 -0
  125. spacepilot/registry/models/qwen3-5-35b-a3b-base.yaml +56 -0
  126. spacepilot/registry/models/qwen3-6-27b.yaml +47 -0
  127. spacepilot/registry/models/qwen3-8-splash.yaml +37 -0
  128. spacepilot/registry/models/qwen3-8.yaml +43 -0
  129. spacepilot/registry/models/qwen3-embedding.yaml +51 -0
  130. spacepilot/registry/models/real-esrgan.yaml +32 -0
  131. spacepilot/registry/models/sana.yaml +30 -0
  132. spacepilot/registry/models/seedvr2.yaml +69 -0
  133. spacepilot/registry/models/sensevoice.yaml +43 -0
  134. spacepilot/registry/models/stable-audio.yaml +30 -0
  135. spacepilot/registry/models/stable-diffusion-3-5.yaml +46 -0
  136. spacepilot/registry/models/supir.yaml +32 -0
  137. spacepilot/registry/models/ternary-bonsai-8b.yaml +149 -0
  138. spacepilot/registry/models/wan-2-1.yaml +62 -0
  139. spacepilot/registry/models/wan-2-2.yaml +44 -0
  140. spacepilot/registry/models/whisper.yaml +168 -0
  141. spacepilot/registry/models/z-image.yaml +79 -0
  142. spacepilot/registry/runtimes/bitnet-cpp.yaml +78 -0
  143. spacepilot/registry/runtimes/desert-ant.yaml +79 -0
  144. spacepilot/registry/runtimes/diffusers.yaml +31 -0
  145. spacepilot/registry/runtimes/edge0.yaml +72 -0
  146. spacepilot/registry/runtimes/kokoro-onnx.yaml +27 -0
  147. spacepilot/registry/runtimes/llama-cpp-prism.yaml +54 -0
  148. spacepilot/registry/runtimes/llama-cpp.yaml +26 -0
  149. spacepilot/registry/runtimes/mflux.yaml +42 -0
  150. spacepilot/registry/runtimes/mlx-audio.yaml +25 -0
  151. spacepilot/registry/runtimes/mlx-lm.yaml +33 -0
  152. spacepilot/registry/runtimes/mlx-video.yaml +29 -0
  153. spacepilot/registry/runtimes/silero-vad.yaml +32 -0
  154. spacepilot/registry/runtimes/splash.yaml +28 -0
  155. spacepilot/registry/runtimes/stable-audio-tools.yaml +26 -0
  156. spacepilot/registry/runtimes/whisper-cpp.yaml +39 -0
  157. spacepilot/registry/silicon/ascend-950pr.yaml +31 -0
  158. spacepilot/registry/silicon/crescent-island.yaml +25 -0
  159. spacepilot/registry/silicon/dgx-spark.yaml +44 -0
  160. spacepilot/registry/silicon/hbf.yaml +33 -0
  161. spacepilot/registry/silicon/hbm4.yaml +26 -0
  162. spacepilot/registry/silicon/instinct-mi450.yaml +30 -0
  163. spacepilot/registry/silicon/mac-mini-m4-pro.yaml +31 -0
  164. spacepilot/registry/silicon/mac-mini-m5-pro.yaml +31 -0
  165. spacepilot/registry/silicon/mac-mini-m6.yaml +34 -0
  166. spacepilot/registry/silicon/mac-studio-m3-ultra.yaml +31 -0
  167. spacepilot/registry/silicon/ryzen-ai-max-395.yaml +38 -0
  168. spacepilot/registry/silicon/vera-rubin-nvl72.yaml +19 -0
  169. spacepilot/registry/silicon/vera.yaml +35 -0
  170. spacepilot/registry/silicon/xiaomi-ai-cube.yaml +44 -0
  171. spacepilot/registry/sweeps/flux-schnell-4bit.yaml +54 -0
  172. spacepilot/registry/sweeps/flux2-klein-4b-4bit.yaml +59 -0
  173. spacepilot/registry/systems/apple-m1-max-32gb.yaml +17 -0
  174. spacepilot/registry/systems/intel-r-core-tm-i5-6200u-cpu-2-30ghz-15gb.yaml +17 -0
  175. spacepilot/registry/systems/intel-r-core-tm-i5-8210y-cpu-1-60ghz-8gb.yaml +18 -0
  176. spacepilot/routes.py +97 -0
  177. spacepilot/runtimes.py +739 -0
  178. spacepilot/services/__init__.py +1 -0
  179. spacepilot/services/anthropic_provider.py +192 -0
  180. spacepilot/services/audio.py +145 -0
  181. spacepilot/services/audio_execution.py +429 -0
  182. spacepilot/services/checkpoint_sync.py +173 -0
  183. spacepilot/services/compatibility.py +189 -0
  184. spacepilot/services/corpus.py +150 -0
  185. spacepilot/services/embedding_execution.py +200 -0
  186. spacepilot/services/execution.py +309 -0
  187. spacepilot/services/generation.py +105 -0
  188. spacepilot/services/gpu_lifecycle.py +105 -0
  189. spacepilot/services/image_utils.py +202 -0
  190. spacepilot/services/lora.py +183 -0
  191. spacepilot/services/model_catalog.py +348 -0
  192. spacepilot/services/polar_billing.py +128 -0
  193. spacepilot/services/provenance.py +126 -0
  194. spacepilot/services/text_execution.py +269 -0
  195. spacepilot/services/watchdog.py +61 -0
  196. spacepilot/silicon.py +284 -0
  197. spacepilot/storyboard_decomposer.py +274 -0
  198. spacepilot/substrate.py +169 -0
  199. spacepilot/sweep.py +521 -0
  200. spacepilot/verdict.py +83 -0
  201. spacepilot/web_api.py +311 -0
  202. spacepilot-2.8.0.dist-info/METADATA +297 -0
  203. spacepilot-2.8.0.dist-info/RECORD +207 -0
  204. spacepilot-2.8.0.dist-info/WHEEL +5 -0
  205. spacepilot-2.8.0.dist-info/entry_points.txt +3 -0
  206. spacepilot-2.8.0.dist-info/licenses/LICENSE +202 -0
  207. spacepilot-2.8.0.dist-info/top_level.txt +1 -0
spacepilot/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """SpacePilot — inference orchestration across the machines you can reach."""
2
+
3
+ __version__ = "2.8.0"
@@ -0,0 +1 @@
1
+ """API layer package."""
spacepilot/api/deps.py ADDED
@@ -0,0 +1,50 @@
1
+ """Shared API dependencies and security gates."""
2
+
3
+ import time
4
+ import secrets
5
+ from typing import Optional
6
+ from fastapi import Header, HTTPException
7
+ from spacepilot.core.config import get_settings
8
+
9
+ _last_activity_time = time.time()
10
+
11
+
12
+ def update_activity() -> None:
13
+ """Record activity to reset the idle watchdog timer."""
14
+ global _last_activity_time
15
+ _last_activity_time = time.time()
16
+
17
+
18
+ def get_last_activity_time() -> float:
19
+ return _last_activity_time
20
+
21
+
22
+ def require_token(
23
+ x_spacepilot_token: Optional[str] = Header(None, alias="X-SpacePilot-Token"),
24
+ x_pluto_token: Optional[str] = Header(None, alias="X-Pluto-Token"),
25
+ ) -> None:
26
+ """Security gate accepting the canonical token plus one legacy header.
27
+
28
+ If a client sends both spellings, it must send the same credential in each.
29
+ That makes ambiguous proxy/client configuration fail closed instead of
30
+ allowing one header to silently override the other.
31
+ """
32
+ settings = get_settings()
33
+ try:
34
+ if x_spacepilot_token is not None and x_pluto_token is not None:
35
+ ok = (
36
+ secrets.compare_digest(x_spacepilot_token, x_pluto_token)
37
+ and secrets.compare_digest(x_spacepilot_token, settings.studio_token)
38
+ )
39
+ else:
40
+ candidate = x_spacepilot_token if x_spacepilot_token is not None else x_pluto_token
41
+ ok = bool(candidate) and secrets.compare_digest(candidate, settings.studio_token)
42
+ except TypeError:
43
+ # Headers decode as latin-1, and compare_digest rejects non-ASCII str.
44
+ ok = False
45
+ if not ok:
46
+ raise HTTPException(
47
+ status_code=401,
48
+ detail="Missing or invalid X-SpacePilot-Token",
49
+ headers={"WWW-Authenticate": "Token"},
50
+ )
@@ -0,0 +1,37 @@
1
+ """SpacePilot API Routes."""
2
+
3
+ from .audio import router as audio_router
4
+ from .billing import billing_router
5
+ from .health import router as health_router
6
+ from .compute import router as compute_router
7
+ from .engines import router as engines_router
8
+ from .storyboard import router as storyboard_router
9
+ from .views import router as views_router
10
+ from .assets import router as assets_router
11
+ from .gpu import router as gpu_router
12
+ from .generate import router as generate_router
13
+ from .checkpoints import router as checkpoints_router
14
+ from .lora import router as lora_router
15
+ from .recipes import router as recipes_router
16
+ from .runtimes import router as runtimes_router
17
+ from .measurements import router as measurements_router
18
+ from .inference import router as inference_router
19
+
20
+ __all__ = [
21
+ "audio_router",
22
+ "billing_router",
23
+ "health_router",
24
+ "compute_router",
25
+ "engines_router",
26
+ "storyboard_router",
27
+ "views_router",
28
+ "assets_router",
29
+ "gpu_router",
30
+ "generate_router",
31
+ "checkpoints_router",
32
+ "lora_router",
33
+ "recipes_router",
34
+ "runtimes_router",
35
+ "measurements_router",
36
+ "inference_router",
37
+ ]
@@ -0,0 +1,255 @@
1
+ """Assets management and file streaming routes."""
2
+
3
+ import json
4
+ import base64
5
+ import uuid
6
+ import subprocess
7
+ from pathlib import Path
8
+ from typing import Optional
9
+ from fastapi import APIRouter, Request, HTTPException
10
+ from fastapi.responses import FileResponse
11
+
12
+ from spacepilot.core.config import get_settings
13
+ from spacepilot.core.utils import resolve_output
14
+ from spacepilot.services.image_utils import (
15
+ MAX_IMAGE_SIZE,
16
+ ALLOWED_IMAGE_MIMES,
17
+ sanitize_upload_filename,
18
+ get_image_info,
19
+ get_aspect_ratio_str,
20
+ parse_multipart_form_data,
21
+ )
22
+
23
+ router = APIRouter(tags=["assets"])
24
+
25
+
26
+ @router.get("/api/assets")
27
+ def list_assets():
28
+ """List all generated videos and 4K masters in outputs library."""
29
+ settings = get_settings()
30
+ outputs_dir = settings.outputs_dir
31
+ assets = []
32
+ seen_ids = set()
33
+
34
+ for f in outputs_dir.glob("*.json"):
35
+ try:
36
+ with open(f, "r") as jf:
37
+ data = json.load(jf)
38
+ asset_id = data.get("id", f.stem)
39
+ mp4 = outputs_dir / f"{asset_id}.mp4"
40
+ if mp4.exists():
41
+ data["size_mb"] = round(mp4.stat().st_size / (1024 * 1024), 2)
42
+ data["video_url"] = f"/api/media/{asset_id}.mp4"
43
+ data["thumb_url"] = f"/api/media/{asset_id}.png"
44
+ assets.append(data)
45
+ seen_ids.add(asset_id)
46
+ except Exception:
47
+ pass
48
+
49
+ for mp4 in outputs_dir.glob("*.mp4"):
50
+ asset_id = mp4.stem
51
+ if asset_id in seen_ids or asset_id.endswith("_raw"):
52
+ continue
53
+ try:
54
+ size_mb = round(mp4.stat().st_size / (1024 * 1024), 2)
55
+ is_master = "master" in asset_id or "4k" in asset_id
56
+
57
+ clean_title = asset_id.replace("_", " ").title()
58
+ if "3blue1brown" in asset_id.lower() or "neural_network" in asset_id.lower():
59
+ clean_title = "🧠 3Blue1Brown: But what is a Neural Network? (19-Minute 4K Master)"
60
+ elif "37min" in asset_id.lower() or "welch" in asset_id.lower():
61
+ clean_title = "🎬 37-Minute Diffusion Physics Master Documentary (Welch Labs 4K)"
62
+
63
+ thumb = outputs_dir / f"{asset_id}.png"
64
+ if not thumb.exists():
65
+ subprocess.run(
66
+ ["ffmpeg", "-y", "-ss", "2.0", "-i", str(mp4), "-vframes", "1", "-q:v", "2", str(thumb)],
67
+ stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL
68
+ )
69
+
70
+ asset_data = {
71
+ "id": asset_id,
72
+ "prompt": clean_title,
73
+ "width": 3840 if is_master else 1024,
74
+ "height": 2160 if is_master else 576,
75
+ "seconds": 2239.94 if "37min" in asset_id else 1119.94,
76
+ "status": "completed",
77
+ "is_upscaled": is_master,
78
+ "is_motionvector_master": is_master,
79
+ "overlay_title": "4K Mathematical Master",
80
+ "overlay_formula": r"\nabla_x \log p_t(x)",
81
+ "size_mb": size_mb,
82
+ "video_url": f"/api/media/{asset_id}.mp4",
83
+ "thumb_url": f"/api/media/{asset_id}.png" if thumb.exists() else "/api/media/placeholder.png",
84
+ "created_at": mp4.stat().st_mtime,
85
+ }
86
+ assets.append(asset_data)
87
+ seen_ids.add(asset_id)
88
+ except Exception:
89
+ pass
90
+
91
+ assets.sort(key=lambda x: x.get("created_at", 0), reverse=True)
92
+ return {"assets": assets, "count": len(assets)}
93
+
94
+
95
+ @router.api_route("/api/media/{file_path:path}", methods=["GET", "HEAD"])
96
+ def serve_media_file(file_path: str):
97
+ """Safely stream a media file from the outputs directory."""
98
+ path = resolve_output(file_path)
99
+ ext = path.suffix.lower()
100
+ media_types = {
101
+ ".mp4": "video/mp4",
102
+ ".png": "image/png",
103
+ ".jpg": "image/jpeg",
104
+ ".jpeg": "image/jpeg",
105
+ ".webp": "image/webp",
106
+ ".wav": "audio/wav",
107
+ ".json": "application/json",
108
+ }
109
+ return FileResponse(path, media_type=media_types.get(ext, "application/octet-stream"))
110
+
111
+
112
+ @router.api_route("/api/assets/{asset_id}/file", methods=["GET", "HEAD"])
113
+ def get_asset_video_file(asset_id: str):
114
+ """Stream MP4 video file for a specific asset ID."""
115
+ clean_id = asset_id.replace(".mp4", "")
116
+ return FileResponse(resolve_output(f"{clean_id}.mp4"), media_type="video/mp4")
117
+
118
+
119
+ @router.api_route("/api/assets/{asset_id}/thumbnail", methods=["GET", "HEAD"])
120
+ def get_asset_thumbnail_file(asset_id: str):
121
+ """Stream thumbnail PNG image for a specific asset ID."""
122
+ clean_id = asset_id.replace(".png", "").replace(".mp4", "")
123
+ settings = get_settings()
124
+ try:
125
+ file_path = resolve_output(f"{clean_id}.png")
126
+ except HTTPException:
127
+ fallback = settings.web_dir / "assets" / "placeholder.png"
128
+ if fallback.exists():
129
+ return FileResponse(fallback, media_type="image/png")
130
+ raise HTTPException(status_code=404, detail=f"Asset thumbnail '{asset_id}' not found")
131
+ return FileResponse(file_path, media_type="image/png")
132
+
133
+
134
+ @router.post("/api/upload-image")
135
+ @router.post("/api/assets/upload-image")
136
+ async def upload_image_api(request: Request):
137
+ """Upload and validate an image for Image-to-Video generation."""
138
+ settings = get_settings()
139
+ uploads_dir = settings.outputs_dir / "uploads"
140
+ uploads_dir.mkdir(parents=True, exist_ok=True)
141
+
142
+ content_type = request.headers.get("content-type", "")
143
+ content: Optional[bytes] = None
144
+ raw_filename: str = "upload.png"
145
+ declared_mime: Optional[str] = None
146
+
147
+ if "multipart/form-data" in content_type or "application/x-www-form-urlencoded" in content_type:
148
+ has_multipart_lib = False
149
+ try:
150
+ import multipart
151
+ has_multipart_lib = True
152
+ except ImportError:
153
+ pass
154
+
155
+ if has_multipart_lib:
156
+ try:
157
+ form = await request.form()
158
+ upload_file = form.get("file") or form.get("image")
159
+ if upload_file is None:
160
+ for v in form.values():
161
+ if hasattr(v, "filename") and hasattr(v, "read"):
162
+ upload_file = v
163
+ break
164
+
165
+ if upload_file is not None and hasattr(upload_file, "filename"):
166
+ raw_filename = upload_file.filename or "upload.png"
167
+ declared_mime = upload_file.content_type
168
+ content = await upload_file.read()
169
+ except Exception:
170
+ pass
171
+
172
+ if content is None:
173
+ raw_body = await request.body()
174
+ content, raw_filename, declared_mime = parse_multipart_form_data(raw_body, content_type)
175
+
176
+ if content is None:
177
+ raise HTTPException(status_code=400, detail="No image file provided in form data")
178
+
179
+ elif "application/json" in content_type:
180
+ try:
181
+ body = await request.json()
182
+ except Exception as e:
183
+ raise HTTPException(status_code=400, detail=f"Invalid JSON body: {e}")
184
+
185
+ b64_data = body.get("image_base64") or body.get("image") or body.get("data") or body.get("file")
186
+ if not b64_data or not isinstance(b64_data, str):
187
+ raise HTTPException(status_code=400, detail="Missing image base64 data in JSON body")
188
+
189
+ raw_filename = body.get("filename", "upload.png")
190
+ declared_mime = body.get("content_type")
191
+
192
+ if "," in b64_data and b64_data.startswith("data:"):
193
+ header_part, b64_part = b64_data.split(",", 1)
194
+ if not declared_mime and ";" in header_part:
195
+ declared_mime = header_part.split(";")[0].replace("data:", "")
196
+ b64_data = b64_part
197
+
198
+ try:
199
+ content = base64.b64decode(b64_data)
200
+ except Exception as e:
201
+ raise HTTPException(status_code=400, detail=f"Invalid base64 payload: {e}")
202
+
203
+ elif any(content_type.startswith(m) for m in ALLOWED_IMAGE_MIMES):
204
+ raw_filename = "upload.png"
205
+ declared_mime = content_type.split(";")[0]
206
+ content = await request.body()
207
+
208
+ else:
209
+ raise HTTPException(
210
+ status_code=400,
211
+ detail="Unsupported Content-Type. Expected multipart/form-data or application/json",
212
+ )
213
+
214
+ if not content or len(content) == 0:
215
+ raise HTTPException(status_code=400, detail="Empty image payload")
216
+
217
+ if len(content) > MAX_IMAGE_SIZE:
218
+ raise HTTPException(status_code=413, detail="File size exceeds maximum allowed 25MB")
219
+
220
+ width, height, detected_mime = get_image_info(content)
221
+ effective_mime = detected_mime or declared_mime
222
+ if effective_mime == "image/jpg":
223
+ effective_mime = "image/jpeg"
224
+
225
+ if not effective_mime or effective_mime not in ALLOWED_IMAGE_MIMES:
226
+ raise HTTPException(
227
+ status_code=400,
228
+ detail=f"Unsupported image type '{effective_mime}'. Allowed: image/png, image/jpeg, image/webp, image/gif",
229
+ )
230
+
231
+ if not width or not height or width <= 0 or height <= 0:
232
+ raise HTTPException(status_code=400, detail="Could not determine valid image dimensions")
233
+
234
+ mime_to_ext = {"image/png": ".png", "image/jpeg": ".jpg", "image/webp": ".webp", "image/gif": ".gif"}
235
+ fallback_ext = mime_to_ext.get(effective_mime, ".png")
236
+ clean_name = sanitize_upload_filename(raw_filename, fallback_ext)
237
+
238
+ unique_id = uuid.uuid4().hex[:10]
239
+ safe_name = f"{unique_id}_{clean_name}"
240
+ dest_path = (uploads_dir / safe_name).resolve()
241
+
242
+ if not dest_path.is_relative_to(uploads_dir.resolve()):
243
+ raise HTTPException(status_code=400, detail="Invalid target filename")
244
+
245
+ dest_path.write_bytes(content)
246
+ aspect_str = get_aspect_ratio_str(width, height)
247
+
248
+ return {
249
+ "image_path": str(dest_path),
250
+ "url": f"/api/media/uploads/{safe_name}",
251
+ "width": width,
252
+ "height": height,
253
+ "aspect_ratio": aspect_str,
254
+ "filename": clean_name,
255
+ }