YoussefSharawy91's picture
Stop wizard/intro drift on HF embed: lock layout to real viewport px
ac67617
Raw
History Blame Contribute Delete
13.1 kB
from __future__ import annotations
import inspect
import os
import re
import socket
from pathlib import Path
import gradio as gr
ROOT = Path(__file__).resolve().parent
DIST_DIR = ROOT / "dist"
ASSETS_DIR = DIST_DIR / "assets"
PATCHED_DIR = ROOT / ".gradio_static"
PORT = int(os.environ.get("GRADIO_PORT", "7899"))
def get_free_port() -> int:
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
sock.bind(("127.0.0.1", 0))
return int(sock.getsockname()[1])
def file_url(path: Path) -> str:
return f"/gradio_api/file={path.resolve()}"
def patched_js_url(index_html: str) -> str:
match = re.search(r'src="/assets/([^"]+\.js)"', index_html)
if not match:
raise RuntimeError("Could not find built JS bundle in dist/index.html")
source_path = ASSETS_DIR / match.group(1)
source = source_path.read_text()
# The worker is referenced by an ABSOLUTE "/assets/worker-*.js" URL, which
# cannot resolve inside the Gradio iframe — rewrite it to a file= URL.
worker_match = re.search(r'worker-[^"]+\.js', source)
if worker_match:
worker_path = ASSETS_DIR / worker_match.group(0)
source = source.replace(f'"/assets/{worker_path.name}"', f'"{file_url(worker_path)}"')
source = source.replace('"logo.png"', f'"{file_url(DIST_DIR / "logo.png")}"')
source = source.replace('"banner.png"', f'"{file_url(DIST_DIR / "banner.png")}"')
source = source.replace('"yeSound.mp3"', f'"{file_url(DIST_DIR / "yeSound.mp3")}"')
for _img in ("Image1.jpg", "Image2.jpg", "Image3.jpg"):
source = source.replace(f'"{_img}"', f'"{file_url(DIST_DIR / _img)}"')
# CRITICAL: lazily-imported chunks (e.g. the transformers.js bundle used by
# =VISION()) are referenced RELATIVELY, like import("./transformers.web-*.js"),
# resolved against this file's URL. So the patched bundle MUST live next to
# those chunks (dist/assets/) — not in .gradio_static/ — or they 404 in the
# iframe and on-device WebGPU inference silently fails.
patched_path = ASSETS_DIR / (source_path.stem + ".patched.js")
patched_path.write_text(source)
return file_url(patched_path)
def patched_index_url() -> str:
index_path = DIST_DIR / "index.html"
if not index_path.exists():
raise RuntimeError("Missing dist/index.html. Run `npm run build` first.")
PATCHED_DIR.mkdir(exist_ok=True)
index_html = index_path.read_text()
# Never cache the entry document, so a rebuild is always picked up (the
# hashed JS bundle name changes each build; a cached index.html would point
# at a stale one).
index_html = index_html.replace(
"<head>",
'<head>\n <meta http-equiv="Cache-Control" content="no-store" />',
1,
)
index_html = re.sub(
r'<script type="module" crossorigin src="/assets/[^"]+\.js"></script>',
f'<script type="module" crossorigin src="{patched_js_url(index_html)}"></script>',
index_html,
)
index_html = re.sub(
r'href="/assets/([^"]+)"',
lambda m: f'href="{file_url(ASSETS_DIR / m.group(1))}"',
index_html,
)
index_html = index_html.replace('href="/logo.png"', f'href="{file_url(DIST_DIR / "logo.png")}"')
index_html = index_html.replace('src="logo.png"', f'src="{file_url(DIST_DIR / "logo.png")}"')
index_html = index_html.replace('src="banner.png"', f'src="{file_url(DIST_DIR / "banner.png")}"')
patched_index = PATCHED_DIR / "index.html"
patched_index.write_text(index_html)
return file_url(patched_index)
def build_ui() -> gr.Blocks:
css = """
html, body, #root, gradio-app, .gradio-container {
width: 100vw !important;
max-width: 100vw !important;
height: 100%;
margin: 0 !important;
padding: 0 !important;
overflow: hidden !important;
background: #fff1a8 !important;
}
.gradio-container {
max-width: none !important;
}
main.app, .main, .contain, .wrap, .block, .html-container, .html-container.padding,
.prose, #component-0, #component-0 > div, .form {
width: 100vw !important;
max-width: 100vw !important;
flex-basis: 100vw !important;
align-self: stretch !important;
height: 100% !important;
min-height: 100% !important;
margin: 0 !important;
padding: 0 !important;
overflow: hidden !important;
transform: none !important;
}
.spreadsheet-host {
position: relative;
width: 100vw !important;
max-width: 100vw !important;
height: 100vh;
height: 100dvh;
margin: 0;
padding: 0;
overflow: hidden;
background: white;
}
.spreadsheet-frame {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
border: 0;
display: block;
background: white;
}
.yellow-tint {
position: absolute;
inset: 0;
pointer-events: none;
z-index: 9999;
background: rgba(255, 232, 83, 0.34);
mix-blend-mode: multiply;
}
footer {
display: none !important;
}
"""
# Keep the iframe in normal layout flow. Hugging Face/Gradio measure the
# document to size the outer Space iframe; hoisting a fixed-position child to
# <body> gives that measurer no stable height and can make the whole UI drift.
head = """
<script>
(function () {
// Once we learn the parent's real visible viewport height (from the embed's
// iframe-resizer), we lock EVERYTHING to exactly that many pixels. Until then
// we fall back to 100dvh / a screen-height cap. dvh and the screen cap are
// both LARGER than the embed's visible area, so the content overflows the
// viewport, the parent scrolls, and the intro/wizard appears "moved down" and
// twitches as it settles. A fixed pixel lock removes that mismatch entirely.
var pinnedH = 0;
function hostHeightCss() {
return pinnedH > 0
? ('height:' + pinnedH + 'px;min-height:' + pinnedH + 'px;max-height:' + pinnedH + 'px')
: 'height:100vh;height:100dvh';
}
function lockLayout() {
document.documentElement.style.overflow = 'hidden';
document.documentElement.style.height = pinnedH > 0 ? (pinnedH + 'px') : '100%';
document.body.style.overflow = 'hidden';
document.body.style.height = pinnedH > 0 ? (pinnedH + 'px') : '100%';
document.body.style.margin = '0';
// Hard cap the page height so the resizer cannot inflate it. Use the real
// parent viewport height once known; before that, fall back to one screen
// (this is what caused the initial drift + having to scroll down before it
// self-corrected).
var _cap = (pinnedH > 0 ? pinnedH : ((window.screen && window.screen.height) ? window.screen.height : 1200)) + 'px';
document.documentElement.style.maxHeight = _cap;
document.body.style.maxHeight = _cap;
var host = document.querySelector('.spreadsheet-host');
var f = document.querySelector('.spreadsheet-frame');
var t = document.querySelector('.yellow-tint');
var selectors = [
'html', 'body', '#root', 'gradio-app', '.gradio-container',
'main.app', '.main', '.contain', '.wrap', '.block',
'.html-container', '.html-container.padding', '.prose',
'#component-0', '#component-0 > div', '.form'
];
selectors.forEach(function (selector) {
document.querySelectorAll(selector).forEach(function (el) {
el.style.setProperty('width', '100vw', 'important');
el.style.setProperty('max-width', '100vw', 'important');
el.style.setProperty('margin', '0', 'important');
el.style.setProperty('padding', '0', 'important');
el.style.setProperty('overflow', 'hidden', 'important');
el.style.setProperty('transform', 'none', 'important');
});
});
if (host) {
host.setAttribute('data-iframe-height', '');
host.style.cssText = 'position:relative;width:100vw!important;max-width:100vw!important;' + hostHeightCss() + ';margin:0;padding:0;overflow:hidden;background:#fff';
}
if (f) { f.style.cssText = 'position:absolute;inset:0;width:100%;height:100%;border:0;background:#fff'; }
if (t) { t.style.cssText = 'position:absolute;inset:0;pointer-events:none;z-index:9999;background:rgba(255,232,83,0.34);mix-blend-mode:multiply'; }
}
// On the huggingface.co embed, Gradio's iframe-resizer auto-sizes the outer
// Space iframe to our measured content. Our 100dvh layout then equals that
// iframe height -> feedback loop -> the iframe grows and the page drifts
// down. The fix: turn auto-resize OFF (once) and pin the iframe to the
// PARENT viewport height (independent of our content, so no loop, no twitch).
var pinned = false;
function pinParentFrame() {
if (pinned || !window.parentIFrame) return;
pinned = true;
try { window.parentIFrame.autoResize(false); } catch (e) {}
function applyParentViewport(info) {
var h = Math.round(Number(info && info.clientHeight) || 0);
if (h > 200) {
pinnedH = h;
try { window.parentIFrame.size(h); } catch (e) {}
// Re-lock html/body/host to the exact visible height. Fires on every
// parent scroll/resize, so the layout (and the fixed wizard overlay
// inside the inner iframe) tracks the real viewport with no drift.
lockLayout();
}
}
if (typeof window.parentIFrame.getPageInfo === 'function') {
// Fires now and again whenever the parent scrolls/resizes — always with
// the parent viewport height, so the size we set never changes due to us.
try { window.parentIFrame.getPageInfo(applyParentViewport); } catch (e) {}
}
// Undo any scroll drift that happened before we pinned: snap the parent
// back so the app is at the top, no manual scrolling needed.
function toTop() { try { window.parentIFrame.scrollToOffset(0, 0); } catch (e) {} }
toTop();
setTimeout(toTop, 250);
setTimeout(toTop, 700);
}
// Poll fast at first so the pin lands within ~100ms (before drift is visible);
// lockLayout also re-runs because Gradio renders components async.
var n = 0, iv = setInterval(function () {
lockLayout();
pinParentFrame();
if (++n > 80) clearInterval(iv);
}, 50);
document.addEventListener('DOMContentLoaded', lockLayout);
window.addEventListener('load', function () { lockLayout(); pinParentFrame(); });
window.addEventListener('resize', lockLayout);
})();
</script>
"""
# NOTE: Gradio 6 applies css/head at launch(); older Gradio applies them on
# Blocks(). We support both so local verification does not depend on one
# specific Gradio version.
blocks_kwargs = {"title": "The Backrooms Spreadsheet", "fill_height": True}
if "css" in inspect.signature(gr.Blocks).parameters:
blocks_kwargs.update({"css": css, "head": head})
with gr.Blocks(**blocks_kwargs) as demo:
gr.HTML(
f"""
<div class="spreadsheet-host">
<iframe
class="spreadsheet-frame"
src="{patched_index_url()}"
allow="clipboard-read; clipboard-write; fullscreen; camera; microphone; autoplay; encrypted-media"
></iframe>
<div class="yellow-tint"></div>
</div>
"""
)
return demo, css, head
if __name__ == "__main__":
demo, css, head = build_ui()
# On a Hugging Face Space, bind 0.0.0.0:7860 (HF sets SPACE_ID/PORT). Locally,
# keep the old behaviour (localhost + a free port, or GRADIO_PORT if set).
on_space = bool(os.environ.get("SPACE_ID"))
if on_space:
server_name = "0.0.0.0"
server_port = int(os.environ.get("PORT", "7860"))
else:
server_name = "127.0.0.1"
server_port = PORT if "GRADIO_PORT" in os.environ else get_free_port()
print(f"Running The Backrooms Spreadsheet at http://{server_name}:{server_port}", flush=True)
launch_kwargs = {
"server_name": server_name,
"server_port": server_port,
"show_error": True,
"allowed_paths": [str(DIST_DIR), str(PATCHED_DIR)],
}
launch_params = inspect.signature(demo.launch).parameters
if "ssr_mode" in launch_params:
launch_kwargs["ssr_mode"] = False # Gradio 6 enables SSR on Spaces, which shadows our custom iframe
if "css" in launch_params:
launch_kwargs["css"] = css # Gradio 6 ignores css/head on gr.Blocks(); they must be passed here
if "head" in launch_params:
launch_kwargs["head"] = head
demo.launch(**launch_kwargs)