BolyosCsaba
fix: inline Three.js + characters.js into srcdoc iframe
4f032fd
"""
app.py โ€” Gradio 6 Space entry point for Immersive Vibe Development Studio.
Streaming bridge: hidden gr.Textbox (elem_id="studio-data") observed by
a MutationObserver + input/change event listeners in the Three.js page.
"""
import json
import re
import shutil
import tempfile
import threading
import time
from pathlib import Path
import gradio as gr
from pipeline import Pipeline, MODELS
from studio import build_studio_html
AVAILABLE_MODELS = MODELS
# โ”€โ”€ Mobile guard HTML โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
MOBILE_HTML = """
<div style="display:flex;align-items:center;justify-content:center;
min-height:60vh;background:#1a1a2e;color:#fff;
font-family:sans-serif;text-align:center;padding:2rem;">
<div>
<div style="font-size:3rem">๐ŸŽฎ</div>
<h2 style="margin-top:1rem">Immersive Vibe Development Studio</h2>
<p style="color:#aaa;margin-top:0.8rem">
Best experienced on a desktop browser (โ‰ฅ 1024 px wide).<br>
Rotate your device or switch to a larger screen.
</p>
</div>
</div>
"""
MOBILE_CHECK_JS = """
<script>
(function() {
if (window.innerWidth < 1024) {
var mob = document.getElementById('ivds-mobile-notice');
if (mob) mob.style.display = 'flex';
var main = document.getElementById('ivds-main');
if (main) main.style.display = 'none';
}
})();
</script>
"""
# โ”€โ”€ Streaming bridge JS โ€” forwards data_out changes into studio iframe โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
BRIDGE_JS = """
<script>
(function() {
var lastVal = '';
function attachBridge() {
var container = document.getElementById('studio-data');
if (!container) { setTimeout(attachBridge, 300); return; }
var el = container.querySelector('textarea') ||
container.querySelector('input[type="text"]') ||
container;
function onUpdate() {
var val = el.value !== undefined ? el.value : (el.textContent || '');
if (!val || val === lastVal) return;
lastVal = val;
try {
var msg = JSON.parse(val);
var iframe = document.getElementById('ivds-studio-iframe');
if (iframe && iframe.contentWindow) {
iframe.contentWindow.postMessage(msg, '*');
}
} catch(_) {}
}
el.addEventListener('input', onUpdate);
el.addEventListener('change', onUpdate);
new MutationObserver(onUpdate).observe(
container,
{ childList: true, subtree: true, characterData: true, attributes: true }
);
}
attachBridge();
})();
</script>
"""
def _wrap_in_iframe(studio_html: str) -> str:
"""Wrap self-contained studio HTML in a srcdoc iframe.
Three.js and characters.js are inlined, so no external requests needed.
"""
import html as html_mod
escaped = html_mod.escape(studio_html, quote=True)
return (
f'<iframe id="ivds-studio-iframe" srcdoc="{escaped}" '
f'style="width:100%;height:600px;border:none;border-radius:8px;background:#1a1a2e" '
f'allow="autoplay" sandbox="allow-scripts"></iframe>'
)
# โ”€โ”€ Background temp-dir cleanup daemon โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
def _cleanup_loop() -> None:
import glob
while True:
time.sleep(15 * 60)
for d in glob.glob(tempfile.gettempdir() + "/ivds_*"):
try:
age = time.time() - Path(d).stat().st_mtime
if age > 3600:
shutil.rmtree(d, ignore_errors=True)
except OSError:
pass
threading.Thread(target=_cleanup_loop, daemon=True).start()
# โ”€โ”€ Active cancel flags (session_hash โ†’ Event) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
_cancel_flags: dict[str, threading.Event] = {}
# โ”€โ”€ Pipeline generator โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
def run_pipeline(
trigger: str,
selected_models: list[str],
oauth_token: gr.OAuthToken | None = None,
request: gr.Request | None = None,
):
if not oauth_token or not oauth_token.token:
yield json.dumps({"type": "error", "text": "Please log in with your HF account first."}), None
return
if not trigger or not trigger.strip():
yield json.dumps({"type": "error", "text": "Enter a 2-word trigger."}), None
return
if len(selected_models) < 2:
yield json.dumps({"type": "error", "text": "Select at least 2 models."}), None
return
session_key = getattr(request, "session_hash", "default") if request else "default"
cancel_flag = threading.Event()
_cancel_flags[session_key] = cancel_flag
pipeline = Pipeline(trigger.strip(), selected_models, oauth_token.token, cancel_flag)
try:
for chunk in pipeline.run():
yield chunk, None
finally:
_cancel_flags.pop(session_key, None)
if cancel_flag.is_set():
return
# Generation complete โ€” build zip and expose download
zip_bytes, zip_name = pipeline.build_zip()
tmp = tempfile.NamedTemporaryFile(
prefix="ivds_", suffix=".zip", delete=False, dir=tempfile.gettempdir()
)
tmp.write(zip_bytes)
tmp.close()
yield json.dumps({"type": "done"}), tmp.name
def cancel_pipeline(request: gr.Request | None = None):
session_key = getattr(request, "session_hash", "default") if request else "default"
flag = _cancel_flags.get(session_key)
if flag:
flag.set()
# โ”€โ”€ Build studio iframe from model list โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
def _build_studio_iframe(selected_models: list[str]) -> str:
"""Build the studio iframe HTML for the given model list."""
import threading as _t
pipeline_tmp = Pipeline("preview demo", selected_models, "placeholder", _t.Event())
assignments = pipeline_tmp.get_model_assignments()
raw_html = build_studio_html(assignments)
return _wrap_in_iframe(raw_html)
# โ”€โ”€ Start generation: rebuild studio with actual models โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
def start_generation(
trigger: str,
selected_models: list[str],
oauth_token: gr.OAuthToken | None = None,
):
if not oauth_token or not oauth_token.token:
return gr.update(), gr.update(visible=False)
iframe = _build_studio_iframe(selected_models)
return iframe, gr.update(visible=False)
# โ”€โ”€ Generate button enable/disable โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
_TRIGGER_RE = re.compile(r'^[a-zA-Zร€-รฟ]{2,20}[\s\-][a-zA-Zร€-รฟ]{2,20}$')
def update_generate_btn(trigger: str, models: list[str]):
valid_trigger = bool(_TRIGGER_RE.match((trigger or "").strip()))
valid_models = 2 <= len(models) <= 5
return gr.update(interactive=(valid_trigger and valid_models))
# โ”€โ”€ View toggle โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
def toggle_view(current: str):
new_mode = "immersive" if current == "compact" else "compact"
label = "โคข Immersive" if new_mode == "compact" else "โคก Compact"
hide_controls = new_mode == "immersive"
return new_mode, gr.update(value=label), gr.update(visible=not hide_controls)
# โ”€โ”€ Gradio UI โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
CSS = """
#generate-btn { background: #7c3aed !important; color: white !important; }
#stop-btn { background: #dc2626 !important; color: white !important; }
#ivds-studio { border: none !important; background: transparent !important; padding: 0 !important; }
.gradio-container { max-width: 100% !important; }
"""
with gr.Blocks(title="๐ŸŽฎ Immersive Vibe Development Studio") as demo:
view_mode = gr.State("compact")
# โ”€โ”€ Mobile notice (hidden by default, shown by JS if narrow) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
gr.HTML(f'<div id="ivds-mobile-notice" style="display:none">{MOBILE_HTML}</div>')
gr.HTML(MOBILE_CHECK_JS)
with gr.Column(elem_id="ivds-main"):
# โ”€โ”€ Top bar โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
with gr.Row():
gr.HTML("<span style='color:#fff;font-family:monospace;font-size:1.1rem;"
"padding:0.4rem 0'>๐ŸŽฎ <strong>Immersive Vibe Development Studio</strong></span>")
toggle_btn = gr.Button("โคข Immersive", size="sm", scale=0)
# โ”€โ”€ Main content โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
with gr.Row():
# Left: controls
with gr.Column(scale=4, elem_id="ivds-controls") as controls_col:
gr.LoginButton()
trigger_box = gr.Textbox(
label="2-Word Game Trigger",
placeholder="jungle monkey",
info="theme + hero, e.g. 'space robot', 'medieval knight', 'ocean dolphin'",
)
model_selector = gr.CheckboxGroup(
choices=AVAILABLE_MODELS,
value=AVAILABLE_MODELS[:2],
label="Select AI Models (2โ€“5)",
)
gr.HTML("<p style='color:#aaa;font-size:12px;margin-top:4px'>"
"โฑ ~15โ€“30 min depending on model speed</p>")
with gr.Row():
generate_btn = gr.Button(
"๐Ÿš€ Generate Game",
variant="primary",
elem_id="generate-btn",
interactive=False,
)
stop_btn = gr.Button("โน Stop", elem_id="stop-btn", scale=0)
download_file = gr.File(label="โฌ‡ Download Game ZIP", visible=False)
# Right: studio scene (pre-rendered with default models)
with gr.Column(scale=6):
_default_iframe = _build_studio_iframe(AVAILABLE_MODELS[:2])
studio_html = gr.HTML(
value=_default_iframe,
elem_id="ivds-studio",
)
# Hidden streaming bridge textbox
data_out = gr.Textbox(visible=False, elem_id="studio-data")
# Inject bridge JS
gr.HTML(BRIDGE_JS)
# โ”€โ”€ Wiring โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
# Enable/disable generate button on input change
trigger_box.change(update_generate_btn,
inputs=[trigger_box, model_selector],
outputs=[generate_btn])
model_selector.change(update_generate_btn,
inputs=[trigger_box, model_selector],
outputs=[generate_btn])
# Single chain: inject studio โ†’ stream pipeline (Fix 4)
_gen = generate_btn.click(
fn=start_generation,
inputs=[trigger_box, model_selector],
outputs=[studio_html, download_file],
).then(
fn=run_pipeline,
inputs=[trigger_box, model_selector],
outputs=[data_out, download_file],
show_progress=False,
)
# Stop cancels the chain (Fix 4)
stop_btn.click(fn=cancel_pipeline, outputs=[], queue=False)
stop_btn.click(fn=None, cancels=[_gen])
# View toggle with actual column visibility (Fix 6)
toggle_btn.click(
fn=toggle_view,
inputs=[view_mode],
outputs=[view_mode, toggle_btn, controls_col],
)
if __name__ == "__main__":
demo.launch(css=CSS, ssr_mode=False)