| from __future__ import annotations |
|
|
| from dotenv import load_dotenv |
|
|
| load_dotenv() |
|
|
| from concurrent.futures import ThreadPoolExecutor |
| from pathlib import Path |
| from threading import Lock |
| from typing import Any |
| from urllib.parse import quote |
| import html |
| import shutil |
| import uuid |
|
|
| import gradio as gr |
| from fastapi import File, Form, HTTPException, UploadFile |
| from fastapi.responses import FileResponse, HTMLResponse, JSONResponse |
|
|
| from pipeline import generate_roast_movie |
|
|
|
|
| APP_TITLE = "YeOldeCut" |
| APP_TAGLINE = "A tiny theatre for texts that deserved better." |
| ROOT_DIR = Path(__file__).resolve().parent |
| OUTPUTS_DIR = ROOT_DIR / "outputs" |
| UPLOAD_DIR = OUTPUTS_DIR / "_uploads" |
|
|
| EXAMPLES = { |
| "job": "Thanks for applying. We enjoyed learning about your background, but we have decided to move forward with candidates whose experience more closely matches the role.", |
| "dating": "I think you are great, but I am not really looking for anything serious right now. I hope you understand.", |
| "investor": "We loved the energy, but the fund is going to pass for now. The market feels a little early and we want to see more traction before revisiting.", |
| } |
| SAMPLE_VIDEO_URL = "https://res.cloudinary.com/dzhtwka6d/video/upload/v1781563310/a7992042-2a8d-44eb-a805-1543c1d0805a_alpzpg.mp4" |
| SAMPLE_PROMPT = "I've been lying to myself for months, pretending you were worth my time. You're not. You're selfish, draining, and honestly? A complete embarrassment. The sex was terrible, your 'ambition' is a joke, and I've never met anyone so desperate for validation. Don't text me again — I'm disgusted I ever touched you." |
|
|
| JOBS: dict[str, dict[str, Any]] = {} |
| JOBS_LOCK = Lock() |
| EXECUTOR = ThreadPoolExecutor(max_workers=1) |
|
|
|
|
| def _artifact_url(path: str | Path) -> str: |
| resolved = Path(path).resolve() |
| try: |
| relative = resolved.relative_to(ROOT_DIR) |
| except ValueError: |
| relative = resolved.relative_to(OUTPUTS_DIR) |
| relative = Path("outputs") / relative |
| return f"/artifact/{quote(relative.as_posix())}" |
|
|
|
|
| def _set_job(job_id: str, **updates: Any) -> None: |
| with JOBS_LOCK: |
| JOBS.setdefault(job_id, {}).update(updates) |
|
|
|
|
| def _get_job(job_id: str) -> dict[str, Any]: |
| with JOBS_LOCK: |
| job = JOBS.get(job_id) |
| if not job: |
| raise HTTPException(status_code=404, detail="Performance not found.") |
| return dict(job) |
|
|
|
|
| def _save_upload(job_id: str, upload: UploadFile | None) -> str | None: |
| if not upload or not upload.filename: |
| return None |
|
|
| UPLOAD_DIR.mkdir(parents=True, exist_ok=True) |
| suffix = Path(upload.filename).suffix or ".png" |
| path = UPLOAD_DIR / f"{job_id}{suffix}" |
| with path.open("wb") as file: |
| shutil.copyfileobj(upload.file, file) |
| return str(path) |
|
|
|
|
| def _run_job( |
| job_id: str, |
| message: str, |
| screenshot_path: str | None, |
| runtime_seconds: int, |
| scene_count: int, |
| ) -> None: |
| def progress(value: float, line: str) -> None: |
| _set_job(job_id, status="running", progress=value, line=line) |
|
|
| try: |
| progress(0.04, "The house lights fall.") |
| result = generate_roast_movie( |
| rejection_text=message, |
| image=screenshot_path, |
| runtime_seconds=runtime_seconds, |
| scene_count=scene_count, |
| progress_callback=progress, |
| ) |
| script = result.get("script") or {} |
| downloads = [ |
| {"label": "Final film", "url": _artifact_url(result["video_path"])}, |
| {"label": "Share caption", "url": _artifact_url(result["script_path"])}, |
| {"label": "Narration", "url": _artifact_url(result["voice_path"])}, |
| {"label": "Score", "url": _artifact_url(result["music_path"])}, |
| ] |
| for index, image_path in enumerate(result.get("image_paths") or [], start=1): |
| downloads.append({"label": f"Still {index}", "url": _artifact_url(image_path)}) |
|
|
| _set_job( |
| job_id, |
| status="done", |
| progress=1.0, |
| line="The curtain opens.", |
| result={ |
| "title": script.get("movie_title") or "The Court Has Spoken", |
| "logline": script.get("logline") or "A private text became public theatre.", |
| "caption": result.get("share_caption") or "", |
| "video_url": _artifact_url(result["video_path"]), |
| "downloads": downloads, |
| }, |
| ) |
| except Exception as exc: |
| _set_job( |
| job_id, |
| status="error", |
| progress=1.0, |
| line="The performance stumbled before the final bow.", |
| error=str(exc), |
| ) |
|
|
|
|
| def create_server() -> gr.Server: |
| server = gr.Server( |
| title=APP_TITLE, |
| docs_url=None, |
| redoc_url=None, |
| openapi_url=None, |
| ) |
|
|
| @server.get("/", response_class=HTMLResponse) |
| def theatre() -> str: |
| return THEATRE_HTML |
|
|
| @server.post("/api/perform") |
| async def perform( |
| message: str = Form(""), |
| runtime_seconds: int = Form(60), |
| scene_count: int = Form(6), |
| screenshot: UploadFile | None = File(None), |
| ) -> JSONResponse: |
| clean_message = (message or "").strip() |
| if not clean_message and not (screenshot and screenshot.filename): |
| raise HTTPException( |
| status_code=400, |
| detail="Bring a message or screenshot to the stage first.", |
| ) |
|
|
| job_id = uuid.uuid4().hex[:12] |
| screenshot_path = _save_upload(job_id, screenshot) |
| _set_job( |
| job_id, |
| status="queued", |
| progress=0.01, |
| line="The theatre inhales.", |
| ) |
| EXECUTOR.submit( |
| _run_job, |
| job_id, |
| clean_message, |
| screenshot_path, |
| int(runtime_seconds), |
| int(scene_count), |
| ) |
| return JSONResponse({"job_id": job_id}) |
|
|
| @server.get("/api/perform/{job_id}") |
| def perform_status(job_id: str) -> JSONResponse: |
| return JSONResponse(_get_job(job_id)) |
|
|
| @server.get("/artifact/{path:path}") |
| def artifact(path: str) -> FileResponse: |
| target = (ROOT_DIR / path).resolve() |
| outputs_root = OUTPUTS_DIR.resolve() |
| if not str(target).startswith(str(outputs_root)) or not target.exists(): |
| raise HTTPException(status_code=404, detail="Artifact not found.") |
| return FileResponse(target) |
|
|
| return server |
|
|
|
|
| def launch_app() -> None: |
| server.launch( |
| server_name="0.0.0.0", |
| show_error=True, |
| _frontend=False, |
| ) |
|
|
|
|
| def _example_attr(key: str) -> str: |
| return html.escape(EXAMPLES[key], quote=True) |
|
|
|
|
| def _sample_prompt_attr() -> str: |
| return html.escape(SAMPLE_PROMPT, quote=True) |
|
|
|
|
| def _sample_prompt_html() -> str: |
| return html.escape(SAMPLE_PROMPT) |
|
|
|
|
| THEATRE_HTML = f""" |
| <!doctype html> |
| <html lang="en"> |
| <head> |
| <meta charset="utf-8"> |
| <meta name="viewport" content="width=device-width, initial-scale=1"> |
| <meta name="theme-color" content="#080504"> |
| <title>{APP_TITLE}</title> |
| <style> |
| :root {{ |
| --blood: #7f1d18; |
| --blood-lit: #be4931; |
| --gold: #e0ad57; |
| --gold-soft: rgba(224, 173, 87, 0.2); |
| --velvet: #180806; |
| --velvet-deep: #080504; |
| --smoke: rgba(255, 244, 217, 0.72); |
| --paper: #f0d8a5; |
| --ink: #21130d; |
| --line: rgba(240, 216, 165, 0.2); |
| }} |
| |
| * {{ |
| box-sizing: border-box; |
| }} |
| |
| html {{ |
| min-height: 100%; |
| background: var(--velvet-deep); |
| color: var(--paper); |
| font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; |
| }} |
| |
| body {{ |
| min-height: 100vh; |
| margin: 0; |
| overflow-x: hidden; |
| background: |
| radial-gradient(circle at 50% -18%, rgba(255, 214, 126, 0.18), transparent 34rem), |
| radial-gradient(circle at 12% 42%, rgba(190, 73, 49, 0.18), transparent 24rem), |
| linear-gradient(180deg, #120604 0%, #080504 48%, #050303 100%); |
| }} |
| |
| button, |
| textarea, |
| input {{ |
| font: inherit; |
| }} |
| |
| .theatre {{ |
| position: relative; |
| min-height: 100vh; |
| isolation: isolate; |
| }} |
| |
| .theatre::before, |
| .theatre::after {{ |
| content: ""; |
| position: fixed; |
| top: -8vh; |
| bottom: -10vh; |
| width: min(21vw, 280px); |
| z-index: -1; |
| background: |
| repeating-linear-gradient(90deg, rgba(255,255,255,0.035) 0 2px, transparent 2px 34px), |
| linear-gradient(90deg, #260805, #7d211a 45%, #260805); |
| filter: drop-shadow(0 0 32px rgba(0,0,0,0.55)); |
| opacity: 0.92; |
| }} |
| |
| .theatre::before {{ |
| left: 0; |
| transform: skewX(-4deg); |
| border-right: 1px solid rgba(224,173,87,0.25); |
| }} |
| |
| .theatre::after {{ |
| right: 0; |
| transform: skewX(4deg); |
| border-left: 1px solid rgba(224,173,87,0.25); |
| }} |
| |
| .light-cone {{ |
| position: fixed; |
| left: 50%; |
| top: -14vh; |
| width: min(88vw, 1080px); |
| height: 78vh; |
| transform: translateX(-50%); |
| z-index: -1; |
| background: radial-gradient(ellipse at top, rgba(255, 236, 179, 0.2), rgba(224, 173, 87, 0.07) 34%, transparent 69%); |
| clip-path: polygon(34% 0, 66% 0, 100% 100%, 0 100%); |
| animation: breathe 6s ease-in-out infinite; |
| }} |
| |
| @keyframes breathe {{ |
| 0%, 100% {{ opacity: 0.72; }} |
| 50% {{ opacity: 1; }} |
| }} |
| |
| .house {{ |
| width: min(1180px, calc(100vw - 32px)); |
| margin: 0 auto; |
| padding: 18px 0 44px; |
| }} |
| |
| .mast {{ |
| min-height: calc(100vh - 62px); |
| display: grid; |
| grid-template-rows: auto 1fr auto; |
| gap: 14px; |
| }} |
| |
| .marquee {{ |
| display: flex; |
| justify-content: center; |
| flex-wrap: wrap; |
| gap: 8px clamp(22px, 8vw, 76px); |
| align-items: center; |
| width: min(760px, 100%); |
| margin: 0 auto; |
| padding: 6px 12px 0; |
| color: rgba(240, 216, 165, 0.78); |
| text-transform: uppercase; |
| font-size: 12px; |
| font-weight: 850; |
| }} |
| |
| .marquee span {{ |
| display: inline-flex; |
| align-items: center; |
| gap: 10px; |
| white-space: nowrap; |
| }} |
| |
| .marquee span::before, |
| .marquee span::after {{ |
| content: ""; |
| width: 7px; |
| height: 7px; |
| border-radius: 999px; |
| background: var(--gold); |
| box-shadow: 0 0 18px var(--gold); |
| }} |
| |
| .stage {{ |
| position: relative; |
| overflow: hidden; |
| display: grid; |
| place-items: center; |
| min-height: clamp(310px, 43vh, 500px); |
| border-radius: 0; |
| border: 0; |
| }} |
| |
| .stage::before {{ |
| content: ""; |
| position: absolute; |
| inset: -8% 8% 0; |
| background: |
| radial-gradient(ellipse at 50% 42%, rgba(255, 236, 179, 0.12), transparent 38%), |
| repeating-linear-gradient(90deg, rgba(255,255,255,0.02) 0 1px, transparent 1px 58px); |
| pointer-events: none; |
| mask-image: radial-gradient(ellipse, black 0 58%, transparent 76%); |
| }} |
| |
| .stage::after {{ |
| content: ""; |
| position: absolute; |
| left: 8%; |
| right: 8%; |
| bottom: -7%; |
| height: 22%; |
| border-radius: 50%; |
| background: radial-gradient(ellipse, rgba(224, 173, 87, 0.36), rgba(54, 22, 10, 0.2) 42%, transparent 72%); |
| filter: blur(5px); |
| }} |
| |
| .title-scene {{ |
| position: relative; |
| z-index: 2; |
| width: min(920px, calc(100% - 42px)); |
| text-align: center; |
| padding: clamp(32px, 6vh, 54px) 0 clamp(14px, 2vh, 22px); |
| }} |
| |
| .sigil {{ |
| margin: 0 auto 18px; |
| width: min(360px, 72vw); |
| border-top: 1px solid rgba(224,173,87,0.68); |
| border-bottom: 1px solid rgba(224,173,87,0.35); |
| padding: 9px 0; |
| color: var(--gold); |
| text-transform: uppercase; |
| font-size: 12px; |
| font-weight: 900; |
| }} |
| |
| h1 {{ |
| margin: 0; |
| color: #fff3cf; |
| font-family: Georgia, "Times New Roman", serif; |
| font-size: clamp(46px, 9.2vw, 116px); |
| line-height: 0.9; |
| font-weight: 900; |
| letter-spacing: 0; |
| text-shadow: 0 8px 0 rgba(67, 22, 14, 0.58), 0 0 50px rgba(224,173,87,0.18); |
| }} |
| |
| .tagline {{ |
| max-width: 660px; |
| margin: 18px auto 0; |
| color: var(--smoke); |
| font-family: Georgia, "Times New Roman", serif; |
| font-size: clamp(20px, 2.5vw, 34px); |
| line-height: 1.08; |
| }} |
| |
| .whisper {{ |
| max-width: 780px; |
| margin: 0 auto; |
| padding: 2px 12px 0; |
| color: rgba(240, 216, 165, 0.62); |
| text-align: center; |
| font-size: 14px; |
| line-height: 1.45; |
| }} |
| |
| .success-scene {{ |
| position: relative; |
| z-index: 3; |
| display: none; |
| width: min(720px, calc(100% - 42px)); |
| place-items: center; |
| text-align: center; |
| padding: clamp(36px, 6vh, 64px) 0 clamp(18px, 3vh, 30px); |
| }} |
| |
| .success-scene::before, |
| .success-scene::after {{ |
| content: ""; |
| position: absolute; |
| left: 50%; |
| width: min(520px, 72vw); |
| height: 1px; |
| transform: translateX(-50%); |
| background: linear-gradient(90deg, transparent, rgba(224,173,87,0.78), transparent); |
| box-shadow: 0 0 26px rgba(224,173,87,0.34); |
| pointer-events: none; |
| }} |
| |
| .success-scene::before {{ |
| top: 14px; |
| }} |
| |
| .success-scene::after {{ |
| bottom: 4px; |
| }} |
| |
| .success-mark {{ |
| width: 92px; |
| height: 92px; |
| display: grid; |
| place-items: center; |
| border-radius: 999px; |
| color: #2a0d08; |
| background: |
| radial-gradient(circle at 50% 18%, rgba(255,255,255,0.46), transparent 42%), |
| linear-gradient(135deg, #ffe6a9, var(--gold)); |
| box-shadow: 0 18px 46px rgba(224,173,87,0.26), 0 0 0 12px rgba(224,173,87,0.08); |
| font-family: Georgia, "Times New Roman", serif; |
| font-size: 28px; |
| font-weight: 900; |
| letter-spacing: 0.04em; |
| margin-bottom: 22px; |
| animation: sealGlow 2.8s ease-in-out infinite; |
| }} |
| |
| .success-scene h2 {{ |
| margin: 0; |
| color: #fff3cf; |
| font-family: Georgia, "Times New Roman", serif; |
| font-size: clamp(34px, 5vw, 66px); |
| line-height: 0.98; |
| text-shadow: 0 8px 0 rgba(67, 22, 14, 0.5), 0 0 48px rgba(224,173,87,0.2); |
| animation: verdictRise 700ms ease both; |
| }} |
| |
| .success-scene p {{ |
| max-width: 580px; |
| margin: 16px auto 0; |
| color: rgba(255, 244, 217, 0.76); |
| font-size: clamp(16px, 2vw, 20px); |
| line-height: 1.5; |
| animation: verdictRise 700ms ease 90ms both; |
| }} |
| |
| .watch {{ |
| margin-top: 28px; |
| min-height: 62px; |
| border: 0; |
| border-radius: 999px; |
| padding: 15px 26px; |
| color: #21130d; |
| background: |
| radial-gradient(circle at 50% 0, rgba(255,255,255,0.36), transparent 42%), |
| linear-gradient(135deg, #ffe7ad, #d6983e 78%); |
| box-shadow: 0 24px 62px rgba(224,173,87,0.28), inset 0 0 0 1px rgba(255,255,255,0.42); |
| cursor: pointer; |
| font-family: Georgia, "Times New Roman", serif; |
| font-size: 21px; |
| font-weight: 900; |
| animation: verdictRise 700ms ease 180ms both; |
| }} |
| |
| .watch:disabled {{ |
| opacity: 0.58; |
| cursor: wait; |
| }} |
| |
| .watch:not(:disabled):hover {{ |
| transform: translateY(-2px); |
| box-shadow: 0 28px 76px rgba(224,173,87,0.36), inset 0 0 0 1px rgba(255,255,255,0.5); |
| }} |
| |
| @keyframes verdictRise {{ |
| from {{ opacity: 0; transform: translateY(12px); }} |
| to {{ opacity: 1; transform: translateY(0); }} |
| }} |
| |
| @keyframes sealGlow {{ |
| 0%, 100% {{ transform: scale(1); box-shadow: 0 18px 46px rgba(224,173,87,0.26), 0 0 0 12px rgba(224,173,87,0.08); }} |
| 50% {{ transform: scale(1.04); box-shadow: 0 24px 66px rgba(224,173,87,0.38), 0 0 0 16px rgba(224,173,87,0.1); }} |
| }} |
| |
| .stage.has-result .title-scene {{ |
| display: none; |
| }} |
| |
| .stage.has-result .success-scene {{ |
| display: grid; |
| }} |
| |
| .stage.is-performing .title-scene {{ |
| transform: translateY(-6px); |
| transition: transform 700ms ease; |
| }} |
| |
| .stage.is-performing .sigil {{ |
| animation: pulseLine 1.4s ease-in-out infinite; |
| }} |
| |
| @keyframes pulseLine {{ |
| 0%, 100% {{ opacity: 0.55; }} |
| 50% {{ opacity: 1; }} |
| }} |
| |
| .orchestra {{ |
| position: relative; |
| display: grid; |
| grid-template-columns: minmax(0, 1fr); |
| gap: 12px; |
| justify-items: center; |
| padding: 0 6px; |
| margin-top: -28px; |
| z-index: 5; |
| }} |
| |
| .composer {{ |
| width: min(980px, 100%); |
| display: grid; |
| gap: 8px; |
| justify-items: start; |
| }} |
| |
| .note {{ |
| position: relative; |
| overflow: visible; |
| width: 100%; |
| border-radius: 999px; |
| background: |
| linear-gradient(135deg, rgba(251, 229, 184, 0.98), rgba(204, 145, 74, 0.95)), |
| var(--paper); |
| color: var(--ink); |
| box-shadow: 0 20px 64px rgba(0,0,0,0.42), inset 0 0 0 1px rgba(255,255,255,0.44); |
| transform: none; |
| }} |
| |
| .note::before {{ |
| content: ""; |
| position: absolute; |
| inset: 0; |
| background: |
| radial-gradient(circle at 18% 20%, rgba(255,255,255,0.24), transparent 13rem); |
| border-radius: inherit; |
| pointer-events: none; |
| }} |
| |
| .note-inner {{ |
| position: relative; |
| display: grid; |
| grid-template-columns: minmax(0, 1fr) auto; |
| align-items: center; |
| gap: 12px; |
| padding: 10px 12px 10px 22px; |
| }} |
| |
| textarea {{ |
| width: 100%; |
| min-height: 50px; |
| max-height: 76px; |
| resize: none; |
| overflow: auto; |
| border: 0; |
| outline: 0; |
| border-radius: 999px; |
| padding: 15px 24px 13px 18px; |
| background: transparent; |
| color: var(--ink); |
| font-size: 16px; |
| line-height: 1.35; |
| box-shadow: none; |
| }} |
| |
| textarea::-webkit-scrollbar {{ |
| width: 0; |
| height: 0; |
| }} |
| |
| textarea::placeholder {{ |
| color: rgba(33, 19, 13, 0.54); |
| }} |
| |
| .props {{ |
| width: auto; |
| display: flex; |
| align-items: center; |
| gap: 0; |
| justify-items: center; |
| }} |
| |
| .upload {{ |
| position: relative; |
| display: grid; |
| place-items: center; |
| width: 54px; |
| height: 54px; |
| min-height: 54px; |
| border: 1px solid rgba(33, 19, 13, 0.24); |
| border-radius: 999px; |
| text-align: center; |
| color: #fff2ce; |
| cursor: pointer; |
| background: |
| radial-gradient(circle at 50% 15%, rgba(255,255,255,0.26), transparent 38%), |
| linear-gradient(135deg, #ae3928, #64140f 76%); |
| box-shadow: 0 10px 26px rgba(96, 18, 12, 0.28); |
| font-size: 0; |
| flex: 0 0 auto; |
| }} |
| |
| .upload span::before {{ |
| content: "+"; |
| display: grid; |
| place-items: center; |
| width: 100%; |
| height: 100%; |
| font-family: Georgia, "Times New Roman", serif; |
| font-size: 34px; |
| line-height: 1; |
| font-weight: 900; |
| }} |
| |
| .upload input {{ |
| position: absolute; |
| inset: 0; |
| opacity: 0; |
| cursor: pointer; |
| }} |
| |
| .prompt-borrow {{ |
| display: grid; |
| justify-items: center; |
| gap: 10px; |
| color: #ffe8b7; |
| font-size: 12px; |
| font-weight: 900; |
| text-transform: uppercase; |
| padding-top : 1.5rem; |
| padding-bottom : 1.5rem; |
| }} |
| |
| .prompt-borrow > span {{ |
| border: 1px solid rgba(224, 173, 87, 0.34); |
| border-radius: 999px; |
| padding: 7px 14px; |
| background: rgba(20, 7, 5, 0.54); |
| box-shadow: 0 10px 26px rgba(0,0,0,0.24), inset 0 0 0 1px rgba(255,255,255,0.05); |
| text-shadow: 0 1px 12px rgba(224, 173, 87, 0.36); |
| margin-bottom : 0.5rem; |
| }} |
| |
| .chips {{ |
| justify-content: center; |
| display: flex; |
| flex-wrap: wrap; |
| gap: 8px; |
| }} |
| |
| .chip {{ |
| border: 1px solid rgba(255, 242, 206, 0.42); |
| border-radius: 999px; |
| padding: 9px 13px; |
| background: rgba(240, 216, 165, 0.88); |
| color: var(--ink); |
| cursor: pointer; |
| font-weight: 760; |
| box-shadow: 0 12px 26px rgba(0,0,0,0.22), inset 0 1px 0 rgba(255,255,255,0.34); |
| }} |
| |
| .call {{ |
| position: relative; |
| overflow: hidden; |
| display: grid; |
| place-items: center; |
| width: min(430px, 100%); |
| min-height: 76px; |
| border: 0; |
| border-radius: 999px; |
| padding: 18px 28px; |
| color: #fff2ce; |
| background: |
| radial-gradient(circle at 50% 0, rgba(255,255,255,0.22), transparent 38%), |
| linear-gradient(135deg, #b83927, #63140f 78%); |
| box-shadow: 0 28px 72px rgba(96, 18, 12, 0.54), inset 0 0 0 1px rgba(255,255,255,0.22); |
| font-family: Georgia, "Times New Roman", serif; |
| font-size: 23px; |
| font-weight: 900; |
| cursor: pointer; |
| text-align: center; |
| transition: filter 180ms ease, opacity 180ms ease, transform 180ms ease; |
| }} |
| |
| .call::before {{ |
| content: ""; |
| position: absolute; |
| inset: 0; |
| background: linear-gradient(110deg, transparent 0 34%, rgba(255,255,255,0.28) 46%, transparent 58%); |
| opacity: 0; |
| transform: translateX(-120%); |
| pointer-events: none; |
| }} |
| |
| .call-text {{ |
| position: relative; |
| z-index: 1; |
| }} |
| |
| .call.is-loading {{ |
| filter: none; |
| opacity: 1; |
| cursor: wait; |
| box-shadow: 0 32px 88px rgba(184, 57, 39, 0.64), inset 0 0 0 1px rgba(255,255,255,0.26); |
| animation: buttonPulse 1.9s ease-in-out infinite; |
| }} |
| |
| .call.is-loading::before {{ |
| opacity: 1; |
| animation: curtainSweep 2.4s ease-in-out infinite; |
| }} |
| |
| .call.is-loading .call-text {{ |
| animation: loadingWords 2.4s ease-in-out infinite; |
| }} |
| |
| .call:disabled:not(.is-loading) {{ |
| filter: grayscale(0.6); |
| opacity: 0.64; |
| cursor: not-allowed; |
| }} |
| |
| .sample-chip {{ |
| color: #ffe8b7; |
| background: rgba(20, 7, 5, 0.58); |
| border-color: rgba(224, 173, 87, 0.44); |
| }} |
| |
| .sample-copy {{ |
| min-width: 0; |
| max-width: 600px; |
| }} |
| |
| .sample-kicker {{ |
| margin: 0 0 10px; |
| color: var(--gold); |
| font-size: 12px; |
| font-weight: 900; |
| letter-spacing: 0.14em; |
| text-transform: uppercase; |
| }} |
| |
| .sample-copy h2 {{ |
| max-width: 560px; |
| margin: 0; |
| color: #fff3cf; |
| font-family: Georgia, "Times New Roman", serif; |
| font-size: clamp(28px, 4.5vw, 56px); |
| line-height: 0.98; |
| text-shadow: 0 6px 0 rgba(67, 22, 14, 0.44), 0 0 42px rgba(224,173,87,0.16); |
| }} |
| |
| .sample-prompt-label {{ |
| margin: 18px 0 8px; |
| color: rgba(255, 244, 217, 0.52); |
| font-size: 12px; |
| font-weight: 900; |
| text-transform: uppercase; |
| }} |
| |
| .sample-prompt {{ |
| max-width: 600px; |
| margin: 0; |
| padding-left: 18px; |
| border-left: 1px solid rgba(224,173,87,0.58); |
| color: rgba(255, 244, 217, 0.82); |
| font-family: Georgia, "Times New Roman", serif; |
| font-size: clamp(16px, 1.7vw, 20px); |
| line-height: 1.32; |
| }} |
| |
| .sample-load {{ |
| margin-top: 16px; |
| border: 1px solid rgba(255, 242, 206, 0.32); |
| border-radius: 999px; |
| padding: 10px 15px; |
| color: #28140c; |
| background: linear-gradient(135deg, rgba(255, 231, 173, 0.96), rgba(214, 152, 62, 0.92)); |
| cursor: pointer; |
| font-size: 13px; |
| font-weight: 860; |
| box-shadow: 0 14px 34px rgba(0,0,0,0.26), inset 0 1px 0 rgba(255,255,255,0.32); |
| }} |
| |
| .sample-load:hover {{ |
| transform: translateY(-1px); |
| box-shadow: 0 18px 42px rgba(0,0,0,0.32), inset 0 1px 0 rgba(255,255,255,0.42); |
| }} |
| |
| .sample-screen {{ |
| position: relative; |
| width: min(100%, 330px); |
| aspect-ratio: 9 / 16; |
| padding: 8px; |
| border-radius: 34px; |
| background: |
| linear-gradient(145deg, rgba(255,231,173,0.72), rgba(127,29,24,0.82) 42%, rgba(18,6,4,0.96)); |
| box-shadow: 0 28px 86px rgba(0,0,0,0.58), 0 0 58px rgba(224,173,87,0.14); |
| transform: rotate(1.4deg); |
| }} |
| |
| .sample-screen::before {{ |
| content: ""; |
| position: absolute; |
| inset: -18px; |
| z-index: -1; |
| border-radius: 42px; |
| background: radial-gradient(ellipse at 50% 20%, rgba(224,173,87,0.22), transparent 62%); |
| filter: blur(10px); |
| }} |
| |
| .sample-screen video {{ |
| width: 100%; |
| height: 100%; |
| display: block; |
| object-fit: cover; |
| border-radius: 27px; |
| background: #050303; |
| }} |
| |
| .sample-badge {{ |
| position: absolute; |
| left: 18px; |
| top: 18px; |
| border: 1px solid rgba(255, 242, 206, 0.26); |
| border-radius: 999px; |
| padding: 6px 9px; |
| color: #fff2ce; |
| background: rgba(20, 7, 5, 0.64); |
| backdrop-filter: blur(10px); |
| font-size: 11px; |
| font-weight: 900; |
| text-transform: uppercase; |
| letter-spacing: 0.08em; |
| pointer-events: none; |
| }} |
| |
| @keyframes curtainSweep {{ |
| 0% {{ transform: translateX(-120%); }} |
| 52%, 100% {{ transform: translateX(120%); }} |
| }} |
| |
| @keyframes buttonPulse {{ |
| 0%, 100% {{ transform: translateY(0); }} |
| 50% {{ transform: translateY(-2px); }} |
| }} |
| |
| @keyframes loadingWords {{ |
| 0%, 100% {{ opacity: 0.45; transform: translateY(2px); }} |
| 24%, 76% {{ opacity: 1; transform: translateY(0); }} |
| }} |
| |
| .image-preview {{ |
| display: none; |
| position: relative; |
| width: 58px; |
| aspect-ratio: 1; |
| justify-self: start; |
| overflow: hidden; |
| border-radius: 14px; |
| border: 1px solid rgba(224, 173, 87, 0.34); |
| background: rgba(255, 244, 217, 0.08); |
| box-shadow: 0 10px 26px rgba(0,0,0,0.26); |
| }} |
| |
| .image-preview.show {{ |
| display: block; |
| }} |
| |
| .image-preview img {{ |
| width: 100%; |
| height: 100%; |
| object-fit: cover; |
| display: block; |
| }} |
| |
| .clear-image {{ |
| position: absolute; |
| right: 4px; |
| top: 4px; |
| width: 20px; |
| height: 20px; |
| border: 0; |
| border-radius: 999px; |
| color: #fff2ce; |
| background: rgba(20, 7, 5, 0.72); |
| cursor: pointer; |
| font-size: 12px; |
| line-height: 1; |
| }} |
| |
| .afterword {{ |
| display: none; |
| grid-template-columns: minmax(0, 1fr) auto; |
| gap: 16px; |
| align-items: end; |
| padding: 18px 8px 0; |
| }} |
| |
| .afterword.show {{ |
| display: grid; |
| }} |
| |
| .review {{ |
| color: rgba(240,216,165,0.86); |
| }} |
| |
| .review h2 {{ |
| margin: 0; |
| font-family: Georgia, "Times New Roman", serif; |
| font-size: clamp(26px, 4vw, 48px); |
| line-height: 1; |
| color: #fff2ce; |
| }} |
| |
| .review p {{ |
| max-width: 680px; |
| margin: 10px 0 0; |
| color: rgba(240,216,165,0.68); |
| line-height: 1.5; |
| }} |
| |
| .downloads {{ |
| display: flex; |
| flex-wrap: wrap; |
| justify-content: flex-end; |
| gap: 8px; |
| }} |
| |
| .downloads a {{ |
| border: 1px solid rgba(224,173,87,0.36); |
| border-radius: 999px; |
| padding: 9px 12px; |
| color: #ffe7ad; |
| text-decoration: none; |
| background: rgba(224,173,87,0.1); |
| font-size: 13px; |
| font-weight: 820; |
| }} |
| |
| .caption {{ |
| margin-top: 14px; |
| width: min(720px, 100%); |
| border-left: 2px solid rgba(224,173,87,0.56); |
| padding-left: 14px; |
| color: rgba(255,242,206,0.84); |
| font-size: 15px; |
| line-height: 1.5; |
| }} |
| |
| .error {{ |
| color: #ffd2c2; |
| }} |
| |
| .film-modal {{ |
| position: fixed; |
| inset: 0; |
| z-index: 30; |
| display: none; |
| place-items: center; |
| padding: clamp(18px, 4vw, 46px); |
| background: |
| radial-gradient(circle at 50% 10%, rgba(224,173,87,0.16), transparent 28rem), |
| rgba(5, 3, 3, 0.9); |
| backdrop-filter: blur(16px); |
| }} |
| |
| .film-modal.open {{ |
| display: grid; |
| }} |
| |
| .sample-modal {{ |
| z-index: 34; |
| overflow: hidden; |
| background: |
| radial-gradient(circle at 22% 18%, rgba(224,173,87,0.18), transparent 22rem), |
| radial-gradient(circle at 70% 0%, rgba(190,73,49,0.18), transparent 24rem), |
| rgba(5, 3, 3, 0.92); |
| }} |
| |
| .sample-shell {{ |
| width: min(1040px, 100%); |
| max-height: min(92vh, 820px); |
| display: grid; |
| grid-template-columns: minmax(0, 1fr) minmax(240px, 330px); |
| align-items: center; |
| gap: clamp(24px, 5vw, 58px); |
| overflow-y: auto; |
| padding: 10px 4px; |
| }} |
| |
| .sample-shell .sample-copy {{ |
| justify-self: end; |
| }} |
| |
| .sample-shell .sample-screen {{ |
| justify-self: start; |
| }} |
| |
| .sample-modal .modal-close {{ |
| top: 14px; |
| right: 14px; |
| z-index: 36; |
| background: rgba(20, 7, 5, 0.86); |
| }} |
| |
| .film-shell {{ |
| width: min(92vw, 520px); |
| max-height: min(92vh, 920px); |
| display: grid; |
| place-items: center; |
| }} |
| |
| .film-frame {{ |
| width: min(92vw, 430px); |
| aspect-ratio: 9 / 16; |
| overflow: hidden; |
| border-radius: 30px; |
| border: 1px solid rgba(224, 173, 87, 0.5); |
| background: #050303; |
| box-shadow: 0 0 0 12px rgba(20, 7, 5, 0.62), 0 34px 110px rgba(0,0,0,0.86); |
| }} |
| |
| .film-frame video {{ |
| width: 100%; |
| height: 100%; |
| display: block; |
| object-fit: contain; |
| background: #050303; |
| }} |
| |
| .modal-close {{ |
| position: fixed; |
| right: clamp(16px, 4vw, 34px); |
| top: clamp(16px, 4vw, 34px); |
| width: 46px; |
| height: 46px; |
| border: 1px solid rgba(255, 242, 206, 0.22); |
| border-radius: 999px; |
| color: #fff2ce; |
| background: rgba(20, 7, 5, 0.72); |
| cursor: pointer; |
| font-size: 22px; |
| font-weight: 800; |
| line-height: 1; |
| box-shadow: 0 14px 36px rgba(0,0,0,0.36); |
| }} |
| |
| body.modal-open {{ |
| overflow: hidden; |
| }} |
| |
| @media (max-width: 880px) {{ |
| .house {{ |
| width: min(100% - 18px, 620px); |
| padding-top: 10px; |
| }} |
| |
| .stage {{ |
| min-height: 330px; |
| border-radius: 0; |
| }} |
| |
| .title-scene {{ |
| padding-top: 38px; |
| }} |
| |
| h1 {{ |
| font-size: clamp(48px, 16vw, 92px); |
| }} |
| |
| .orchestra, |
| .afterword {{ |
| grid-template-columns: 1fr; |
| }} |
| |
| .sample-shell {{ |
| grid-template-columns: 1fr; |
| justify-items: center; |
| gap: 18px; |
| max-height: calc(100vh - 24px); |
| overflow-y: auto; |
| padding: 48px 8px 24px; |
| }} |
| |
| .sample-shell .sample-copy {{ |
| justify-self: center; |
| text-align: center; |
| }} |
| |
| .sample-copy h2 {{ |
| font-size: clamp(30px, 8vw, 42px); |
| }} |
| |
| .sample-prompt {{ |
| text-align: left; |
| font-size: 15px; |
| }} |
| |
| .sample-shell .sample-screen {{ |
| justify-self: center; |
| width: min(76vw, 330px); |
| transform: none; |
| }} |
| |
| .call {{ |
| width: 100%; |
| min-height: 72px; |
| }} |
| |
| .note-inner {{ |
| padding-left: 18px; |
| }} |
| |
| .theatre::before, |
| .theatre::after {{ |
| width: 74px; |
| opacity: 0.64; |
| }} |
| |
| .marquee {{ |
| font-size: 10px; |
| gap: 7px 18px; |
| padding-top: 4px; |
| }} |
| |
| .downloads {{ |
| justify-content: flex-start; |
| }} |
| |
| .film-frame {{ |
| width: min(82vw, 390px); |
| }} |
| }} |
| </style> |
| </head> |
| <body> |
| <main class="theatre"> |
| <div class="light-cone"></div> |
| <div class="house"> |
| <section class="mast"> |
| <div class="marquee"> |
| <span>Now performing</span> |
| <span>Admission: one terrible text</span> |
| </div> |
| |
| <section id="stage" class="stage" aria-live="polite"> |
| <div class="title-scene"> |
| <div class="sigil">The Royal Theatre of Rejections</div> |
| <h1>{APP_TITLE}</h1> |
| <p class="tagline">{APP_TAGLINE}</p> |
| </div> |
| <div class="success-scene" id="successScene"> |
| <div class="success-mark" aria-hidden="true">FIN</div> |
| <h2 id="successTitle">The verdict has arrived.</h2> |
| <p id="successCopy">The reel is sealed, the balcony is silent, and the court is prepared to be ridiculous.</p> |
| <button id="watchFilm" class="watch" type="button" disabled>Enter the screening room</button> |
| </div> |
| </section> |
| |
| <form id="playbill" class="orchestra"> |
| <div class="composer"> |
| <div id="imagePreview" class="image-preview" aria-live="polite"> |
| <img id="previewImage" alt="Selected screenshot preview"> |
| <button id="clearImage" class="clear-image" type="button" aria-label="Remove selected image">x</button> |
| </div> |
| <div class="note"> |
| <div class="note-inner"> |
| <textarea id="message" name="message" placeholder="Paste the rejection, breakup, ghosting note, or polite corporate dagger here..."></textarea> |
| <div class="props"> |
| <label class="upload"> |
| <input id="screenshot" name="screenshot" type="file" accept="image/*"> |
| <span id="uploadLabel">Or slip in a screenshot</span> |
| </label> |
| </div> |
| </div> |
| </div> |
| </div> |
| <div class="prompt-borrow"> |
| <span>Borrow a dramatic prompt</span> |
| <div class="chips"> |
| <button class="chip" type="button" data-example="{_example_attr('job')}">Job letter</button> |
| <button class="chip" type="button" data-example="{_example_attr('dating')}">Soft heartbreak</button> |
| <button class="chip" type="button" data-example="{_example_attr('investor')}">Investor pass</button> |
| <button id="openSample" class="chip sample-chip" type="button">See sample</button> |
| </div> |
| </div> |
| <button id="summon" class="call" type="submit"><span id="summonText" class="call-text">Raise the curtain</span></button> |
| </form> |
| |
| <section id="afterword" class="afterword"> |
| <div class="review"> |
| <h2 id="title"></h2> |
| <p id="logline"></p> |
| <div id="caption" class="caption"></div> |
| </div> |
| <nav id="downloads" class="downloads" aria-label="Downloads"></nav> |
| </section> |
| </section> |
| </div> |
| </main> |
| |
| <div id="sampleModal" class="film-modal sample-modal" aria-hidden="true" role="dialog" aria-modal="true" aria-label="Sample generated film"> |
| <button id="closeSample" class="modal-close" type="button" aria-label="Close sample">x</button> |
| <div class="sample-shell"> |
| <div class="sample-copy"> |
| <p class="sample-kicker">Sample from the stage</p> |
| <h2>A finished cut, already sentenced.</h2> |
| <p class="sample-prompt-label">Generation prompt</p> |
| <blockquote class="sample-prompt">{_sample_prompt_html()}</blockquote> |
| <button id="sampleLoad" class="sample-load" type="button" data-example="{_sample_prompt_attr()}">Load this prompt</button> |
| </div> |
| <div class="sample-screen"> |
| <span class="sample-badge">Sample MP4</span> |
| <video id="sampleFilm" src="{SAMPLE_VIDEO_URL}" controls playsinline preload="metadata"></video> |
| </div> |
| </div> |
| </div> |
| |
| <div id="filmModal" class="film-modal" aria-hidden="true" role="dialog" aria-modal="true" aria-label="Generated performance"> |
| <button id="closeFilm" class="modal-close" type="button" aria-label="Close video">x</button> |
| <div class="film-shell"> |
| <div class="film-frame"> |
| <video id="film" controls playsinline preload="metadata"></video> |
| </div> |
| </div> |
| </div> |
| |
| <script> |
| const stage = document.querySelector("#stage"); |
| const form = document.querySelector("#playbill"); |
| const message = document.querySelector("#message"); |
| const screenshot = document.querySelector("#screenshot"); |
| const uploadLabel = document.querySelector("#uploadLabel"); |
| const imagePreview = document.querySelector("#imagePreview"); |
| const previewImage = document.querySelector("#previewImage"); |
| const clearImage = document.querySelector("#clearImage"); |
| const summon = document.querySelector("#summon"); |
| const summonText = document.querySelector("#summonText"); |
| const successTitle = document.querySelector("#successTitle"); |
| const successCopy = document.querySelector("#successCopy"); |
| const watchFilm = document.querySelector("#watchFilm"); |
| const openSample = document.querySelector("#openSample"); |
| const sampleModal = document.querySelector("#sampleModal"); |
| const closeSample = document.querySelector("#closeSample"); |
| const sampleFilm = document.querySelector("#sampleFilm"); |
| const sampleLoad = document.querySelector("#sampleLoad"); |
| const sampleShell = document.querySelector("#sampleModal .sample-shell"); |
| const filmModal = document.querySelector("#filmModal"); |
| const closeFilm = document.querySelector("#closeFilm"); |
| const film = document.querySelector("#film"); |
| const afterword = document.querySelector("#afterword"); |
| const title = document.querySelector("#title"); |
| const logline = document.querySelector("#logline"); |
| const caption = document.querySelector("#caption"); |
| const downloads = document.querySelector("#downloads"); |
| |
| const stageLines = [ |
| "The house lights fall.", |
| "A quill scratches in the balcony.", |
| "The chorus gasps quietly.", |
| "Someone important adjusts a ridiculous hat.", |
| "The spotlight searches for the wound.", |
| "The curtain is thinking about it." |
| ]; |
| const DEFAULT_RUNTIME_SECONDS = "60"; |
| const DEFAULT_SCENE_COUNT = "6"; |
| let lineTimer = null; |
| let isPerforming = false; |
| let previewUrl = null; |
| let idleSummonText = "Raise the curtain"; |
| let currentVideoUrl = ""; |
| |
| function hasEvidence() {{ |
| return Boolean(message.value.trim() || screenshot.files?.length); |
| }} |
| |
| function setSummonText(text) {{ |
| summonText.textContent = text || idleSummonText; |
| }} |
| |
| function updateSummonState() {{ |
| summon.disabled = isPerforming || !hasEvidence(); |
| }} |
| |
| function clearPreviewUrl() {{ |
| if (previewUrl) URL.revokeObjectURL(previewUrl); |
| previewUrl = null; |
| }} |
| |
| function updateImagePreview() {{ |
| clearPreviewUrl(); |
| const file = screenshot.files?.[0]; |
| uploadLabel.textContent = file?.name || "Or slip in a screenshot"; |
| |
| if (!file) {{ |
| previewImage.removeAttribute("src"); |
| imagePreview.classList.remove("show"); |
| updateSummonState(); |
| return; |
| }} |
| |
| previewUrl = URL.createObjectURL(file); |
| previewImage.src = previewUrl; |
| imagePreview.classList.add("show"); |
| updateSummonState(); |
| }} |
| |
| function setLoadingLine(text) {{ |
| if (isPerforming && text) setSummonText(text); |
| }} |
| |
| function beginTheatreLines() {{ |
| let index = 0; |
| clearInterval(lineTimer); |
| setLoadingLine(stageLines[index]); |
| index += 1; |
| lineTimer = setInterval(() => {{ |
| if (!stage.classList.contains("is-performing")) return; |
| setLoadingLine(stageLines[index % stageLines.length]); |
| index += 1; |
| }}, 2800); |
| }} |
| |
| function hasOpenModal() {{ |
| return filmModal.classList.contains("open") || sampleModal.classList.contains("open"); |
| }} |
| |
| function stopTheatreLines() {{ |
| clearInterval(lineTimer); |
| lineTimer = null; |
| summon.classList.remove("is-loading"); |
| }} |
| |
| function openFilmModal() {{ |
| if (!currentVideoUrl) return; |
| filmModal.classList.add("open"); |
| filmModal.setAttribute("aria-hidden", "false"); |
| document.body.classList.add("modal-open"); |
| film.focus(); |
| }} |
| |
| function closeFilmModal(returnFocus = true) {{ |
| film.pause(); |
| filmModal.classList.remove("open"); |
| filmModal.setAttribute("aria-hidden", "true"); |
| if (!hasOpenModal()) document.body.classList.remove("modal-open"); |
| if (returnFocus) watchFilm.focus(); |
| }} |
| |
| function openSampleModal() {{ |
| sampleModal.scrollTop = 0; |
| sampleShell.scrollTop = 0; |
| sampleModal.classList.add("open"); |
| sampleModal.setAttribute("aria-hidden", "false"); |
| document.body.classList.add("modal-open"); |
| sampleFilm.focus(); |
| }} |
| |
| function closeSampleModal(returnFocus = true) {{ |
| sampleFilm.pause(); |
| sampleModal.classList.remove("open"); |
| sampleModal.setAttribute("aria-hidden", "true"); |
| if (!hasOpenModal()) document.body.classList.remove("modal-open"); |
| if (returnFocus) openSample.focus(); |
| }} |
| |
| document.querySelectorAll("[data-example]").forEach((button) => {{ |
| button.addEventListener("click", () => {{ |
| message.value = button.dataset.example || ""; |
| message.focus(); |
| updateSummonState(); |
| }}); |
| }}); |
| |
| message.addEventListener("input", updateSummonState); |
| screenshot.addEventListener("change", updateImagePreview); |
| clearImage.addEventListener("click", () => {{ |
| screenshot.value = ""; |
| updateImagePreview(); |
| }}); |
| watchFilm.addEventListener("click", openFilmModal); |
| closeFilm.addEventListener("click", closeFilmModal); |
| openSample.addEventListener("click", openSampleModal); |
| closeSample.addEventListener("click", closeSampleModal); |
| sampleLoad.addEventListener("click", () => closeSampleModal(false)); |
| filmModal.addEventListener("click", (event) => {{ |
| if (event.target === filmModal) closeFilmModal(); |
| }}); |
| sampleModal.addEventListener("click", (event) => {{ |
| if (event.target === sampleModal) closeSampleModal(); |
| }}); |
| document.addEventListener("keydown", (event) => {{ |
| if (event.key !== "Escape") return; |
| if (sampleModal.classList.contains("open")) closeSampleModal(); |
| if (filmModal.classList.contains("open")) closeFilmModal(); |
| }}); |
| updateSummonState(); |
| |
| async function poll(jobId) {{ |
| const response = await fetch(`/api/perform/${{jobId}}`); |
| if (!response.ok) throw new Error("The theatre lost the playbill."); |
| const job = await response.json(); |
| setLoadingLine(job.line); |
| |
| if (job.status === "done") {{ |
| stopTheatreLines(); |
| stage.classList.remove("is-performing"); |
| stage.classList.add("has-result"); |
| isPerforming = false; |
| idleSummonText = "Stage another"; |
| setSummonText(idleSummonText); |
| updateSummonState(); |
| currentVideoUrl = job.result.video_url; |
| film.src = currentVideoUrl; |
| watchFilm.disabled = false; |
| const resultTitle = job.result.title || "The Court Has Spoken"; |
| const resultLogline = job.result.logline || "The tiny theatre has finished its work. Press play when you want the drama in full."; |
| successTitle.textContent = "The verdict has arrived."; |
| successCopy.textContent = resultLogline || "The reel is sealed, the balcony is silent, and the court is prepared to be ridiculous."; |
| title.textContent = resultTitle; |
| logline.textContent = resultLogline; |
| caption.textContent = job.result.caption || ""; |
| downloads.innerHTML = ""; |
| (job.result.downloads || []).forEach((item) => {{ |
| const link = document.createElement("a"); |
| link.href = item.url; |
| link.textContent = item.label; |
| link.download = ""; |
| downloads.appendChild(link); |
| }}); |
| afterword.classList.add("show"); |
| return; |
| }} |
| |
| if (job.status === "error") {{ |
| stopTheatreLines(); |
| stage.classList.remove("is-performing"); |
| isPerforming = false; |
| idleSummonText = "Try the scene again"; |
| setSummonText(idleSummonText); |
| summon.title = job.error || "The performance collapsed backstage."; |
| updateSummonState(); |
| return; |
| }} |
| |
| setTimeout(() => poll(jobId).catch(showError), 1400); |
| }} |
| |
| function showError(error) {{ |
| stopTheatreLines(); |
| stage.classList.remove("is-performing"); |
| isPerforming = false; |
| idleSummonText = "Try the scene again"; |
| setSummonText(idleSummonText); |
| summon.title = error.message || error; |
| updateSummonState(); |
| }} |
| |
| form.addEventListener("submit", async (event) => {{ |
| event.preventDefault(); |
| if (!message.value.trim() && !screenshot.files?.length) {{ |
| setSummonText("Bring a message first"); |
| setTimeout(() => {{ |
| if (!isPerforming) setSummonText(idleSummonText); |
| }}, 1600); |
| return; |
| }} |
| |
| isPerforming = true; |
| summon.classList.add("is-loading"); |
| summon.title = ""; |
| updateSummonState(); |
| setSummonText("Curtain rising"); |
| stage.classList.remove("has-result"); |
| stage.classList.add("is-performing"); |
| afterword.classList.remove("show"); |
| currentVideoUrl = ""; |
| watchFilm.disabled = true; |
| closeFilmModal(false); |
| closeSampleModal(false); |
| film.removeAttribute("src"); |
| film.load(); |
| beginTheatreLines(); |
| |
| const data = new FormData(form); |
| data.set("runtime_seconds", DEFAULT_RUNTIME_SECONDS); |
| data.set("scene_count", DEFAULT_SCENE_COUNT); |
| try {{ |
| const response = await fetch("/api/perform", {{ |
| method: "POST", |
| body: data |
| }}); |
| if (!response.ok) {{ |
| const detail = await response.json().catch(() => ({{ detail: "The curtain refused to rise." }})); |
| throw new Error(detail.detail || "The curtain refused to rise."); |
| }} |
| const payload = await response.json(); |
| poll(payload.job_id).catch(showError); |
| }} catch (error) {{ |
| showError(error); |
| }} |
| }}); |
| </script> |
| </body> |
| </html> |
| """ |
|
|
|
|
| server = create_server() |
|
|
|
|
| if __name__ == "__main__": |
| launch_app() |
|
|