bar-chart-race / app.py
cdechoch's picture
Deploy bar-chart-race API with Flask + Docker
5394240 verified
"""Flask API for bar-chart-race video generation.
Endpoints:
GET /api/health → {"status": "ok"}
GET /api/themes → JSON list of all 50 themes
POST /api/generate → accepts multipart form, returns MP4 bytes
"""
from __future__ import annotations
import json
import os
import tempfile
import uuid
from pathlib import Path
from flask import Flask, Response, jsonify, request
from flask_cors import CORS
# Ensure bar_race package is importable from this directory.
import sys
sys.path.insert(0, str(Path(__file__).resolve().parent))
from bar_race.config import Config
from bar_race.pipeline import run
from bar_race.themes import THEMES
app = Flask(__name__)
CORS(app)
SPACE_DIR = Path(__file__).resolve().parent
HEADSHOT_DIR = SPACE_DIR / "assets" / "headshots"
@app.route("/api/health", methods=["GET"])
def health():
return jsonify({"status": "ok"})
@app.route("/api/themes", methods=["GET"])
def themes():
themes_list = []
for slug in sorted(THEMES):
t = THEMES[slug]
themes_list.append({
"slug": t.slug,
"name": t.name,
"description": t.description,
"bg_type": t.bg_type,
"bg_colors": t.bg_colors,
"accent_color": t.accent_color,
"accent_secondary": t.accent_secondary,
"text_color": t.text_color,
"text_secondary_color": t.text_secondary_color,
"bar_radius": t.bar_radius,
"bar_gradient": t.bar_gradient,
"bar_border": t.bar_border,
"bar_team_stripe": t.bar_team_stripe,
"show_accent_line": t.show_accent_line,
"show_diagonal_slash": t.show_diagonal_slash,
"show_court_lines": t.show_court_lines,
"show_grid_lines": t.show_grid_lines,
"show_background_circle": t.show_background_circle,
"leader_glow": t.leader_glow,
"leader_outline": t.leader_outline,
"leader_underline": t.leader_underline,
"show_rank_numbers": t.show_rank_numbers,
"rank_giant_watermark": t.rank_giant_watermark,
"rank_number_style": t.rank_number_style,
"font_family": t.font_family,
"label_case": t.label_case,
"headshot_shape": t.headshot_shape,
"headshot_border": t.headshot_border,
"border_frame": t.border_frame,
"show_branding_tag": t.show_branding_tag,
"branding_text": t.branding_text,
"branding_color": t.branding_color,
"vignette": t.vignette,
"noise": t.noise,
"bar_shadow": t.bar_shadow,
"bar_opacity": t.bar_opacity,
"value_suffix": t.value_suffix,
"date_color": t.date_color,
"date_opacity": t.date_opacity,
"title_color": t.title_color,
})
return jsonify(themes_list)
@app.route("/api/generate", methods=["POST"])
def generate():
tmp_input = None
tmp_output = None
try:
# Parse config from form field or JSON body.
if request.content_type and "multipart/form-data" in request.content_type:
config_str = request.form.get("config", "{}")
config = json.loads(config_str)
# Handle uploaded file.
uploaded = request.files.get("file")
gsheet_url = config.get("gsheet_url")
if uploaded and uploaded.filename:
ext = Path(uploaded.filename).suffix
tmp = tempfile.NamedTemporaryFile(
delete=False, suffix=ext, prefix="bcr_input_"
)
uploaded.save(tmp)
tmp.close()
tmp_input = tmp.name
elif not gsheet_url:
return jsonify({"error": "No file or Google Sheets URL provided"}), 400
else:
config = request.get_json(force=True) or {}
gsheet_url = config.get("gsheet_url")
tmp_input = None
if not gsheet_url:
return jsonify({"error": "No file or Google Sheets URL provided"}), 400
# Build output path.
output_name = f"output_{uuid.uuid4().hex[:8]}.mp4"
tmp_output = tempfile.NamedTemporaryFile(
delete=False, suffix=".mp4", prefix="bcr_out_"
).name
# Build Config.
cfg = Config(
input_path=tmp_input,
gsheet_url=gsheet_url if not tmp_input else None,
output=tmp_output,
preset=config.get("preset", "reels"),
theme=config.get("theme", "midnight-premium"),
fps=int(config.get("fps", 30)),
duration_sec=float(config.get("duration", 30)),
top_n=int(config.get("top_n", 10)),
title=config.get("title", ""),
subtitle=config.get("subtitle", ""),
watermark=config.get("watermark", ""),
headshot_dir=str(HEADSHOT_DIR),
)
# Run pipeline (synchronous — HF Spaces have generous timeouts).
run(cfg)
# Read video into memory before cleanup.
video_bytes = Path(tmp_output).read_bytes()
return Response(
video_bytes,
mimetype="video/mp4",
headers={
"Content-Disposition": f'attachment; filename="{output_name}"',
"Content-Length": str(len(video_bytes)),
},
)
except Exception as e:
return jsonify({"error": str(e)}), 500
finally:
# Clean up temp files.
for path in (tmp_input, tmp_output):
if path:
try:
os.unlink(path)
except OSError:
pass
if __name__ == "__main__":
port = int(os.environ.get("PORT", 7860))
app.run(host="0.0.0.0", port=port, debug=False)