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.
- spacepilot/__init__.py +3 -0
- spacepilot/api/__init__.py +1 -0
- spacepilot/api/deps.py +50 -0
- spacepilot/api/routes/__init__.py +37 -0
- spacepilot/api/routes/assets.py +255 -0
- spacepilot/api/routes/audio.py +368 -0
- spacepilot/api/routes/billing.py +117 -0
- spacepilot/api/routes/checkpoints.py +52 -0
- spacepilot/api/routes/compute.py +79 -0
- spacepilot/api/routes/engines.py +70 -0
- spacepilot/api/routes/generate.py +622 -0
- spacepilot/api/routes/gpu.py +468 -0
- spacepilot/api/routes/health.py +53 -0
- spacepilot/api/routes/inference.py +597 -0
- spacepilot/api/routes/lora.py +67 -0
- spacepilot/api/routes/measurements.py +46 -0
- spacepilot/api/routes/recipes.py +45 -0
- spacepilot/api/routes/runtimes.py +152 -0
- spacepilot/api/routes/storyboard.py +69 -0
- spacepilot/api/routes/views.py +73 -0
- spacepilot/api/security.py +121 -0
- spacepilot/app.py +144 -0
- spacepilot/cli.py +2276 -0
- spacepilot/core/__init__.py +5 -0
- spacepilot/core/config.py +121 -0
- spacepilot/core/http.py +33 -0
- spacepilot/core/utils.py +76 -0
- spacepilot/daemon/__init__.py +6 -0
- spacepilot/daemon/__main__.py +26 -0
- spacepilot/daemon/api.py +291 -0
- spacepilot/daemon/fleet.py +506 -0
- spacepilot/daemon/identity.py +475 -0
- spacepilot/daemon/index.py +344 -0
- spacepilot/daemon/log.py +697 -0
- spacepilot/daemon/orders.py +761 -0
- spacepilot/daemon/peer_auth.py +338 -0
- spacepilot/daemon/picture.py +91 -0
- spacepilot/daemon/runtime.py +404 -0
- spacepilot/daemon/server.py +479 -0
- spacepilot/daemon/service.py +302 -0
- spacepilot/daemon/tailscale.py +164 -0
- spacepilot/device_probe.py +806 -0
- spacepilot/drivers/__init__.py +12 -0
- spacepilot/drivers/base.py +111 -0
- spacepilot/drivers/gguf_driver.py +235 -0
- spacepilot/drivers/kokoro_driver.py +340 -0
- spacepilot/drivers/kokoro_runner.py +105 -0
- spacepilot/drivers/local_voice_engine.py +115 -0
- spacepilot/drivers/mflux_driver.py +287 -0
- spacepilot/drivers/mlx_embed_driver.py +137 -0
- spacepilot/drivers/mlx_embed_runner.py +91 -0
- spacepilot/drivers/mlx_lm_driver.py +255 -0
- spacepilot/drivers/mlx_lm_runner.py +96 -0
- spacepilot/drivers/whisper_cpp_driver.py +195 -0
- spacepilot/engines/__init__.py +29 -0
- spacepilot/engines/base.py +156 -0
- spacepilot/engines/hunyuan_engine.py +162 -0
- spacepilot/engines/ltx_engine.py +160 -0
- spacepilot/engines/registry.py +102 -0
- spacepilot/engines/wan_engine.py +180 -0
- spacepilot/generate_music_templates.py +123 -0
- spacepilot/local_workers.py +287 -0
- spacepilot/ltx_worker.py +392 -0
- spacepilot/mcp_server.py +397 -0
- spacepilot/measurements.py +615 -0
- spacepilot/model_recommender.py +239 -0
- spacepilot/model_registry.py +562 -0
- spacepilot/paths.py +509 -0
- spacepilot/registry/README.md +105 -0
- spacepilot/registry/measurements/apple-m1-max-32gb/flux2-klein-4b-4bit/20260823T081009Z-mflux-int4-27ab38d3.yaml +22 -0
- spacepilot/registry/measurements/apple-m1-max-32gb/flux2-klein-4b-4bit/20260823T081009Z-mflux-int4-ec4e3c4c.yaml +23 -0
- spacepilot/registry/measurements/apple-m1-max-32gb/flux2-klein-4b-4bit/20260823T094901Z-mflux-int4-0650465f.yaml +20 -0
- spacepilot/registry/measurements/apple-m1-max-32gb/flux2-klein-4b-4bit/20260823T094901Z-mflux-int4-d37072c3.yaml +19 -0
- spacepilot/registry/measurements/apple-m1-max-32gb/kokoro-82m/20260823T141545Z-kokoro-onnx-fp32-c83a0b85.yaml +24 -0
- spacepilot/registry/measurements/apple-m1-max-32gb/kokoro-82m-onnx/20260825T192113Z-kokoro-onnx-fp32-06cca368.yaml +19 -0
- spacepilot/registry/measurements/apple-m1-max-32gb/kokoro-82m-onnx/20260825T194015Z-kokoro-onnx-fp32-03add528.yaml +19 -0
- spacepilot/registry/measurements/apple-m1-max-32gb/kokoro-82m-onnx/20260825T194021Z-kokoro-onnx-fp32-42a5b069.yaml +19 -0
- spacepilot/registry/measurements/apple-m1-max-32gb/minicpm5-2b-bf16/20260908T024913Z-mlx-bf16.yaml +14 -0
- spacepilot/registry/measurements/apple-m1-max-32gb/minicpm5-2b-mlx-4bit/20260908T024832Z-mlx-int4.yaml +14 -0
- spacepilot/registry/measurements/apple-m1-max-32gb/whisper-small-en/20260825T192238Z-whisper-cpp-f16-0c5ef2c1.yaml +17 -0
- spacepilot/registry/measurements/intel-r-core-tm-i5-6200u-cpu-2-30ghz-15gb/kokoro-82m/20260823T141135Z-kokoro-onnx-fp32-a97d23e8.yaml +24 -0
- spacepilot/registry/measurements/intel-r-core-tm-i5-8210y-cpu-1-60ghz-8gb/kokoro-82m/20260823T141410Z-kokoro-onnx-fp32-0f98f04b.yaml +24 -0
- spacepilot/registry/measurements/intel-r-core-tm-i5-8210y-cpu-1-60ghz-8gb/kokoro-82m/20260823T141519Z-kokoro-onnx-int8-a4e696b4.yaml +24 -0
- spacepilot/registry/models/auk.yaml +53 -0
- spacepilot/registry/models/aurasr.yaml +32 -0
- spacepilot/registry/models/bitnet-b1-58-2b4t.yaml +114 -0
- spacepilot/registry/models/boogu.yaml +38 -0
- spacepilot/registry/models/deepseek-r1-distill-qwen.yaml +30 -0
- spacepilot/registry/models/deepseek-v4-flash.yaml +58 -0
- spacepilot/registry/models/desert-ant-align.yaml +56 -0
- spacepilot/registry/models/desert-ant-clear.yaml +56 -0
- spacepilot/registry/models/desert-ant-clips.yaml +56 -0
- spacepilot/registry/models/desert-ant-ear.yaml +56 -0
- spacepilot/registry/models/desert-ant-emo.yaml +56 -0
- spacepilot/registry/models/desert-ant-gist.yaml +56 -0
- spacepilot/registry/models/desert-ant-redact.yaml +56 -0
- spacepilot/registry/models/desert-ant-shapes.yaml +56 -0
- spacepilot/registry/models/desert-ant-title.yaml +56 -0
- spacepilot/registry/models/desert-ant-tongue.yaml +56 -0
- spacepilot/registry/models/desert-ant-uhm.yaml +56 -0
- spacepilot/registry/models/desert-ant-voz.yaml +56 -0
- spacepilot/registry/models/distil-whisper.yaml +50 -0
- spacepilot/registry/models/edge0-35b-a3b.yaml +66 -0
- spacepilot/registry/models/edge0-8b-a1b.yaml +67 -0
- spacepilot/registry/models/ernie-image.yaml +56 -0
- spacepilot/registry/models/f5-tts.yaml +30 -0
- spacepilot/registry/models/fibo.yaml +103 -0
- spacepilot/registry/models/flashvsr.yaml +32 -0
- spacepilot/registry/models/flux.yaml +157 -0
- spacepilot/registry/models/flux2-klein.yaml +230 -0
- spacepilot/registry/models/gemma4-31b.yaml +46 -0
- spacepilot/registry/models/hunyuan-video.yaml +36 -0
- spacepilot/registry/models/ideogram.yaml +41 -0
- spacepilot/registry/models/kokoro.yaml +58 -0
- spacepilot/registry/models/lens.yaml +45 -0
- spacepilot/registry/models/ltx-video.yaml +83 -0
- spacepilot/registry/models/minicpm5-2b.yaml +64 -0
- spacepilot/registry/models/minimax-music.yaml +30 -0
- spacepilot/registry/models/moonshine.yaml +53 -0
- spacepilot/registry/models/muse-glimmer-coreai.yaml +57 -0
- spacepilot/registry/models/nemotron-3.yaml +54 -0
- spacepilot/registry/models/qwen-image.yaml +57 -0
- spacepilot/registry/models/qwen1-5-moe.yaml +46 -0
- spacepilot/registry/models/qwen2-5-vl.yaml +30 -0
- spacepilot/registry/models/qwen3-5-35b-a3b-base.yaml +56 -0
- spacepilot/registry/models/qwen3-6-27b.yaml +47 -0
- spacepilot/registry/models/qwen3-8-splash.yaml +37 -0
- spacepilot/registry/models/qwen3-8.yaml +43 -0
- spacepilot/registry/models/qwen3-embedding.yaml +51 -0
- spacepilot/registry/models/real-esrgan.yaml +32 -0
- spacepilot/registry/models/sana.yaml +30 -0
- spacepilot/registry/models/seedvr2.yaml +69 -0
- spacepilot/registry/models/sensevoice.yaml +43 -0
- spacepilot/registry/models/stable-audio.yaml +30 -0
- spacepilot/registry/models/stable-diffusion-3-5.yaml +46 -0
- spacepilot/registry/models/supir.yaml +32 -0
- spacepilot/registry/models/ternary-bonsai-8b.yaml +149 -0
- spacepilot/registry/models/wan-2-1.yaml +62 -0
- spacepilot/registry/models/wan-2-2.yaml +44 -0
- spacepilot/registry/models/whisper.yaml +168 -0
- spacepilot/registry/models/z-image.yaml +79 -0
- spacepilot/registry/runtimes/bitnet-cpp.yaml +78 -0
- spacepilot/registry/runtimes/desert-ant.yaml +79 -0
- spacepilot/registry/runtimes/diffusers.yaml +31 -0
- spacepilot/registry/runtimes/edge0.yaml +72 -0
- spacepilot/registry/runtimes/kokoro-onnx.yaml +27 -0
- spacepilot/registry/runtimes/llama-cpp-prism.yaml +54 -0
- spacepilot/registry/runtimes/llama-cpp.yaml +26 -0
- spacepilot/registry/runtimes/mflux.yaml +42 -0
- spacepilot/registry/runtimes/mlx-audio.yaml +25 -0
- spacepilot/registry/runtimes/mlx-lm.yaml +33 -0
- spacepilot/registry/runtimes/mlx-video.yaml +29 -0
- spacepilot/registry/runtimes/silero-vad.yaml +32 -0
- spacepilot/registry/runtimes/splash.yaml +28 -0
- spacepilot/registry/runtimes/stable-audio-tools.yaml +26 -0
- spacepilot/registry/runtimes/whisper-cpp.yaml +39 -0
- spacepilot/registry/silicon/ascend-950pr.yaml +31 -0
- spacepilot/registry/silicon/crescent-island.yaml +25 -0
- spacepilot/registry/silicon/dgx-spark.yaml +44 -0
- spacepilot/registry/silicon/hbf.yaml +33 -0
- spacepilot/registry/silicon/hbm4.yaml +26 -0
- spacepilot/registry/silicon/instinct-mi450.yaml +30 -0
- spacepilot/registry/silicon/mac-mini-m4-pro.yaml +31 -0
- spacepilot/registry/silicon/mac-mini-m5-pro.yaml +31 -0
- spacepilot/registry/silicon/mac-mini-m6.yaml +34 -0
- spacepilot/registry/silicon/mac-studio-m3-ultra.yaml +31 -0
- spacepilot/registry/silicon/ryzen-ai-max-395.yaml +38 -0
- spacepilot/registry/silicon/vera-rubin-nvl72.yaml +19 -0
- spacepilot/registry/silicon/vera.yaml +35 -0
- spacepilot/registry/silicon/xiaomi-ai-cube.yaml +44 -0
- spacepilot/registry/sweeps/flux-schnell-4bit.yaml +54 -0
- spacepilot/registry/sweeps/flux2-klein-4b-4bit.yaml +59 -0
- spacepilot/registry/systems/apple-m1-max-32gb.yaml +17 -0
- spacepilot/registry/systems/intel-r-core-tm-i5-6200u-cpu-2-30ghz-15gb.yaml +17 -0
- spacepilot/registry/systems/intel-r-core-tm-i5-8210y-cpu-1-60ghz-8gb.yaml +18 -0
- spacepilot/routes.py +97 -0
- spacepilot/runtimes.py +739 -0
- spacepilot/services/__init__.py +1 -0
- spacepilot/services/anthropic_provider.py +192 -0
- spacepilot/services/audio.py +145 -0
- spacepilot/services/audio_execution.py +429 -0
- spacepilot/services/checkpoint_sync.py +173 -0
- spacepilot/services/compatibility.py +189 -0
- spacepilot/services/corpus.py +150 -0
- spacepilot/services/embedding_execution.py +200 -0
- spacepilot/services/execution.py +309 -0
- spacepilot/services/generation.py +105 -0
- spacepilot/services/gpu_lifecycle.py +105 -0
- spacepilot/services/image_utils.py +202 -0
- spacepilot/services/lora.py +183 -0
- spacepilot/services/model_catalog.py +348 -0
- spacepilot/services/polar_billing.py +128 -0
- spacepilot/services/provenance.py +126 -0
- spacepilot/services/text_execution.py +269 -0
- spacepilot/services/watchdog.py +61 -0
- spacepilot/silicon.py +284 -0
- spacepilot/storyboard_decomposer.py +274 -0
- spacepilot/substrate.py +169 -0
- spacepilot/sweep.py +521 -0
- spacepilot/verdict.py +83 -0
- spacepilot/web_api.py +311 -0
- spacepilot-2.8.0.dist-info/METADATA +297 -0
- spacepilot-2.8.0.dist-info/RECORD +207 -0
- spacepilot-2.8.0.dist-info/WHEEL +5 -0
- spacepilot-2.8.0.dist-info/entry_points.txt +3 -0
- spacepilot-2.8.0.dist-info/licenses/LICENSE +202 -0
- spacepilot-2.8.0.dist-info/top_level.txt +1 -0
spacepilot/__init__.py
ADDED
|
@@ -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
|
+
}
|