sagnik-mukherjee's picture
Deploy provenance-first OpenComic-Continue research UI
42be466 verified
Raw
History Blame Contribute Delete
16.4 kB
from __future__ import annotations
import base64
import io
import json
import os
import tempfile
import zipfile
from pathlib import Path
import gradio as gr
import httpx
from PIL import Image
try:
import spaces
except ImportError: # Local CPU development; Hugging Face provides this module in the Space.
class _LocalSpaces:
@staticmethod
def GPU(*_args, **_kwargs):
return lambda function: function
spaces = _LocalSpaces()
API_URL = os.getenv("MODAL_API_URL", "http://127.0.0.1:8000").rstrip("/")
API_TOKEN = os.getenv("OPENCOMIC_API_TOKEN", "")
@spaces.GPU(duration=60)
def zerogpu_compatibility_probe() -> str:
"""Satisfy the existing ZeroGPU Space hardware contract; normal UI calls use Modal."""
return "OpenComic uses its authenticated Modal renderer; no Hugging Face GPU is required."
def _headers() -> dict[str, str]:
return {"Authorization": f"Bearer {API_TOKEN}"} if API_TOKEN else {}
def _upload_payload(files: list[str] | None) -> tuple[bytes, str, str]:
paths = [Path(item) for item in files or []]
if not paths:
raise gr.Error("Upload at least one image, PDF, CBZ, or ZIP file.")
if len(paths) == 1:
return paths[0].read_bytes(), paths[0].name, "application/octet-stream"
stream = io.BytesIO()
with zipfile.ZipFile(stream, "w", zipfile.ZIP_DEFLATED) as archive:
for index, path in enumerate(paths):
archive.writestr(f"{index:04d}{path.suffix.lower()}", path.read_bytes())
return stream.getvalue(), "uploaded-pages.cbz", "application/vnd.comicbook+zip"
def analyze(files: list[str] | None, reading_direction: str, continuity_notes: str):
payload, name, mime = _upload_payload(files)
with httpx.Client(timeout=900) as client:
response = client.post(
f"{API_URL}/analyze-comic",
params={
"reading_direction": reading_direction,
"continuity_notes": continuity_notes,
},
headers=_headers(),
files={"file": (name, payload, mime)},
)
if response.is_error:
raise gr.Error(f"Analysis failed ({response.status_code}): {response.text[:500]}")
result = response.json()
summary = (
f"Analyzed {result['pages']} pages and {result['panels']} panels. "
"Qwen semantic analysis built a concrete cast, event chain, setting, and unresolved thread."
)
session = {
"memory": result["memory"],
"context_images": result.get("context_images", []),
"reference_images": result.get("reference_images", []),
"semantic_analysis": result.get("semantic_analysis", {}),
"layout_analysis": result.get("layout_analysis", {}),
"reading_direction": reading_direction,
}
return session, result["memory"], summary
def _data_url_to_image(value: str) -> Image.Image:
payload = base64.b64decode(value.split(",", 1)[1])
return Image.open(io.BytesIO(payload)).convert("RGB")
def _continuation_request(
session: dict,
pages: int,
creativity: float,
dialogue_density: float,
style_fidelity: float,
character_fidelity: float,
reading_direction: str,
research_mode: bool,
) -> dict:
resolved_direction = (
session.get("reading_direction", "ltr")
if reading_direction == "auto"
else reading_direction
)
return {
"memory": session.get("memory", session),
"context_images": session.get("context_images", []),
"reference_images": session.get("reference_images", []),
"settings": {
"pages": int(pages),
"creativity": creativity,
"dialogue_density": dialogue_density,
"style_fidelity": style_fidelity,
"character_fidelity": character_fidelity,
"reading_direction": resolved_direction,
"research_mode": research_mode,
"seed": 20260823,
},
}
def generate(
session: dict | None,
pages: int,
creativity: float,
dialogue_density: float,
style_fidelity: float,
character_fidelity: float,
reading_direction: str,
research_mode: bool,
):
if not session:
raise gr.Error("Analyze a comic before requesting a continuation.")
request = _continuation_request(
session,
pages,
creativity,
dialogue_density,
style_fidelity,
character_fidelity,
reading_direction,
research_mode,
)
reference_images = request["reference_images"]
context_images = request["context_images"]
with httpx.Client(timeout=3600) as client:
planned_response = client.post(
f"{API_URL}/plan-continuation", headers=_headers(), json=request
)
if planned_response.is_error:
raise gr.Error(
f"Planning failed ({planned_response.status_code}): {planned_response.text[:500]}"
)
planned = planned_response.json()
render_request = {
**request,
"scripts": planned["scripts"],
"planner_metadata": planned.get("planner", {}),
}
response = client.post(f"{API_URL}/render-script", headers=_headers(), json=render_request)
if response.is_error:
raise gr.Error(f"Rendering failed ({response.status_code}): {response.text[:500]}")
result = response.json()
images = [_data_url_to_image(item) for item in result.get("page_data_urls", [])]
research = {
"model_variant": [item.get("model_variant") for item in result.get("scripts", [])],
"routing": planned.get("routing", result.get("routing", [])),
"job_id": result.get("job_id"),
"renderer": result.get(
"renderer", {"status": "RENDERER NOT RUN (visible placeholder panels)"}
),
"planner": result.get("planner", {}),
}
updated_session = {
"memory": result.get("memory"),
"context_images": context_images,
"reference_images": result.get("next_reference_images", reference_images),
"semantic_analysis": session.get("semantic_analysis", {}),
"layout_analysis": session.get("layout_analysis", {}),
}
return (
images,
result.get("scripts", []),
updated_session,
result.get("memory"),
research,
)
def draft_story_stage(
session: dict | None,
pages: int,
creativity: float,
dialogue_density: float,
style_fidelity: float,
character_fidelity: float,
reading_direction: str,
research_mode: bool,
):
if not session:
raise gr.Error("Analyze a comic before drafting its continuation.")
request = _continuation_request(
session,
pages,
creativity,
dialogue_density,
style_fidelity,
character_fidelity,
reading_direction,
research_mode,
)
with httpx.Client(timeout=1800) as client:
response = client.post(f"{API_URL}/draft-story", headers=_headers(), json=request)
if response.is_error:
raise gr.Error(f"Story drafting failed ({response.status_code}): {response.text[:500]}")
drafted = response.json()
state = {"request": request, "story": drafted, "scripts": None}
return (
state,
json.dumps(drafted["story_brief"], indent=2, ensure_ascii=False),
"Story drafted and editorially validated. Edit it if needed, then lock it into a storyboard.",
{"story_stage": drafted},
)
def storyboard_stage(planning_state: dict | None, approved_story: dict | str | None):
if not planning_state or not approved_story:
raise gr.Error("Draft and approve a story before storyboarding.")
if isinstance(approved_story, str):
try:
approved_story = json.loads(approved_story)
except json.JSONDecodeError as exc:
raise gr.Error(f"The edited story is not valid JSON: {exc}") from exc
request = {**planning_state["request"], "locked_story_brief": approved_story}
with httpx.Client(timeout=1800) as client:
response = client.post(
f"{API_URL}/storyboard-continuation", headers=_headers(), json=request
)
if response.is_error:
raise gr.Error(f"Storyboarding failed ({response.status_code}): {response.text[:500]}")
planned = response.json()
state = {**planning_state, "request": request, "storyboard": planned, "scripts": planned["scripts"]}
return (
state,
planned.get("planner", {}).get("causal_plan", {}),
planned["scripts"],
"Storyboard locked. Review the page and panel beats, then start visual rendering.",
{"story_stage": planning_state.get("story", {}), "storyboard_stage": planned},
)
def render_locked_stage(session: dict | None, planning_state: dict | None):
if not session or not planning_state or not planning_state.get("scripts"):
raise gr.Error("Lock a storyboard before rendering.")
request = planning_state["request"]
render_request = {
**request,
"scripts": planning_state["scripts"],
"planner_metadata": planning_state.get("storyboard", {}).get("planner", {}),
}
with httpx.Client(timeout=3600) as client:
response = client.post(f"{API_URL}/render-script", headers=_headers(), json=render_request)
if response.is_error:
raise gr.Error(f"Rendering failed ({response.status_code}): {response.text[:500]}")
result = response.json()
images = [_data_url_to_image(item) for item in result.get("page_data_urls", [])]
research = {
"story_stage": planning_state.get("story", {}),
"storyboard_stage": planning_state.get("storyboard", {}),
"renderer": result.get("renderer", {}),
"job_id": result.get("job_id"),
}
updated_session = {
"memory": result.get("memory"),
"context_images": request.get("context_images", []),
"reference_images": result.get(
"next_reference_images", request.get("reference_images", [])
),
"semantic_analysis": session.get("semantic_analysis", {}),
"layout_analysis": session.get("layout_analysis", {}),
"reading_direction": request["settings"]["reading_direction"],
}
return images, result.get("scripts", []), updated_session, result.get("memory"), research
def record_preference(choice: str, notes: str) -> str:
if not choice:
return "Choose a preference before submitting."
row = {"preference": choice, "notes": notes[:1000]}
destination = Path(tempfile.gettempdir()) / "opencomic_pairwise.jsonl"
with destination.open("a", encoding="utf-8") as handle:
handle.write(json.dumps(row, ensure_ascii=False) + "\n")
return "Anonymous preference recorded for this research session."
with gr.Blocks(title="OpenComic-Continue") as demo:
gr.Markdown(
"# OpenComic-Continue\n"
"A multi-page story-first research prototype: understand several context pages, lock a "
"complete continuation arc, storyboard each page into natural four- or five-panel pacing, "
"then render every box from the previous "
"image plus immutable cast/prop references. "
"Only upload material you own or have permission to transform."
)
memory_state = gr.State()
planning_state = gr.State()
with gr.Tab("Analyze"):
uploads = gr.File(
label="Comic pages, PDF, CBZ, or ZIP",
file_count="multiple",
type="filepath",
)
direction = gr.Radio(["ltr", "rtl"], value="ltr", label="Reading direction")
continuity_notes = gr.Textbox(
label="Optional continuity corrections",
placeholder="Example: four physical cats; the round bottle is pink, not red",
lines=2,
max_lines=4,
)
analyze_button = gr.Button("Analyze comic", variant="primary")
analysis_summary = gr.Markdown()
memory_json = gr.JSON(label="StoryMemory")
analyze_button.click(
analyze,
[uploads, direction, continuity_notes],
[memory_state, memory_json, analysis_summary],
api_name="analyze_quality",
)
with gr.Tab("Continue"):
with gr.Row():
page_count = gr.Slider(2, 4, value=2, step=1, label="Continuation pages")
creativity = gr.Slider(0, 1, value=0.5, label="Creativity")
dialogue = gr.Slider(
0,
1,
value=0.0,
label="Dialogue density (0 recommended unless source dialogue was extracted)",
)
with gr.Row():
style = gr.Slider(0, 1, value=0.8, label="Style fidelity")
character = gr.Slider(0, 1, value=0.9, label="Character fidelity")
generation_direction = gr.Dropdown(
["auto", "ltr", "rtl"], value="auto", label="Reading direction"
)
research_mode = gr.Checkbox(label="Research mode (show routing and model metadata)")
gr.Markdown(
"1. Draft the causal prose story. 2. Edit/approve it and lock a storyboard. "
"3. Render the locked panels sequentially with strict anatomy, cast, and prop checks."
)
with gr.Row():
draft_button = gr.Button("1 路 Draft story", variant="primary")
storyboard_button = gr.Button("2 路 Lock storyboard")
render_button = gr.Button("3 路 Render panels")
stage_status = gr.Markdown()
story_brief = gr.Code(label="Editable story brief", language="json", lines=24)
storyboard_json = gr.JSON(label="Locked page/panel storyboard")
gallery = gr.Gallery(label="Continuation pages", columns=2, object_fit="contain")
scripts = gr.JSON(label="Structured scripts")
updated_memory = gr.JSON(label="Updated StoryMemory")
research_json = gr.JSON(label="Research metadata")
draft_button.click(
draft_story_stage,
[
memory_state,
page_count,
creativity,
dialogue,
style,
character,
generation_direction,
research_mode,
],
[planning_state, story_brief, stage_status, research_json],
api_name="draft_story_quality",
)
storyboard_button.click(
storyboard_stage,
[planning_state, story_brief],
[planning_state, storyboard_json, scripts, stage_status, research_json],
api_name="storyboard_quality",
)
render_button.click(
render_locked_stage,
[memory_state, planning_state],
[gallery, scripts, memory_state, updated_memory, research_json],
api_name="render_quality",
)
# Keep the original combined API contract for scripted research clients.
legacy_generate_button = gr.Button("Legacy combined generation", visible=False)
legacy_generate_button.click(
generate,
[
memory_state,
page_count,
creativity,
dialogue,
style,
character,
generation_direction,
research_mode,
],
[gallery, scripts, memory_state, updated_memory, research_json],
api_name="generate_quality",
)
with gr.Tab("Pairwise evaluation"):
gr.Markdown(
"Use this form after comparing two model outputs supplied by a study administrator. "
"No identity is collected."
)
preference = gr.Radio(["A", "B", "Tie", "Both invalid"], label="Preferred output")
preference_notes = gr.Textbox(label="Optional reason", lines=3)
preference_button = gr.Button("Submit preference")
preference_status = gr.Markdown()
preference_button.click(
record_preference, [preference, preference_notes], preference_status
)
gr.Markdown(
"Generated continuations may be inaccurate or legally restricted. The current demo "
"labels unexecuted image rendering rather than presenting placeholders as model results."
)
if __name__ == "__main__":
demo.launch(show_error=True)