| """ |
| PROJECT CANVAS-5 backend (Docker CPU Space). |
| |
| Serves the full static app at / and exposes POST /convert, which decodes an |
| uploaded video frame-by-frame (OpenCV), renders each frame to ASCII (Pillow, |
| true color), and re-encodes the WHOLE clip with ffmpeg (imageio-ffmpeg). CPU |
| only - no browser, no GPU, no real-time race - so output is complete and never |
| black. Each result is also written into the mounted bucket at /data. |
| """ |
| import os |
| import time |
| import shutil |
| import pathlib |
| import tempfile |
| import json |
| import subprocess |
| import threading |
| import uuid |
| import queue |
| import socket |
| import ipaddress |
| from urllib.parse import urlparse |
| from collections import deque |
|
|
| import numpy as np |
| import cv2 |
| import imageio |
| import yt_dlp |
| from PIL import Image, ImageDraw, ImageFont |
| from fastapi import FastAPI, UploadFile, File, Form, Request, Response |
| from fastapi.responses import FileResponse, JSONResponse, StreamingResponse |
| from fastapi.staticfiles import StaticFiles |
|
|
| GLYPHS = " .,:;irsXA253hMHGS#9B&@" |
| BG = (2, 6, 7) |
| FONT_CANDIDATES = [ |
| "/usr/share/fonts/truetype/dejavu/DejaVuSansMono-Bold.ttf", |
| "/usr/share/fonts/truetype/dejavu/DejaVuSansMono.ttf", |
| "/system/fonts/DroidSansMono.ttf", |
| "/System/Library/Fonts/Cache/Courier.ttc", |
| ] |
|
|
| _DATA_OVERRIDE = os.environ.get("CANVAS5_DATA_DIR") |
| _DATA = pathlib.Path(_DATA_OVERRIDE or "/data") |
| _USING_DATA_ROOT = bool(_DATA_OVERRIDE) or (_DATA.is_dir() and os.access(_DATA, os.W_OK)) |
| BUCKET = (_DATA / "renders") if _USING_DATA_ROOT else pathlib.Path("bucket") |
| BUCKET.mkdir(parents=True, exist_ok=True) |
| UPLOADS = (BUCKET.parent / "uploads") if _USING_DATA_ROOT else (BUCKET / "uploads") |
| JOBS_DIR = (BUCKET.parent / "jobs") if _USING_DATA_ROOT else (BUCKET / "jobs") |
| CONTROL_DIR = BUCKET / "control" |
| UPLOADS.mkdir(parents=True, exist_ok=True) |
| JOBS_DIR.mkdir(parents=True, exist_ok=True) |
| CONTROL_DIR.mkdir(parents=True, exist_ok=True) |
| CHUNK_SIZE = 8 * 1024 * 1024 |
| JOBS = {} |
| JOBS_LOCK = threading.Lock() |
| |
| IS_SPACE = bool(os.environ.get("SPACE_ID")) |
| IS_TERMUX = os.path.exists("/data/data/com.termux/files/home") |
| IS_IPHONE = not IS_TERMUX and os.path.exists("/private/var/mobile") |
|
|
| |
| CONVERT_SLOTS_DEFAULT = "4" if (IS_TERMUX or IS_IPHONE) else "1" |
| CONVERT_SLOTS = max(1, int(os.environ.get("CANVAS5_CONVERT_SLOTS", CONVERT_SLOTS_DEFAULT))) |
| CONVERT_GATE = threading.Semaphore(CONVERT_SLOTS) |
| CONTROL_TOKEN = os.environ.get("CANVAS5_CONTROL_TOKEN", "").strip() |
| CONTROL_CONTENT_LIMIT = max(4096, int(os.environ.get("CANVAS5_CONTROL_CONTENT_LIMIT", "262144"))) |
| CONTROL_ALLOWED_EXTS = {".txt", ".md", ".json", ".html", ".htm", ".svg", ".css", ".ansi", ".log"} |
| CONTROL_LOCK = threading.Lock() |
| CONTROL_EVENTS = deque(maxlen=240) |
| CONTROL_OBSERVATIONS = deque(maxlen=80) |
| CONTROL_SUBSCRIBERS = [] |
| CONTROL_STATE = { |
| "schema": "project-canvas-5/control-state/v1", |
| "settings": { |
| "fx": True, |
| "theme": {"color": "#00ff41", "density": 0.55, "glow": 1.0}, |
| "caption": "ASCII displacement field online.", |
| }, |
| "last_command": None, |
| "last_observation": None, |
| } |
|
|
| CONTROL_TOOLS = [ |
| {"name": "canvas5.telecast", "description": "Broadcast an overlay caption and pulse into connected Canvas-5 pages."}, |
| {"name": "canvas5.pulse", "description": "Send a short reactive ASCII pulse with optional x/y, color, energy, and life controls."}, |
| {"name": "canvas5.theme", "description": "Patch live theme controls such as color, hue, density, and glow."}, |
| {"name": "canvas5.fx", "description": "Turn the reactive background FX layer on or off."}, |
| {"name": "canvas5.patch", "description": "Apply a mixed live-state patch: caption, theme, FX, density, color, and telecast text."}, |
| {"name": "canvas5.clear", "description": "Clear transient live pulses/caption state in connected pages."}, |
| {"name": "canvas5.snapshot", "description": "Ask connected pages to report their current observable state."}, |
| {"name": "control.state", "description": "Return server-side control state, recent commands, observations, and artifacts."}, |
| {"name": "artifact.write", "description": "Create a bounded text/JSON/HTML/SVG artifact in the Space render bucket."}, |
| {"name": "artifact.list", "description": "List control artifacts written under the render bucket."}, |
| {"name": "artifact.read", "description": "Read a bounded text-like control artifact from the render bucket."}, |
| {"name": "canvas5_url_resolve", "description": "Resolve a public media URL and start ASCII conversion or preview. Returns a job_id."}, |
| {"name": "canvas5_job_status", "description": "Read progress/result of a conversion or resolve job by job_id."}, |
| {"name": "canvas5_index", "description": "Self-describing skillbook: the full tool/endpoint/range/constraint catalog with next-step hints."}, |
| ] |
|
|
| CANVAS5_LOOKS = [ |
| "natural", "vibrance", "vivid", "ektar", "portra", "verita", "kodachrome", |
| "technicolor", "neonPop", "monoNeutral", "triX", "hp5", "delta100", "sepia", |
| "cyanotype", "colorNegative", "monoNegative", "crossProcess", "bleachBypass", "solarize", |
| ] |
|
|
|
|
| def _font(size): |
| for p in FONT_CANDIDATES: |
| try: |
| return ImageFont.truetype(p, size) |
| except Exception: |
| continue |
| return ImageFont.load_default() |
|
|
|
|
| def _even(n): |
| n = int(round(n)) |
| return n if n % 2 == 0 else n + 1 |
|
|
|
|
| def _safe_name(value, fallback="video.mp4"): |
| name = pathlib.Path(str(value or fallback)).name |
| cleaned = "".join(ch for ch in name if ch.isalnum() or ch in "._-") |
| return cleaned[:160] or fallback |
|
|
|
|
| def _safe_control_name(value, fallback="canvas5-artifact.txt"): |
| name = _safe_name(value, fallback) |
| suffix = pathlib.Path(name).suffix.lower() |
| if not suffix: |
| name = f"{name}.txt" |
| suffix = ".txt" |
| if suffix not in CONTROL_ALLOWED_EXTS: |
| raise ValueError(f"Control artifacts must use one of: {', '.join(sorted(CONTROL_ALLOWED_EXTS))}") |
| return name |
|
|
|
|
| def _validate_public_media_url(value): |
| url = str(value or "").strip() |
| parsed = urlparse(url) |
| if parsed.scheme not in {"http", "https"} or not parsed.hostname: |
| raise ValueError("Use a public http:// or https:// video URL.") |
| try: |
| addresses = { |
| item[4][0] |
| for item in socket.getaddrinfo(parsed.hostname, parsed.port or (443 if parsed.scheme == "https" else 80)) |
| } |
| except socket.gaierror as exc: |
| raise ValueError("The media URL hostname could not be resolved.") from exc |
| for address in addresses: |
| ip = ipaddress.ip_address(address) |
| if ( |
| ip.is_private or ip.is_loopback or ip.is_link_local or ip.is_multicast |
| or ip.is_reserved or ip.is_unspecified |
| ): |
| raise ValueError("Private or local network media URLs are not allowed.") |
| return url |
|
|
|
|
| def _write_json(path, payload): |
| tmp = pathlib.Path(str(path) + ".tmp") |
| tmp.write_text(json.dumps(payload, indent=2, default=str), encoding="utf-8") |
| tmp.replace(path) |
|
|
|
|
| def _json_safe(value): |
| try: |
| json.dumps(value) |
| return value |
| except Exception: |
| return str(value) |
|
|
|
|
| def _control_authorized(request): |
| if not CONTROL_TOKEN: |
| return True |
| auth = request.headers.get("authorization", "") |
| if auth.lower().startswith("bearer ") and auth[7:].strip() == CONTROL_TOKEN: |
| return True |
| return request.query_params.get("token", "") == CONTROL_TOKEN |
|
|
|
|
| def _control_forbidden(): |
| return JSONResponse({"error": "Canvas-5 control token required."}, status_code=403) |
|
|
|
|
| def _sse_frame(event_name, payload): |
| data = json.dumps(payload, separators=(",", ":"), ensure_ascii=False) |
| lines = [ |
| f"id: {payload.get('id', uuid.uuid4().hex)}", |
| f"event: {event_name}", |
| f"data: {data}", |
| "", |
| "", |
| ] |
| return "\n".join(lines) |
|
|
|
|
| def _control_emit(kind, payload=None, event_name="canvas5"): |
| envelope = { |
| "schema": "project-canvas-5/control-event/v1", |
| "id": uuid.uuid4().hex, |
| "kind": kind, |
| "payload": _json_safe(payload or {}), |
| "created_at": time.time(), |
| } |
| with CONTROL_LOCK: |
| CONTROL_EVENTS.append(envelope) |
| CONTROL_STATE["last_command"] = envelope if event_name == "canvas5" else CONTROL_STATE.get("last_command") |
| subscribers = list(CONTROL_SUBSCRIBERS) |
| for subscriber in subscribers: |
| try: |
| subscriber.put_nowait((event_name, envelope)) |
| except queue.Full: |
| try: |
| subscriber.get_nowait() |
| subscriber.put_nowait((event_name, envelope)) |
| except Exception: |
| pass |
| return envelope |
|
|
|
|
| def _control_observe(payload): |
| observation = { |
| "schema": "project-canvas-5/control-observation/v1", |
| "id": uuid.uuid4().hex, |
| "kind": str(payload.get("kind") or "canvas5.observation")[:80], |
| "payload": _json_safe(payload.get("payload") or {}), |
| "state": _json_safe(payload.get("state") or {}), |
| "created_at": time.time(), |
| } |
| with CONTROL_LOCK: |
| CONTROL_OBSERVATIONS.append(observation) |
| CONTROL_STATE["last_observation"] = observation |
| subscribers = list(CONTROL_SUBSCRIBERS) |
| for subscriber in subscribers: |
| try: |
| subscriber.put_nowait(("canvas5.observation", observation)) |
| except queue.Full: |
| pass |
| return observation |
|
|
|
|
| def _artifact_record(path): |
| return { |
| "name": path.name, |
| "bytes": path.stat().st_size, |
| "modified": path.stat().st_mtime, |
| "url": f"/api/control/artifacts/{path.name}", |
| } |
|
|
|
|
| def _list_control_artifacts(): |
| items = [] |
| for path in CONTROL_DIR.iterdir(): |
| if path.is_file() and path.suffix.lower() in CONTROL_ALLOWED_EXTS: |
| items.append(_artifact_record(path)) |
| return sorted(items, key=lambda item: item["modified"], reverse=True) |
|
|
|
|
| def _write_control_artifact(name, content): |
| safe = _safe_control_name(name) |
| if not isinstance(content, str): |
| content = json.dumps(content, indent=2, ensure_ascii=False) |
| raw = content.encode("utf-8") |
| if len(raw) > CONTROL_CONTENT_LIMIT: |
| raise ValueError(f"Control artifact exceeds {CONTROL_CONTENT_LIMIT} bytes.") |
| path = CONTROL_DIR / safe |
| path.write_bytes(raw) |
| return _artifact_record(path) |
|
|
|
|
| def _read_control_artifact(name): |
| safe = _safe_control_name(name) |
| path = CONTROL_DIR / safe |
| if not path.is_file(): |
| raise FileNotFoundError("Control artifact not found.") |
| if path.stat().st_size > CONTROL_CONTENT_LIMIT: |
| raise ValueError("Control artifact is too large to read through JSON.") |
| record = _artifact_record(path) |
| record["content"] = path.read_text(encoding="utf-8", errors="replace") |
| return record |
|
|
|
|
| def _control_snapshot(): |
| with CONTROL_LOCK: |
| events = list(CONTROL_EVENTS)[-24:] |
| observations = list(CONTROL_OBSERVATIONS)[-12:] |
| state = json.loads(json.dumps(CONTROL_STATE)) |
| return { |
| "schema": "project-canvas-5/control-snapshot/v1", |
| "ok": True, |
| "sse_url": "/api/control/sse", |
| "command_url": "/api/control/command", |
| "tool_url": "/api/control/tool", |
| "observe_url": "/api/control/observe", |
| "artifacts_url": "/api/control/artifacts", |
| "token_required": bool(CONTROL_TOKEN), |
| "subscribers": len(CONTROL_SUBSCRIBERS), |
| "state": state, |
| "tools": CONTROL_TOOLS, |
| "recent_events": events, |
| "recent_observations": observations, |
| "artifacts": _list_control_artifacts(), |
| } |
|
|
|
|
| def _failure(error_type, message, next_step, detail=None): |
| """The Yellow Brick Road: every failure carries an actionable next step.""" |
| envelope = { |
| "ok": False, |
| "schema": "canvas5/failure-mode/v1", |
| "error_type": error_type, |
| "message": str(message), |
| "actionable_next_step": next_step, |
| } |
| if detail is not None: |
| envelope["detail"] = _json_safe(detail) |
| return envelope |
|
|
|
|
| def _canvas5_skillbook(args=None): |
| """Self-describing index so an SSE agent discovers the whole surface cold. |
| |
| Multi-view for different learning models: `view` selects one of |
| all|tools|cookbook|recipes|quickref|narrative (default all). |
| """ |
| args = args or {} |
| book = { |
| "ok": True, |
| "schema": "project-canvas-5/skillbook/v1", |
| "summary": ( |
| "Canvas-5 ASCII media lab. Resolve/convert public video URLs to ASCII video, " |
| "drive live visual state, and read artifacts. Poll job_status as the source of " |
| "truth; transient SSE events are hints and may be missed on reconnect." |
| ), |
| "transports": { |
| "sse": "/api/control/sse", |
| "tool_call": "/api/control/tool", |
| "mcp_call_alias": "/api/mcp/call", |
| "tools_list": "/api/control/tools", |
| }, |
| "call_shape": {"name": "<tool name>", "arguments": {"...": "..."}}, |
| "tools": [ |
| { |
| "name": "canvas5_url_resolve", |
| "aliases": ["url_resolve", "resolve"], |
| "description": "Resolve a public media URL and start ASCII conversion (or preview).", |
| "parameters": { |
| "url": {"type": "string", "format": "uri", "required": True}, |
| "operation": {"type": "string", "enum": ["convert", "preview"], "default": "convert"}, |
| "look": {"type": "string", "enum": CANVAS5_LOOKS}, |
| "cols": {"type": "integer", "min": 24, "max": 220, "default": 96}, |
| "fps": {"type": "integer", "min": 0, "max": 60, "default": 0}, |
| } |
| }, |
| { |
| "name": "canvas5_job_status", |
| "aliases": ["job_status", "status"], |
| "description": "Read the status/progress/result of a conversion or resolve job.", |
| "parameters": {"job_id": {"type": "string", "required": True}} |
| }, |
| { |
| "name": "canvas5_telecast", |
| "aliases": ["telecast"], |
| "description": "Broadcast an overlay caption and pulse into connected pages.", |
| "parameters": { |
| "text": {"type": "string", "required": True}, |
| "color": {"type": "string", "description": "Hex color for the pulse"}, |
| "energy": {"type": "number", "default": 1.0} |
| } |
| }, |
| { |
| "name": "canvas5_pulse", |
| "aliases": ["pulse"], |
| "description": "Send a reactive ASCII pulse with coordinate controls.", |
| "parameters": { |
| "text": {"type": "string", "default": "PULSE"}, |
| "x": {"type": "integer", "min": 0, "max": 100}, |
| "y": {"type": "integer", "min": 0, "max": 100}, |
| "color": {"type": "string"} |
| } |
| }, |
| { |
| "name": "canvas5_theme_patch", |
| "aliases": ["theme"], |
| "description": "Patch live theme controls: color, hue, density, and glow.", |
| "parameters": { |
| "color": {"type": "string"}, |
| "density": {"type": "number", "min": 0, "max": 1}, |
| "glow": {"type": "number"} |
| } |
| }, |
| { |
| "name": "canvas5_fx_toggle", |
| "aliases": ["fx"], |
| "description": "Turn the reactive background FX layer on or off.", |
| "parameters": {"enabled": {"type": "boolean", "default": True}} |
| }, |
| { |
| "name": "canvas5_surface_patch", |
| "aliases": ["patch"], |
| "description": "Combined patch for caption, theme, and FX state.", |
| "parameters": { |
| "caption": {"type": "string"}, |
| "fx": {"type": "boolean"}, |
| "theme": {"type": "object"} |
| } |
| }, |
| { |
| "name": "canvas5_clear", |
| "aliases": ["clear"], |
| "description": "Clear transient live pulses and caption state.", |
| "parameters": {} |
| }, |
| { |
| "name": "canvas5_snapshot", |
| "aliases": ["snapshot"], |
| "description": "Ask connected pages to report their current observable state.", |
| "parameters": {} |
| }, |
| { |
| "name": "control_state", |
| "aliases": ["state"], |
| "description": "Get server-side control state, recent commands, and observations.", |
| "parameters": {} |
| }, |
| { |
| "name": "artifact_write", |
| "aliases": ["write"], |
| "description": "Create a bounded text/JSON/HTML/SVG artifact in the bucket.", |
| "parameters": { |
| "name": {"type": "string", "required": True}, |
| "content": {"type": "string", "required": True} |
| } |
| }, |
| { |
| "name": "artifact_list", |
| "aliases": ["list"], |
| "description": "List control artifacts in the bucket.", |
| "parameters": {} |
| }, |
| { |
| "name": "artifact_read", |
| "aliases": ["read"], |
| "description": "Read a bounded text-like artifact from the bucket.", |
| "parameters": {"name": {"type": "string", "required": True}} |
| }, |
| { |
| "name": "canvas5_index", |
| "aliases": ["playbook"], |
| "description": "This skillbook: the full discoverable tool catalog.", |
| "parameters": {"view": {"type": "string", "enum": ["all", "tools", "cookbook", "recipes", "quickref", "narrative"], "default": "all"}} |
| } |
| ], |
| "governance": { |
| "ranges": { |
| "cols": {"min": 24, "max": 220, "default": 96}, |
| "fps": {"min": 0, "max": 60, "note": "0 keeps source fps, clamped to 1-60"}, |
| }, |
| "concurrency": {"convert_slots_env": "CANVAS5_CONVERT_SLOTS", "default": 1}, |
| "constraints": [ |
| "Facebook share URLs fail in yt-dlp (Cannot parse data). Fallback: use the in-browser CAPTURE_TAB route, or supply a direct media file URL.", |
| "Only public http/https URLs resolve; private/loopback/link-local hosts are rejected.", |
| "Linked downloads are capped by CANVAS5_URL_MAX_BYTES (default 8 GiB).", |
| ], |
| }, |
| "error_envelope_schema": "canvas5/failure-mode/v1", |
| "truth_source": "Poll canvas5_job_status (or GET /api/jobs/{id}); SSE events may be missed on reconnect.", |
| } |
| book["recipes"] = [ |
| { |
| "goal": "Convert a public video URL to ASCII video", |
| "steps": [ |
| "Call canvas5_url_resolve {url, look?, cols?, fps?} -> get job_id.", |
| "Poll canvas5_job_status {job_id} every ~1s until status='complete'.", |
| "GET the returned artifact_url (under /api/artifacts/) to fetch the MP4.", |
| ], |
| "on_failure": "If status='error' on a Facebook/unsupported source, switch to the in-browser CAPTURE_TAB route.", |
| }, |
| { |
| "goal": "Get a fast browser-playable preview of a link", |
| "steps": [ |
| "Call canvas5_url_resolve {url, operation:'preview'} -> job_id.", |
| "Poll canvas5_job_status until complete; artifact_url is a H.264/AAC preview.", |
| ], |
| }, |
| { |
| "goal": "Drive the live visuals of a connected page", |
| "steps": [ |
| "Subscribe GET /api/control/sse (the target page must be open and subscribed).", |
| "Call canvas5_surface_patch {caption?, color?, density?, glow?, fx?} to mutate state.", |
| "Watch the canvas5.command events arrive on the SSE stream.", |
| ], |
| }, |
| { |
| "goal": "Make and read a text artifact (banner/ansi/json)", |
| "steps": [ |
| "Call artifact.write {name, content} (.txt/.md/.json/.html/.svg/.css, bounded).", |
| "Discover names with artifact.list; read with canvas5_artifact_read {name}.", |
| ], |
| }, |
| { |
| "goal": "Recover after a disconnect", |
| "steps": [ |
| "Re-open the page; it auto-reattaches running/finished jobs saved in localStorage.", |
| "Or call canvas5_job_status {job_id} directly if you kept the id.", |
| ], |
| }, |
| ] |
| book["quickref"] = ( |
| "url_resolve{url,look,cols(24-220),fps(0-60)}->job_id | " |
| "job_status{job_id}->progress/artifact_url | " |
| "surface_patch{caption,color,density(0-1),fx} | " |
| "artifact_read{name} | index{view:all|tools|cookbook|quickref|narrative}. " |
| "Truth=poll job_status. Facebook=>CAPTURE_TAB." |
| ) |
| book["narrative"] = ( |
| "You are talking to Canvas-5, an ASCII media lab. To turn a video link into ASCII video, " |
| "call canvas5_url_resolve with the URL; you receive a job_id. Poll canvas5_job_status with " |
| "that id until it reports complete, then download artifact_url. Sizes are governed: columns " |
| "24-220 (default 96), fps 0-60. To paint the live page, subscribe to /api/control/sse and " |
| "call canvas5_surface_patch. Every error returns a quinesmith/failure-mode/v1 envelope with " |
| "an actionable_next_step - follow it. Facebook share links cannot be resolved server-side; " |
| "use CAPTURE_TAB instead. Trust polled job status over transient SSE events." |
| ) |
| view = str(args.get("view") or args.get("format") or "all").lower() |
| view_fields = { |
| "tools": ["summary", "transports", "call_shape", "tools", "error_envelope_schema"], |
| "cookbook": ["summary", "recipes", "governance", "error_envelope_schema"], |
| "recipes": ["summary", "recipes"], |
| "quickref": ["summary", "quickref", "governance"], |
| "narrative": ["summary", "narrative", "truth_source"], |
| } |
| if view in view_fields: |
| keep = set(view_fields[view]) | {"ok", "schema", "view"} |
| slim = {k: v for k, v in book.items() if k in keep} |
| slim["view"] = view |
| return slim |
| book["view"] = "all" |
| book["available_views"] = ["all", "tools", "cookbook", "recipes", "quickref", "narrative"] |
| return book |
|
|
|
|
| def _tool_url_resolve(args): |
| raw = args.get("url") or args.get("source") or "" |
| try: |
| url = _validate_public_media_url(raw) |
| except ValueError as exc: |
| host = (urlparse(str(raw)).hostname or "").lower() |
| if "facebook." in host or "fb.watch" in host or host == "fb.com": |
| return _failure( |
| "UnsupportedSource", str(exc), |
| "Facebook share URLs cannot be parsed by yt-dlp. Use the in-browser CAPTURE_TAB route, or supply a direct media file URL.", |
| ) |
| return _failure( |
| "InvalidUrl", str(exc), |
| "Supply a public http(s) URL to a direct media file or a yt-dlp-supported page.", |
| ) |
| settings = dict(args.get("settings")) if isinstance(args.get("settings"), dict) else {} |
| for key in ("look", "cols", "fps", "color"): |
| if key in args and key not in settings: |
| settings[key] = args[key] |
| operation = "preview" if str(args.get("operation") or settings.get("operation") or "").lower() == "preview" else "convert" |
| settings["operation"] = operation |
| job_id = uuid.uuid4().hex |
| _job_update( |
| job_id, status="queued", stage="Queued", progress=0.0, |
| operation=operation, source_url=url, created_at=time.time(), |
| ) |
| threading.Thread( |
| target=_run_url_job, args=(job_id, url, settings), |
| daemon=True, name=f"canvas5-url-{operation}-{job_id[:8]}", |
| ).start() |
| return { |
| "ok": True, "job_id": job_id, "operation": operation, |
| "next_step": "Poll canvas5_job_status with this job_id until status=complete, then read artifact_url.", |
| } |
|
|
|
|
| def _tool_job_status(args): |
| job_id = str(args.get("job_id") or args.get("id") or "").strip() |
| if not job_id: |
| return _failure("MissingArgument", "job_id is required.", "Call canvas5_url_resolve first to obtain a job_id.") |
| with JOBS_LOCK: |
| job = JOBS.get(job_id) |
| if job is None: |
| path = JOBS_DIR / f"{_safe_name(job_id, '')}.json" |
| if path.exists(): |
| job = json.loads(path.read_text(encoding="utf-8")) |
| if job is None: |
| return _failure("JobNotFound", f"No job {job_id}.", "The job id may be wrong or its record was cleaned. Start a new canvas5_url_resolve.") |
| result = {"ok": True, **job} |
| if job.get("status") == "error": |
| result["failure"] = _failure( |
| "JobFailed", job.get("error") or "Conversion failed.", |
| "Inspect the error; for unsupported sources use CAPTURE_TAB or a direct media file URL.", |
| ) |
| return result |
|
|
|
|
| _CANVAS5_MEDIA_TOOLS = { |
| "canvas5_index": "index", "canvas5.index": "index", "canvas5_playbook": "index", "canvas5.playbook": "index", "playbook": "index", |
| "canvas5_url_resolve": "url_resolve", "canvas5.url_resolve": "url_resolve", "url_resolve": "url_resolve", "resolve": "url_resolve", |
| "canvas5_job_status": "job_status", "canvas5.job_status": "job_status", "job_status": "job_status", "status": "job_status", |
| "canvas5.telecast": "telecast", "canvas5_telecast": "telecast", "telecast": "telecast", |
| "canvas5.pulse": "pulse", "canvas5_pulse": "pulse", "pulse": "pulse", |
| "canvas5.theme": "theme", "canvas5_theme": "theme", "theme": "theme", |
| "canvas5.fx": "fx", "canvas5_fx": "fx", "fx": "fx", |
| "canvas5.patch": "patch", "canvas5_patch": "patch", "patch": "patch", |
| "canvas5.clear": "clear", "canvas5_clear": "clear", "clear": "clear", |
| "canvas5.snapshot": "snapshot", "canvas5_snapshot": "snapshot", "snapshot": "snapshot", |
| "control.state": "control_state", "control_state": "control_state", |
| "artifact.write": "artifact_write", "artifact_write": "artifact_write", |
| "artifact.list": "artifact_list", "artifact_list": "artifact_list", |
| "artifact.read": "artifact_read", "artifact_read": "artifact_read", |
| } |
|
|
|
|
| def _control_tool_result(name, args): |
| args = args or {} |
| op = _CANVAS5_MEDIA_TOOLS.get(name) |
| if not op: |
| |
| if name.startswith("canvas5."): |
| op = name.replace("canvas5.", "", 1) |
| elif name.startswith("artifact."): |
| op = name.replace(".", "_", 1) |
| else: |
| op = name |
|
|
| if op == "index": |
| return _canvas5_skillbook(args) |
| if op == "url_resolve": |
| return _tool_url_resolve(args) |
| if op == "job_status": |
| return _tool_job_status(args) |
| if op == "control_state" or op == "state": |
| return _control_snapshot() |
| if op == "artifact_write": |
| record = _write_control_artifact(args.get("name") or args.get("path"), args.get("content") or "") |
| event = _control_emit("canvas5.artifact", {"action": "telecast", "text": "Artifact written: {0}".format(record['name']), "artifact": record}) |
| return {"ok": True, "artifact": record, "event": event} |
| if op == "artifact_list": |
| return {"ok": True, "artifacts": _list_control_artifacts()} |
| if op == "artifact_read": |
| return {"ok": True, "artifact": _read_control_artifact(args.get("name") or args.get("path"))} |
| |
| |
| if op in {"telecast", "pulse", "theme", "fx", "patch", "clear", "snapshot"}: |
| payload = {"action": op} |
| if op == "telecast": |
| payload["text"] = args.get("text") or args.get("message") or args.get("value") or "" |
| payload.update({k: v for k, v in args.items() if k not in payload}) |
| elif op == "pulse": |
| payload["text"] = args.get("text") or args.get("message") or args.get("value") or "PULSE" |
| payload.update({k: v for k, v in args.items() if k not in payload}) |
| elif op == "theme": |
| CONTROL_STATE["settings"]["theme"].update({k: v for k, v in args.items() if k in {"color", "hue", "density", "glow"}}) |
| payload.update(args) |
| elif op == "fx": |
| enabled = args.get("enabled", args.get("value", True)) |
| CONTROL_STATE["settings"]["fx"] = bool(enabled) |
| payload["enabled"] = bool(enabled) |
| elif op == "patch": |
| if "caption" in args: CONTROL_STATE["settings"]["caption"] = str(args["caption"]) |
| if "fx" in args: CONTROL_STATE["settings"]["fx"] = bool(args["fx"]) |
| theme = args.get("theme") if isinstance(args.get("theme"), dict) else args |
| CONTROL_STATE["settings"]["theme"].update({k: v for k, v in theme.items() if k in {"color", "hue", "density", "glow"}}) |
| payload.update(args) |
| |
| event = _control_emit("canvas5.command", payload) |
| return {"ok": True, "event": event, "state": CONTROL_STATE} |
|
|
| raise ValueError("Unknown Canvas-5 control tool: {0} (mapped to {1})".format(name, op)) |
| def _has_audio(path): |
| probe = subprocess.run( |
| [ |
| "ffprobe", "-v", "error", "-select_streams", "a:0", |
| "-show_entries", "stream=codec_name", "-of", "default=nw=1:nk=1", |
| str(path), |
| ], |
| capture_output=True, |
| text=True, |
| check=False, |
| ) |
| return probe.returncode == 0 and bool(probe.stdout.strip()) |
|
|
|
|
| def _probe_duration(path): |
| probe = subprocess.run( |
| [ |
| "ffprobe", "-v", "error", "-show_entries", "format=duration", |
| "-of", "default=nw=1:nk=1", str(path), |
| ], |
| capture_output=True, |
| text=True, |
| check=False, |
| ) |
| try: |
| duration = float(probe.stdout.strip()) |
| except (TypeError, ValueError): |
| duration = 0.0 |
| return max(0.0, duration) |
|
|
|
|
| def transcode_browser_preview(in_path, progress=None): |
| stamp = time.strftime("%Y%m%d-%H%M%S") + "-" + uuid.uuid4().hex[:8] |
| out_path = BUCKET / f"canvas5-preview-{stamp}.mp4" |
| duration = _probe_duration(in_path) |
| command = [ |
| "ffmpeg", "-y", "-loglevel", "error", |
| "-i", str(in_path), |
| "-map", "0:v:0", "-map", "0:a?", |
| "-vf", "scale=w='min(1280,iw)':h=-2", |
| "-c:v", "libx264", "-preset", "ultrafast", "-crf", "25", |
| "-pix_fmt", "yuv420p", |
| "-c:a", "aac", "-b:a", "128k", |
| "-movflags", "+faststart", "-shortest", |
| "-progress", "pipe:1", "-nostats", |
| str(out_path), |
| ] |
| process = subprocess.Popen( |
| command, |
| stdout=subprocess.PIPE, |
| stderr=subprocess.STDOUT, |
| text=True, |
| encoding="utf-8", |
| errors="replace", |
| ) |
| diagnostic = deque(maxlen=30) |
| if process.stdout is not None: |
| for raw_line in process.stdout: |
| line = raw_line.strip() |
| if not line: |
| continue |
| diagnostic.append(line) |
| if progress and duration and line.startswith("out_time_ms="): |
| try: |
| seconds = int(line.split("=", 1)[1]) / 1_000_000 |
| progress(min(98.0, seconds / duration * 98.0), seconds, duration) |
| except (TypeError, ValueError): |
| pass |
| return_code = process.wait() |
| if return_code or not out_path.is_file() or out_path.stat().st_size == 0: |
| out_path.unlink(missing_ok=True) |
| detail = " | ".join(diagnostic) or "ffmpeg could not create a browser preview" |
| raise RuntimeError("Browser preview transcode failed: " + detail) |
| if progress: |
| progress(100.0, duration, duration) |
| return out_path, duration, _has_audio(out_path) |
|
|
|
|
|
|
| def _concat_segments(job_dir, output_path): |
| segments = sorted(job_dir.glob("seg-*.mp4")) |
| if not segments: |
| raise RuntimeError("No segments found for concatenation.") |
| |
| concat_file = job_dir / "concat.txt" |
| with concat_file.open("w", encoding="utf-8") as f: |
| for seg in segments: |
| |
| safe_path = str(seg.absolute()).replace("'", "'\\''") |
| f.write("file '{0}'\n".format(safe_path)) |
| |
| command = [ |
| "ffmpeg", "-y", "-loglevel", "error", |
| "-f", "concat", "-safe", "0", |
| "-i", str(concat_file), |
| "-c", "copy", |
| str(output_path), |
| ] |
| result = subprocess.run(command, capture_output=True, text=True, check=False) |
| if result.returncode: |
| raise RuntimeError("Segment concatenation failed: " + (result.stderr.strip() or "ffmpeg error")) |
|
|
|
|
|
|
|
|
|
|
| def _mux_source_audio(rendered_path, source_path, output_path): |
| command = [ |
| "ffmpeg", "-y", "-loglevel", "error", |
| "-i", str(rendered_path), "-i", str(source_path), |
| "-map", "0:v:0", "-map", "1:a?", |
| "-c:v", "copy", "-c:a", "aac", "-b:a", "192k", |
| "-strict", "-2", "-shortest", "-movflags", "+faststart", str(output_path), |
| ] |
| result = subprocess.run(command, capture_output=True, text=True, check=False) |
| if result.returncode: |
| raise RuntimeError("Audio transfer failed: " + (result.stderr.strip() or "ffmpeg error")) |
|
|
|
|
| def convert_video(in_path, cols, fps_cap, color, progress=None, job_dir=None, resume_state=None): |
| cap = cv2.VideoCapture(in_path) |
| if not cap.isOpened(): |
| raise RuntimeError("Could not open the uploaded video.") |
| src_fps = cap.get(cv2.CAP_PROP_FPS) or 24.0 |
| if not (src_fps and src_fps == src_fps and src_fps > 0): |
| src_fps = 24.0 |
| out_fps = float(min(src_fps, fps_cap)) if fps_cap else float(src_fps) |
| out_fps = max(1.0, min(60.0, out_fps)) |
| w = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH) or 16) |
| h = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT) or 9) |
| source_frames = int(cap.get(cv2.CAP_PROP_FRAME_COUNT) or 0) |
| source_duration = source_frames / src_fps if source_frames else 0.0 |
|
|
| cols = int(max(24, min(220, cols))) |
| font_size = 12 |
| font = _font(font_size) |
| box = font.getbbox("M") |
| cell_w = max(6, box[2] - box[0]) |
| cell_h = max(8, int(round(font_size * 1.25))) |
| rows = int(max(8, round(cols * (h / max(1, w)) * (cell_w / cell_h)))) |
| out_w = _even(cols * cell_w) |
| out_h = _even(rows * cell_h) |
|
|
| stamp = time.strftime("%Y%m%d-%H%M%S") + "-" + uuid.uuid4().hex[:8] |
| out_path = BUCKET / "canvas5-ascii-{0}.mp4".format(stamp) |
| rendered_path = BUCKET / ".canvas5-video-{0}.mp4".format(stamp) |
| |
| n = 0 |
| source_index = 0 |
| next_output_time = 0.0 |
| segments_done = 0 |
| |
| if resume_state: |
| segments_done = resume_state.get("segments_done", 0) |
| n = resume_state.get("frames_done", 0) |
| source_index = resume_state.get("source_index", 0) |
| next_output_time = resume_state.get("next_output_time", 0.0) |
| cap.set(cv2.CAP_PROP_POS_FRAMES, source_index) |
| if int(cap.get(cv2.CAP_PROP_POS_FRAMES)) != source_index: |
| cap.set(cv2.CAP_PROP_POS_FRAMES, 0) |
| for _ in range(source_index): |
| cap.read() |
| |
| seg_frames = 240 |
| current_seg_frames = 0 |
| writer = None |
| |
| def open_writer(seg_idx=None): |
| if job_dir: |
| job_dir.mkdir(parents=True, exist_ok=True) |
| p = job_dir / "seg-{0:04d}.mp4".format(seg_idx) |
| else: |
| p = rendered_path |
| return imageio.get_writer( |
| str(p), fps=out_fps, codec="libx264", quality=8, |
| pixelformat="yuv420p", macro_block_size=None, |
| ) |
|
|
| if not job_dir: |
| writer = open_writer() |
| else: |
| writer = open_writer(segments_done) |
|
|
| try: |
| while True: |
| ok, frame = cap.read() |
| if not ok: |
| break |
| frame_time = source_index / src_fps |
| source_index += 1 |
| if frame_time + (0.5 / src_fps) < next_output_time: |
| if progress and source_frames: |
| progress(min(90.0, source_index / source_frames * 90.0), source_index, source_frames) |
| continue |
| |
| if job_dir and current_seg_frames >= seg_frames: |
| writer.close() |
| segments_done += 1 |
| job_id = job_dir.name |
| _job_update( |
| job_id, |
| segments_done=segments_done, |
| frames_done=n, |
| source_index=source_index - 1, |
| next_output_time=next_output_time, |
| out_fps=out_fps, |
| cols=cols, |
| rows=rows, |
| resumable=True, |
| source_path=in_path |
| ) |
| writer = open_writer(segments_done) |
| current_seg_frames = 0 |
|
|
| small = cv2.resize(frame, (cols, rows), interpolation=cv2.INTER_AREA) |
| small = cv2.cvtColor(small, cv2.COLOR_BGR2RGB) |
| luma = 0.2126 * small[:, :, 0] + 0.7152 * small[:, :, 1] + 0.0722 * small[:, :, 2] |
| idx = np.clip((luma / 255.0 * (len(GLYPHS) - 1)).astype(int), 0, len(GLYPHS) - 1) |
| img = Image.new("RGB", (out_w, out_h), BG) |
| draw = ImageDraw.Draw(img) |
| if color: |
| for y in range(rows): |
| yy = y * cell_h |
| for x in range(cols): |
| ch = GLYPHS[idx[y, x]] |
| if ch == " ": |
| continue |
| r, g, b = small[y, x] |
| draw.text((x * cell_w, yy), ch, font=font, fill=(int(r), int(g), int(b))) |
| else: |
| for y in range(rows): |
| line = "".join(GLYPHS[idx[y, x]] for x in range(cols)) |
| draw.text((0, y * cell_h), line, font=font, fill=(0, 255, 65)) |
| writer.append_data(np.asarray(img)) |
| n += 1 |
| current_seg_frames += 1 |
| next_output_time += 1.0 / out_fps |
| if progress and source_frames: |
| progress(min(90.0, source_index / source_frames * 90.0), source_index, source_frames) |
| finally: |
| cap.release() |
| if writer: |
| writer.close() |
| if job_dir and current_seg_frames > 0: |
| segments_done += 1 |
| job_id = job_dir.name |
| _job_update( |
| job_id, |
| segments_done=segments_done, |
| frames_done=n, |
| source_index=source_index, |
| next_output_time=next_output_time, |
| out_fps=out_fps, |
| cols=cols, |
| rows=rows, |
| resumable=True, |
| source_path=in_path |
| ) |
| if n == 0: |
| raise RuntimeError("No frames decoded from the uploaded video.") |
| try: |
| if progress: |
| progress(94.0, source_index, source_frames) |
| if job_dir: |
| _concat_segments(job_dir, rendered_path) |
| if _has_audio(in_path): |
| _mux_source_audio(rendered_path, in_path, out_path) |
| else: |
| rendered_path.replace(out_path) |
| if job_dir: |
| shutil.rmtree(job_dir, ignore_errors=True) |
| finally: |
| rendered_path.unlink(missing_ok=True) |
| if progress: |
| progress(100.0, source_index, source_frames) |
| return out_path, n, out_fps, out_w, out_h, source_duration, _has_audio(out_path) |
|
|
|
|
| def _job_update(job_id, **values): |
| with JOBS_LOCK: |
| current = dict(JOBS.get(job_id, {})) |
| current.update(values) |
| current["updated_at"] = time.time() |
| JOBS[job_id] = current |
| _write_json(JOBS_DIR / f"{job_id}.json", current) |
| return current |
|
|
|
|
| def _run_conversion_job(job_id, source_path, settings): |
| job = _job_update(job_id, status="queued", stage="Waiting for converter", progress=0.0) |
| last_report = [0.0] |
|
|
| def report(percent, frame, total): |
| now = time.monotonic() |
| if percent < 100.0 and now - last_report[0] < 0.75: |
| return |
| last_report[0] = now |
| _job_update( |
| job_id, |
| status="converting", |
| stage="Rendering ASCII frames" if percent < 94 else "Transferring source audio", |
| progress=round(float(percent), 2), |
| source_frame=frame, |
| source_frames=total, |
| ) |
|
|
| try: |
| with CONVERT_GATE: |
| resume_state = job if job.get("resumable") else None |
| _job_update(job_id, status="converting", stage="Rendering ASCII frames", progress=job.get("progress", 1.0)) |
| result = convert_video( |
| source_path, |
| int(settings.get("cols") or job.get("cols") or 96), |
| int(settings.get("fps") or job.get("fps") or 0), |
| str(settings.get("color") or job.get("color") or "color") != "mono", |
| report, |
| job_dir=JOBS_DIR / job_id, |
| resume_state=resume_state |
| ) |
| out_path, frames, out_fps, width, height, duration, audio = result |
| _job_update( |
| job_id, |
| status="complete", |
| stage="Download ready", |
| progress=100.0, |
| artifact=out_path.name, |
| artifact_url="/api/artifacts/{0}".format(out_path.name), |
| bytes=out_path.stat().st_size, |
| frames=frames, |
| fps=out_fps, |
| width=width, |
| height=height, |
| duration=duration, |
| audio=audio, |
| ) |
| except Exception as exc: |
| _job_update(job_id, status="error", stage="Conversion failed", error=str(exc)) |
| finally: |
| current_job = JOBS.get(job_id, {}) |
| if current_job.get("status") == "complete" or not current_job.get("resumable"): |
| pathlib.Path(source_path).unlink(missing_ok=True) |
|
|
|
|
| def _run_preview_job(job_id, source_path): |
| _job_update(job_id, status="queued", stage="Waiting for preview transcoder", progress=0.0) |
| last_report = [0.0] |
|
|
| def report(percent, current, duration): |
| now = time.monotonic() |
| if percent < 100.0 and now - last_report[0] < 0.75: |
| return |
| last_report[0] = now |
| _job_update( |
| job_id, |
| status="converting", |
| stage="Preparing browser-playable preview", |
| progress=round(float(percent), 2), |
| preview_time=round(float(current), 3), |
| duration=round(float(duration), 3), |
| ) |
|
|
| try: |
| with CONVERT_GATE: |
| _job_update( |
| job_id, |
| status="converting", |
| stage="Preparing browser-playable preview", |
| progress=1.0, |
| ) |
| out_path, duration, audio = transcode_browser_preview(source_path, report) |
| _job_update( |
| job_id, |
| status="complete", |
| stage="ASCII preview ready", |
| progress=100.0, |
| operation="preview", |
| artifact=out_path.name, |
| artifact_url=f"/api/artifacts/{out_path.name}", |
| bytes=out_path.stat().st_size, |
| duration=duration, |
| audio=audio, |
| ) |
| except Exception as exc: |
| _job_update(job_id, status="error", stage="Preview preparation failed", error=str(exc)) |
| finally: |
| pathlib.Path(source_path).unlink(missing_ok=True) |
|
|
|
|
| def _download_url_source(job_id, url): |
| output_template = str(UPLOADS / f"{job_id}-source.%(ext)s") |
|
|
| def progress_hook(status): |
| if status.get("status") != "downloading": |
| return |
| downloaded = int(status.get("downloaded_bytes") or 0) |
| total = int(status.get("total_bytes") or status.get("total_bytes_estimate") or 0) |
| percent = min(44.0, downloaded / total * 44.0) if total else 4.0 |
| _job_update( |
| job_id, |
| status="downloading", |
| stage="Downloading linked video", |
| progress=round(percent, 2), |
| downloaded_bytes=downloaded, |
| total_bytes=total, |
| speed=status.get("speed"), |
| eta=status.get("eta"), |
| ) |
|
|
| options = { |
| "outtmpl": output_template, |
| "format": "bv*+ba/b", |
| "merge_output_format": "mp4", |
| "noplaylist": True, |
| "quiet": True, |
| "no_warnings": True, |
| "restrictfilenames": True, |
| "socket_timeout": 30, |
| "progress_hooks": [progress_hook], |
| "max_filesize": max(64 * 1024 * 1024, int(os.environ.get("CANVAS5_URL_MAX_BYTES", str(8 * 1024 * 1024 * 1024)))), |
| } |
| with yt_dlp.YoutubeDL(options) as downloader: |
| info = downloader.extract_info(url, download=True) |
| candidates = [ |
| path for path in UPLOADS.glob(f"{job_id}-source.*") |
| if path.is_file() and path.suffix != ".part" |
| ] |
| if not candidates: |
| raise RuntimeError("The linked video downloaded, but no media file was produced.") |
| source_path = max(candidates, key=lambda path: path.stat().st_size) |
| title = str((info or {}).get("title") or source_path.stem) |
| return source_path, title |
|
|
|
|
| def _run_url_job(job_id, url, settings): |
| operation = "preview" if str(settings.get("operation") or "").lower() == "preview" else "convert" |
| source_path = None |
| try: |
| _job_update(job_id, status="resolving", stage="Resolving linked video", progress=1.0) |
| safe_url = _validate_public_media_url(url) |
| source_path, title = _download_url_source(job_id, safe_url) |
| _job_update( |
| job_id, |
| status="queued", |
| stage="Waiting for preview transcoder" if operation == "preview" else "Waiting for ASCII converter", |
| progress=45.0, |
| title=title, |
| source_bytes=source_path.stat().st_size, |
| ) |
| with CONVERT_GATE: |
| if operation == "preview": |
| def preview_report(percent, current, duration): |
| _job_update( |
| job_id, |
| status="converting", |
| stage="Preparing browser-playable preview", |
| progress=round(45.0 + float(percent) * 0.55, 2), |
| preview_time=round(float(current), 3), |
| duration=round(float(duration), 3), |
| ) |
|
|
| out_path, duration, audio = transcode_browser_preview(source_path, preview_report) |
| result = { |
| "operation": "preview", |
| "artifact": out_path.name, |
| "artifact_url": f"/api/artifacts/{out_path.name}", |
| "bytes": out_path.stat().st_size, |
| "duration": duration, |
| "audio": audio, |
| } |
| else: |
| def convert_report(percent, frame, total): |
| _job_update( |
| job_id, |
| status="converting", |
| stage="Rendering linked video as ASCII" if percent < 94 else "Transferring source audio", |
| progress=round(45.0 + float(percent) * 0.55, 2), |
| source_frame=frame, |
| source_frames=total, |
| ) |
|
|
| resume_state = JOBS.get(job_id) if JOBS.get(job_id, {}).get("resumable") else None |
| out_path, frames, out_fps, width, height, duration, audio = convert_video( |
| source_path, |
| int(settings.get("cols") or 96), |
| int(settings.get("fps") or 0), |
| str(settings.get("color") or "color") != "mono", |
| convert_report, |
| job_dir=JOBS_DIR / job_id, |
| resume_state=resume_state |
| ) |
| result = { |
| "operation": "convert", |
| "artifact": out_path.name, |
| "artifact_url": f"/api/artifacts/{out_path.name}", |
| "bytes": out_path.stat().st_size, |
| "frames": frames, |
| "fps": out_fps, |
| "width": width, |
| "height": height, |
| "duration": duration, |
| "audio": audio, |
| } |
| _job_update( |
| job_id, |
| status="complete", |
| stage="ASCII preview ready" if operation == "preview" else "Download ready", |
| progress=100.0, |
| **result, |
| ) |
| except Exception as exc: |
| _job_update(job_id, status="error", stage="Linked video failed", error=str(exc)) |
| finally: |
| if source_path is not None: |
| current_job = JOBS.get(job_id, {}) |
| if current_job.get("status") == "complete" or not current_job.get("resumable"): |
| pathlib.Path(source_path).unlink(missing_ok=True) |
|
|
|
|
|
|
| def _resume_incomplete_jobs(): |
| for job_path in JOBS_DIR.glob("*.json"): |
| try: |
| job = json.loads(job_path.read_text(encoding="utf-8")) |
| job_id = job.get("job_id") or job_path.stem |
| status = job.get("status") |
| source_path = job.get("source_path") |
| |
| if status in {"queued", "converting", "downloading"} and source_path: |
| p = pathlib.Path(source_path) |
| if p.is_file(): |
| operation = job.get("operation", "convert") |
| if operation == "convert": |
| target = _run_conversion_job |
| args = (job_id, source_path, job.get("settings", {})) |
| elif operation == "preview": |
| target = _run_preview_job |
| args = (job_id, source_path) |
| else: |
| continue |
| |
| |
| with JOBS_LOCK: |
| JOBS[job_id] = job |
| |
| threading.Thread( |
| target=target, |
| args=args, |
| daemon=True, |
| name="canvas5-resume-{0}-{1}".format(operation, job_id[:8]), |
| ).start() |
| except Exception as e: |
| print("Failed to resume job {0}: {1}".format(job_path, e)) |
|
|
|
|
|
|
|
|
|
|
|
|
| async def _handle_mcp_message(request: Request): |
| payload = await request.json() |
| msg_id = payload.get("id") |
| method = payload.get("method") |
| params = payload.get("params", {}) |
| |
| if method == "initialize": |
| return { |
| "jsonrpc": "2.0", |
| "id": msg_id, |
| "result": { |
| "protocolVersion": "2024-11-05", |
| "capabilities": { |
| "tools": {"listChanged": False}, |
| "resources": {"subscribe": False, "listChanged": False}, |
| "prompts": {"listChanged": False} |
| }, |
| "serverInfo": { |
| "name": "project-canvas-5", |
| "version": "1.0.0" |
| } |
| } |
| } |
| |
| if method == "tools/list": |
| tools = [] |
| book = _canvas5_skillbook({"view": "tools"}) |
| for t in book.get("tools", []): |
| mcp_tool = { |
| "name": t["name"], |
| "description": t["description"], |
| "inputSchema": { |
| "type": "object", |
| "properties": {}, |
| "required": [] |
| } |
| } |
| |
| for p_name, p_def in t.get("parameters", {}).items(): |
| p_type = p_def.get("type", "string") |
| prop = {"type": p_type} |
| if "description" in p_def: prop["description"] = p_def["description"] |
| if "default" in p_def: prop["default"] = p_def["default"] |
| if "enum" in p_def: prop["enum"] = p_def["enum"] |
| if "min" in p_def: prop["minimum"] = p_def["min"] |
| if "max" in p_def: prop["maximum"] = p_def["max"] |
| |
| mcp_tool["inputSchema"]["properties"][p_name] = prop |
| if p_def.get("required"): |
| mcp_tool["inputSchema"]["required"].append(p_name) |
| |
| tools.append(mcp_tool) |
| |
| return { |
| "jsonrpc": "2.0", |
| "id": msg_id, |
| "result": {"tools": tools} |
| } |
| |
| if method == "tools/call": |
| tool_name = params.get("name") |
| tool_args = params.get("arguments", {}) |
| try: |
| result = _control_tool_result(tool_name, tool_args) |
| return { |
| "jsonrpc": "2.0", |
| "id": msg_id, |
| "result": { |
| "content": [{"type": "text", "text": json.dumps(result, indent=2)}] |
| } |
| } |
| except Exception as e: |
| return { |
| "jsonrpc": "2.0", |
| "id": msg_id, |
| "error": {"code": -32603, "message": str(e)} |
| } |
|
|
| if method == "notifications/initialized": |
| return None |
|
|
| return { |
| "jsonrpc": "2.0", |
| "id": msg_id, |
| "error": {"code": -32601, "message": f"Method {method} not found"} |
| } |
|
|
|
|
|
|
| app = FastAPI() |
|
|
|
|
| @app.post("/api/monkey/push") |
| async def monkey_push_retired(request: Request): |
| return JSONResponse( |
| { |
| "ok": False, |
| "error": "Monkey MJPEG stream is retired. Use OBS Window Capture on the Canvas-5 Monkey deck.", |
| }, |
| status_code=410, |
| ) |
|
|
|
|
| @app.get("/api/monkey/stream") |
| async def monkey_stream_retired(request: Request): |
| return JSONResponse( |
| { |
| "ok": False, |
| "error": "Monkey MJPEG stream is retired. Use OBS Window Capture on the Canvas-5 Monkey deck.", |
| }, |
| status_code=410, |
| ) |
|
|
| @app.on_event("startup") |
| def on_startup(): |
| _resume_incomplete_jobs() |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| @app.get("/healthz") |
| def healthz(): |
| return { |
| "ok": True, |
| "bucket": str(BUCKET), |
| "chunk_size": CHUNK_SIZE, |
| "large_file_jobs": True, |
| "audio_transfer": True, |
| "browser_preview_transcode": True, |
| "linked_video_jobs": True, |
| "control_sse": True, |
| "control_tools": True, |
| "control_artifacts": True, |
| "control_token_required": bool(CONTROL_TOKEN), |
| } |
|
|
|
|
| @app.get("/api/control/status") |
| def control_status(request: Request): |
| if not _control_authorized(request): |
| return _control_forbidden() |
| return _control_snapshot() |
|
|
|
|
| @app.get("/api/control/tools") |
| def control_tools(request: Request): |
| if not _control_authorized(request): |
| return _control_forbidden() |
| return { |
| "schema": "project-canvas-5/control-tools/v1", |
| "ok": True, |
| "tools": CONTROL_TOOLS, |
| "tool_url": "/api/control/tool", |
| "mcp_aliases": ["/api/mcp/tools", "/api/mcp/call"], |
| } |
|
|
|
|
| @app.get("/api/mcp/tools") |
| def mcp_tools_alias(request: Request): |
| return control_tools(request) |
|
|
|
|
| @app.get("/api/control/sse") |
| def control_sse(request: Request): |
| if not _control_authorized(request): |
| return _control_forbidden() |
|
|
| def stream(): |
| subscriber = queue.Queue(maxsize=64) |
| with CONTROL_LOCK: |
| CONTROL_SUBSCRIBERS.append(subscriber) |
| hello = { |
| "schema": "project-canvas-5/control-event/v1", |
| "id": uuid.uuid4().hex, |
| "kind": "canvas5.control.hello", |
| "payload": { |
| "message": "Canvas-5 server control bus connected.", |
| "tools": [tool["name"] for tool in CONTROL_TOOLS], |
| "observe_url": "/api/control/observe", |
| }, |
| "created_at": time.time(), |
| } |
| try: |
| yield _sse_frame("canvas5.control", hello) |
| while True: |
| try: |
| event_name, payload = subscriber.get(timeout=15) |
| yield _sse_frame(event_name, payload) |
| except queue.Empty: |
| yield ": canvas5 keepalive\n\n" |
| finally: |
| with CONTROL_LOCK: |
| if subscriber in CONTROL_SUBSCRIBERS: |
| CONTROL_SUBSCRIBERS.remove(subscriber) |
|
|
| return StreamingResponse( |
| stream(), |
| media_type="text/event-stream", |
| headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"}, |
| ) |
|
|
|
|
| @app.post("/api/control/observe") |
| async def control_observe(request: Request): |
| if not _control_authorized(request): |
| return _control_forbidden() |
| payload = await request.json() |
| return {"ok": True, "observation": _control_observe(payload)} |
|
|
|
|
| @app.post("/api/control/command") |
| async def control_command(request: Request): |
| if not _control_authorized(request): |
| return _control_forbidden() |
| payload = await request.json() |
| if "payload" in payload and "kind" in payload: |
| command_payload = dict(payload.get("payload") or {}) |
| command_payload.setdefault("action", str(payload.get("kind") or "canvas5.telecast").replace("canvas5.", "")) |
| else: |
| command_payload = dict(payload) |
| event = _control_emit("canvas5.command", command_payload) |
| return {"ok": True, "event": event} |
|
|
|
|
| @app.post("/api/control/tool") |
| async def control_tool(request: Request): |
| if not _control_authorized(request): |
| return _control_forbidden() |
| payload = await request.json() |
| name = str(payload.get("name") or payload.get("tool") or "").strip() |
| args = payload.get("arguments") |
| if args is None: |
| args = payload.get("args") |
| if args is None: |
| args = payload.get("payload") |
| try: |
| if not name: |
| raise ValueError("Missing control tool name.") |
| return _control_tool_result(name, args or {}) |
| except Exception as exc: |
| return JSONResponse({"ok": False, "error": str(exc)}, status_code=400) |
|
|
|
|
| @app.post("/api/mcp/call") |
| async def mcp_call_alias(request: Request): |
| return await control_tool(request) |
|
|
|
|
| @app.get("/api/control/artifacts") |
| def control_artifacts(request: Request): |
| if not _control_authorized(request): |
| return _control_forbidden() |
| return {"ok": True, "artifacts": _list_control_artifacts()} |
|
|
|
|
| @app.post("/api/control/artifacts") |
| async def write_control_artifact(request: Request): |
| if not _control_authorized(request): |
| return _control_forbidden() |
| payload = await request.json() |
| try: |
| record = _write_control_artifact(payload.get("name") or payload.get("path"), payload.get("content") or "") |
| _control_emit("canvas5.artifact", {"action": "telecast", "text": f"Artifact written: {record['name']}", "artifact": record}) |
| return {"ok": True, "artifact": record} |
| except Exception as exc: |
| return JSONResponse({"ok": False, "error": str(exc)}, status_code=400) |
|
|
|
|
| @app.get("/api/control/artifacts/{name}") |
| def get_control_artifact(request: Request, name: str): |
| if not _control_authorized(request): |
| return _control_forbidden() |
| try: |
| safe = _safe_control_name(name, "") |
| except ValueError as exc: |
| return JSONResponse({"error": str(exc)}, status_code=400) |
| path = CONTROL_DIR / safe |
| if not safe or not path.is_file(): |
| return JSONResponse({"error": "Control artifact not found."}, status_code=404) |
| media = { |
| ".css": "text/css", |
| ".html": "text/html", |
| ".htm": "text/html", |
| ".json": "application/json", |
| ".svg": "image/svg+xml", |
| }.get(path.suffix.lower(), "text/plain") |
| return FileResponse(str(path), media_type=media, filename=path.name) |
|
|
|
|
| @app.post("/convert") |
| def convert(video: UploadFile = File(...), cols: int = Form(96), |
| fps: int = Form(0), color: str = Form("color")): |
| suffix = os.path.splitext(video.filename or "input.mp4")[1] or ".mp4" |
| tmp = tempfile.NamedTemporaryFile(delete=False, suffix=suffix) |
| try: |
| with tmp as fh: |
| shutil.copyfileobj(video.file, fh) |
| with CONVERT_GATE: |
| out_path, n, out_fps, ow, oh, duration, audio = convert_video( |
| tmp.name, cols, fps, color != "mono" |
| ) |
| except Exception as e: |
| return JSONResponse({"error": str(e)}, status_code=500) |
| finally: |
| try: |
| os.unlink(tmp.name) |
| except Exception: |
| pass |
| return FileResponse( |
| str(out_path), media_type="video/mp4", filename=out_path.name, |
| headers={"X-Canvas5-Frames": str(n), "X-Canvas5-Fps": str(int(out_fps)), |
| "X-Canvas5-Size": f"{ow}x{oh}", |
| "X-Canvas5-Duration": f"{duration:.3f}", |
| "X-Canvas5-Audio": "yes" if audio else "no"}, |
| ) |
|
|
|
|
| @app.post("/api/preview") |
| def create_browser_preview(video: UploadFile = File(...)): |
| suffix = os.path.splitext(video.filename or "input.mp4")[1] or ".mp4" |
| tmp = tempfile.NamedTemporaryFile(delete=False, suffix=suffix) |
| try: |
| with tmp as handle: |
| shutil.copyfileobj(video.file, handle) |
| with CONVERT_GATE: |
| out_path, duration, audio = transcode_browser_preview(tmp.name) |
| except Exception as exc: |
| return JSONResponse({"error": str(exc)}, status_code=500) |
| finally: |
| pathlib.Path(tmp.name).unlink(missing_ok=True) |
| return { |
| "ok": True, |
| "operation": "preview", |
| "artifact": out_path.name, |
| "artifact_url": f"/api/artifacts/{out_path.name}", |
| "bytes": out_path.stat().st_size, |
| "duration": duration, |
| "audio": audio, |
| } |
|
|
|
|
| @app.post("/api/url-jobs") |
| async def create_url_job(request: Request): |
| payload = await request.json() |
| try: |
| url = _validate_public_media_url(payload.get("url")) |
| except ValueError as exc: |
| return JSONResponse({"error": str(exc)}, status_code=400) |
| settings = payload.get("settings") if isinstance(payload.get("settings"), dict) else {} |
| operation = "preview" if str(payload.get("operation") or settings.get("operation") or "").lower() == "preview" else "convert" |
| settings = {**settings, "operation": operation} |
| job_id = uuid.uuid4().hex |
| _job_update( |
| job_id, |
| status="queued", |
| stage="Queued", |
| progress=0.0, |
| operation=operation, |
| source_url=url, |
| created_at=time.time(), |
| ) |
| threading.Thread( |
| target=_run_url_job, |
| args=(job_id, url, settings), |
| daemon=True, |
| name=f"canvas5-url-{operation}-{job_id[:8]}", |
| ).start() |
| return {"job_id": job_id, "status": "queued", "operation": operation} |
|
|
|
|
| @app.post("/api/uploads/init") |
| async def init_upload(request: Request): |
| payload = await request.json() |
| upload_id = uuid.uuid4().hex |
| filename = _safe_name(payload.get("filename")) |
| size = max(0, int(payload.get("size") or 0)) |
| upload_path = UPLOADS / f"{upload_id}-{filename}.part" |
| upload_path.touch() |
| meta = { |
| "upload_id": upload_id, |
| "filename": filename, |
| "size": size, |
| "uploaded": 0, |
| "path": str(upload_path), |
| "created_at": time.time(), |
| } |
| _write_json(UPLOADS / f"{upload_id}.json", meta) |
| return {"upload_id": upload_id, "chunk_size": CHUNK_SIZE, "uploaded": 0} |
|
|
|
|
| @app.put("/api/uploads/{upload_id}/chunk") |
| async def upload_chunk(upload_id: str, request: Request, offset: int = 0): |
| meta_path = UPLOADS / f"{upload_id}.json" |
| if not meta_path.exists(): |
| return JSONResponse({"error": "Upload session not found."}, status_code=404) |
| meta = json.loads(meta_path.read_text(encoding="utf-8")) |
| expected = int(meta.get("uploaded") or 0) |
| if offset != expected: |
| return JSONResponse( |
| {"error": f"Chunk offset mismatch. Expected {expected}, received {offset}.", "uploaded": expected}, |
| status_code=409, |
| ) |
| data = await request.body() |
| if not data: |
| return JSONResponse({"error": "Empty upload chunk."}, status_code=400) |
| path = pathlib.Path(meta["path"]) |
| with path.open("ab") as handle: |
| handle.write(data) |
| meta["uploaded"] = expected + len(data) |
| _write_json(meta_path, meta) |
| return {"uploaded": meta["uploaded"], "size": meta["size"]} |
|
|
|
|
| @app.post("/api/uploads/{upload_id}/complete") |
| async def complete_upload(upload_id: str, request: Request): |
| meta_path = UPLOADS / f"{upload_id}.json" |
| if not meta_path.exists(): |
| return JSONResponse({"error": "Upload session not found."}, status_code=404) |
| meta = json.loads(meta_path.read_text(encoding="utf-8")) |
| if meta["size"] and meta["uploaded"] != meta["size"]: |
| return JSONResponse( |
| {"error": f"Upload incomplete: {meta['uploaded']} of {meta['size']} bytes."}, |
| status_code=409, |
| ) |
| settings = await request.json() |
| source_path = pathlib.Path(meta["path"]) |
| suffix = pathlib.Path(meta["filename"]).suffix or ".mp4" |
| ready_path = UPLOADS / f"{upload_id}{suffix}" |
| source_path.replace(ready_path) |
| meta_path.unlink(missing_ok=True) |
| job_id = uuid.uuid4().hex |
| operation = "preview" if str(settings.get("operation") or "").lower() == "preview" else "convert" |
| job = { |
| "job_id": job_id, |
| "status": "queued", |
| "stage": "Queued", |
| "progress": 0.0, |
| "filename": meta["filename"], |
| "operation": operation, |
| "created_at": time.time(), |
| } |
| _job_update(job_id, **{key: value for key, value in job.items() if key != "job_id"}) |
| target = _run_preview_job if operation == "preview" else _run_conversion_job |
| args = (job_id, str(ready_path)) if operation == "preview" else (job_id, str(ready_path), settings) |
| threading.Thread( |
| target=target, |
| args=args, |
| daemon=True, |
| name=f"canvas5-{operation}-{job_id[:8]}", |
| ).start() |
| return job |
|
|
|
|
| @app.get("/api/uploads/{upload_id}") |
| def upload_status(upload_id: str): |
| """Report chunked-upload progress so a disconnected client can resume. |
| |
| The client stores its upload_id locally; on reconnect it reads this and |
| continues PUT /chunk from `uploaded` instead of restarting from byte 0. |
| """ |
| meta_path = UPLOADS / f"{_safe_name(upload_id, '')}.json" |
| if not upload_id or not meta_path.exists(): |
| return JSONResponse( |
| {"error": "Upload session not found.", "recoverable": False}, |
| status_code=404, |
| ) |
| meta = json.loads(meta_path.read_text(encoding="utf-8")) |
| size = int(meta.get("size") or 0) |
| uploaded = int(meta.get("uploaded") or 0) |
| return { |
| "upload_id": upload_id, |
| "filename": meta.get("filename"), |
| "size": size, |
| "uploaded": uploaded, |
| "chunk_size": CHUNK_SIZE, |
| "complete": bool(size and uploaded >= size), |
| "remaining": max(0, size - uploaded), |
| } |
|
|
|
|
| @app.get("/api/jobs/{job_id}") |
| def job_status(job_id: str): |
| with JOBS_LOCK: |
| job = JOBS.get(job_id) |
| if job is None: |
| path = JOBS_DIR / f"{job_id}.json" |
| if path.exists(): |
| job = json.loads(path.read_text(encoding="utf-8")) |
| if job is None: |
| return JSONResponse({"error": "Conversion job not found."}, status_code=404) |
| return job |
|
|
|
|
| @app.get("/api/artifacts/{name}") |
| def get_artifact(name: str): |
| safe = _safe_name(name, "") |
| path = BUCKET / safe |
| if not safe or not path.is_file(): |
| return JSONResponse({"error": "Artifact not found."}, status_code=404) |
| return FileResponse(str(path), media_type="video/mp4", filename=path.name) |
|
|
|
|
| |
|
|
| @app.get("/mcp/sse") |
| async def mcp_sse(request: Request): |
| if not _control_authorized(request): |
| return _control_forbidden() |
|
|
| def stream(): |
| subscriber = queue.Queue(maxsize=64) |
| with CONTROL_LOCK: |
| CONTROL_SUBSCRIBERS.append(subscriber) |
| |
| |
| base_url = str(request.base_url).rstrip('/') |
| yield f"event: endpoint\ndata: {base_url}/api/mcp/messages\n\n" |
| |
| try: |
| while True: |
| try: |
| event_name, payload = subscriber.get(timeout=15) |
| yield _sse_frame(event_name, payload) |
| except queue.Empty: |
| yield ": ping\n\n" |
| finally: |
| with CONTROL_LOCK: |
| if subscriber in CONTROL_SUBSCRIBERS: |
| CONTROL_SUBSCRIBERS.remove(subscriber) |
|
|
| return StreamingResponse( |
| stream(), |
| media_type="text/event-stream", |
| headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"}, |
| ) |
|
|
| @app.post("/api/mcp/messages") |
| async def mcp_messages(request: Request): |
| if not _control_authorized(request): |
| return _control_forbidden() |
| res = await _handle_mcp_message(request) |
| if res is None: |
| return Response(status_code=204) |
| return JSONResponse(res) |
|
|
|
|
| app.mount("/", StaticFiles(directory=".", html=True), name="app") |
|
|