"""The /web playground: pick a design, work on it through any surface, score it. Same shape as the other FineEnvs playgrounds (task controls on the left, the action and the score on the right), with one addition: the real editor is embedded, live-synced to the episode, so whatever you do in the Tool call, HTML or Expert tabs appears on the canvas as it happens, and whatever you drag in the editor shows up in the trajectory. Every button here goes through the same `DesignCanvasEnvironment` a trainer talks to, via `CallToolAction`, so the playground exercises the real MCP path. """ from __future__ import annotations import base64 import io import json import random import threading from collections import OrderedDict import gradio as gr from openenv.core.env_server.mcp_types import CallToolAction, ListToolsAction from PIL import Image try: from ..core import expert from ..core.pack import TaskPack from ..core.score import CONFIGS from ..realapps import available as apps_available from ..realapps import status as apps_status from .environment import DesignCanvasEnvironment from .episodes import REAL_SKINS, SKIN_INFO, STORE except ImportError: from core import expert from core.pack import TaskPack from core.score import CONFIGS from realapps import available as apps_available from realapps import status as apps_status from server.environment import DesignCanvasEnvironment from server.episodes import REAL_SKINS, SKIN_INFO, STORE SIM_CHOICES = [(f"{name} ({kind})", sid) for sid, (name, kind) in SKIN_INFO.items()] SKIN_CHOICES = SIM_CHOICES + [(f"{name} (real app)", sid) for sid, (name, _) in REAL_SKINS.items()] def skin_choices(surface: str) -> list: """The editors a surface can use: real apps only on computer use (the env rejects them elsewhere), and marked when this server cannot reach them.""" if surface != "gui": return SIM_CHOICES ok, _ = apps_available() return SIM_CHOICES + [(f"{name} (real app{'' if ok else ', not set up here'})", sid) for sid, (name, _) in REAL_SKINS.items()] MODE_CHOICES = [("Recreate from reference", "reference"), ("Build from brief", "description")] SURFACE_CHOICES = [("Tool calls", "tools"), ("HTML", "html"), ("Computer use", "gui")] CLICK_CHOICES = [("Click", "left_click"), ("Double-click", "double_click"), ("Right-click", "right_click")] INTRO = """ Recreate a real graphic design from its parts. Each task is a [Crello](https://huggingface.co/datasets/cyberagent/crello) template taken apart into its pre-rendered assets and its typed copy; the agent rebuilds it on a canvas through **tool calls**, **one page of HTML**, or **an editor driven by mouse and keyboard** (computer use). All three edit the same document and are scored by the same renderer. On computer use the editor is the built-in one in one of six layouts, or a real app (Penpot, LibreOffice Impress, Inkscape, GIMP) that this server runs, locally or in Daytona sandboxes. A real app's own document is read back on submit and scored the same way. Reward (reference mode) `= 0.60·structure + 0.30·visual + 0.05·order + 0.05·background`, where *structure* matches elements one-to-one (content, position, size, typography) and *visual* is multi-scale SSIM + colour layout against `render(ground truth)`, normalised so an empty canvas scores 0. Description mode loosens geometry and adds coverage of every asset and every line of copy. """ SCORING = """ **Structure (0.60).** Submitted and target elements are matched one-to-one with the Hungarian algorithm. A pair scores `content × geometry` for images and `content × (0.62·geometry + 0.38·typography)` for text. Content is 1 for the right asset (a look-alike asset earns partial credit by visual similarity) and 1 − normalised edit distance for copy. Geometry averages a tolerant IoU, centre distance (σ = 2.5 % of the canvas diagonal) and log size ratio, on the box that is actually drawn: for text, the extent of the glyphs, so an invisible box width is never scored. Typography covers font, size, colour, alignment, weight, case, letter spacing and line height. The sum is divided by `max(#target, #submitted)`, so extra elements cost as much as missing ones. **Visual (0.30).** Both documents are rendered at 256 px by the same skia renderer and compared on an area-averaged pyramid: SSIM at 128, 64 and 32 px plus colour-layout error at 32 px, each rescaled so a blank canvas is 0 and an exact match is 1. Weighting the coarse scales keeps a few-pixel nudge from reading as a different design; precision is the structure term's job. The target is `render(ground truth)`, never Crello's own preview, so a perfect reconstruction scores exactly 1.0. **Order (0.05)** agreement of stacking order among overlapping matched pairs. **Background (0.05)** colour distance of the page. Replaying the expert trajectory scores 1.000 on every task in the pack. """ def _b64_image(data: str) -> Image.Image: return Image.open(io.BytesIO(base64.b64decode(data))).convert("RGB") def _png(data: bytes) -> Image.Image: return Image.open(io.BytesIO(data)).convert("RGB") class Playground: def __init__(self, pack: TaskPack): self.pack = pack self.envs: OrderedDict[str, DesignCanvasEnvironment] = OrderedDict() self.lock = threading.Lock() # ------------------------------------------------------------ sessions def env(self, episode_id: str | None) -> DesignCanvasEnvironment | None: if not episode_id: return None with self.lock: env = self.envs.get(episode_id) if env is not None: self.envs.move_to_end(episode_id) return env def start(self, split, index, mode, surface, skin, previous=None): with self.lock: # this tab's last episode is done with: free its app (a desktop, a browser page) first old = self.envs.pop(previous, None) if previous else None if old is not None: old.close() env = DesignCanvasEnvironment(self.pack) obs = env.reset(split=split, index=int(index), mode=mode, surface=surface, skin=skin) ep_id = obs.metadata["episode_id"] with self.lock: self.envs[ep_id] = env while len(self.envs) > 64: _, old = self.envs.popitem(last=False) old.close() return env, obs def call(self, episode_id, tool, args): env = self.env(episode_id) if env is None: raise gr.Error("Start a task first.") obs = env.step(CallToolAction(tool_name=tool, arguments=args)) return obs def tools(self, episode_id): env = self.env(episode_id) if env is None: return [] obs = env.step(ListToolsAction()) return [t.name for t in obs.tools if t.name != "start_task"] def build_ui(pack: TaskPack) -> gr.Blocks: pg = Playground(pack) splits = pack.splits() default_split = "validation" if "validation" in splits else splits[0] def editor_frame(ep_id, skin): # Inline styles: OpenEnv mounts this Blocks inside its own app, which # drops a nested Blocks' css. if not ep_id: return ("
Start a task to open the " "editor.
") src = f"/editor/?episode={ep_id}&skin={skin}" # Sized by its width at the editor's own 1280x800 shape, never by 100vh: on a Space the page sits in an # iframe the Hub grows to fit its content, so vh there is that iframe's height and the editor ran away. return (f"
" f"
Open the editor in a new tab ↗
") def task_info(task, obs_meta): app = obs_meta.get("app") where = f" · in {app['name']} (real app, {apps_status()['runs_on']})" if app else "" return (f"**{task['title']}** · {task['format']} · {task['canvas']['width']}×{task['canvas']['height']} px · " f"{task['stats']['n_elements']} layers ({task['stats']['n_text']} text) · {task['difficulty']} · " f"`{task['task_id']}` · episode `{obs_meta['episode_id']}`{where}") def screen_now(ep_id): ep = STORE.maybe(ep_id) if ep is None or ep.surface != "gui": return None obs = pg.call(ep_id, "screenshot", {}) content = (obs.result.get("content") if isinstance(obs.result, dict) else getattr(obs.result, "content", None)) or [] for c in content: c = c if isinstance(c, dict) else c.model_dump() if c.get("type") == "image": return _b64_image(c["data"]) return None def load(split, index, mode, surface, skin, previous=None, progress=gr.Progress()): count = pack.count(split) index = max(0, min(int(index or 0), count - 1)) real = skin in REAL_SKINS if real and surface != "gui": raise gr.Error(f"{REAL_SKINS[skin][0]} is a real app: it runs on Computer use only.") if real and not apps_available()[0]: raise gr.Error(f"{REAL_SKINS[skin][0]} is not available on this server: {apps_available()[1]}") if real: progress(0, desc=f"Opening {REAL_SKINS[skin][0]} (a real app: the first one can take a minute)") env, obs = pg.start(split, index, mode, surface, skin, previous) meta = obs.metadata ep = STORE.get(meta["episode_id"]) task = ep.task gallery = [(str(p), aid) for aid, p in ep.assets.items()] target = _png(pack.target_png(task, 768)) if mode == "reference" else None brief = task["description"] if mode == "description" else "" tools = pg.tools(meta["episode_id"]) return ( meta["episode_id"], index, task_info(task, meta), gr.update(value=target, visible=mode == "reference"), gr.update(value=brief, visible=mode == "description"), gallery, gr.update(value=editor_frame(meta["episode_id"], skin), visible=not real), _png(ep.render_png(768)), meta["instructions"], gr.update(choices=tools, value=tools[0] if tools else None), "", None, "", "*Not submitted yet.*", {}, gr.update(visible=surface == "html"), gr.update(visible=real), screen_now(meta["episode_id"]) if real else None, gr.update(visible=real), ) def act_on_screen(ep_id, tool, args): if not ep_id or STORE.maybe(ep_id) is None: raise gr.Error("Start a task first.") text, _, _ = call_tool(ep_id, tool, json.dumps(args)) return screen_now(ep_id), text def click_screen(ep_id, kind, evt: gr.SelectData): x, y = (int(v) for v in evt.index) return act_on_screen(ep_id, kind, {"coordinate": [x, y]}) def read_from_app(ep_id): ep = STORE.maybe(ep_id) if ep is None: raise gr.Error("Start a task first.") if not ep.read_app(): raise gr.Error("Only a running real-app episode has a document to read.") return _png(ep.render_png(768)) def apps_table(check): st = apps_status(check=check) rows = ["| App | Skin |", "|---|---|"] + [f"| {a['name']} | `{a['skin']}` |" for a in st["apps"]] md = [f"Real apps run **{st['runs_on']}**" + (f" (up to {st['desktops']} desktop episodes at once)" if st["runs_on"] == "daytona" else "") + ".", "", *rows] if "sandboxes" in st: md += ["", "| Sandbox | Role | State | CPU | Memory | Episodes now |", "|---|---|---|---:|---:|---:|"] md += [f"| `{b['id']}` | {b['role']} | {b['state']} | {b['cpu']} | {b['memory_gb']} GB | {b['episodes']} |" for b in st["sandboxes"]] or ["| none yet | | | | | |"] if "endpoints" in st: md += ["", "| Endpoint | Role | Up |", "|---|---|---|"] md += [f"| `{e['url']}` | {e['role']} | {('yes' if e['up'] else 'no') if 'up' in e else 'not checked'} |" for e in st["endpoints"]] return "\n".join(md) def shuffle(split, mode, surface, skin, previous=None): return load(split, random.randrange(pack.count(split)), mode, surface, skin, previous) def step_index(split, index, delta, mode, surface, skin, previous=None): return load(split, (int(index or 0) + delta) % pack.count(split), mode, surface, skin, previous) def render_now(ep_id): ep = STORE.maybe(ep_id) return _png(ep.render_png(768)) if ep else None def call_tool(ep_id, tool, args_text): if not tool: raise gr.Error("Pick a tool.") try: args = json.loads(args_text) if args_text and args_text.strip() else {} except json.JSONDecodeError as exc: raise gr.Error(f"Arguments are not JSON: {exc}") from None obs = pg.call(ep_id, tool, args) text, image = "", None if obs.error is not None: text = f"❌ {obs.error.message}" else: res = obs.result content = (res.get("content") if isinstance(res, dict) else getattr(res, "content", None)) or [] for c in content: c = c if isinstance(c, dict) else c.model_dump() if c.get("type") == "image": image = _b64_image(c["data"]) elif c.get("type") == "text": text += c["text"] if isinstance(res, dict) and res.get("is_error"): text = "❌ " + text if obs.done: text += f"\n\n**Episode done · reward {obs.reward:.3f}**" return text, image, render_now(ep_id) def tool_template(tool): examples = { "add_image": {"asset_id": "a1", "x": 0, "y": 0, "width": 400}, "add_text": {"text": "Summer\nSale", "x": 80, "y": 120, "font_family": "Montserrat", "font_size": 96, "color": "#1d1d1f", "align": "left"}, "add_shape": {"shape": "rect", "x": 100, "y": 100, "width": 300, "height": 120, "fill": "#e63946", "radius": 24}, "update_element": {"element_id": "e1", "properties": {"x": 40, "y": 40}}, "delete_element": {"element_id": "e1"}, "duplicate_element": {"element_id": "e1", "dx": 20, "dy": 20}, "reorder_element": {"element_id": "e1", "to": "front"}, "align_elements": {"element_ids": ["e1"], "alignment": "center"}, "set_background": {"color": "#f4efe6"}, "list_fonts": {"query": "sans"}, "write_html": {"html": "
…
"}, } return json.dumps(examples.get(tool, {}), indent=2) def read_html(ep_id): ep = STORE.maybe(ep_id) if ep is None: raise gr.Error("Start a task first.") return ep.html() def write_html(ep_id, html): text, _, img = call_tool(ep_id, "write_html", json.dumps({"html": html})) return text, img def replay_expert(ep_id, progress=gr.Progress()): ep = STORE.maybe(ep_id) if ep is None: raise gr.Error("Start a task first.") if ep.done: raise gr.Error("This episode is finished; load the task again.") gt = pack.gt_doc(ep.task) lines = [] if ep.realapp is not None: # a real app: the ground truth is written into the app's own document backend, session = ep.realapp backend.write_doc(session, gt) lines.append(f"Wrote the {len(gt.elements)} target elements into {REAL_SKINS[ep.skin][0]}. " "Submit to read the app's document back and score it.") return "\n".join(lines), render_now(ep_id) if ep.surface == "gui": # the built-in editor on computer use: the editor shows the document as it changes ep.apply("replace_document", {"document": gt.model_dump(mode="json")}, "expert") return f"Set the editor's document to the {len(gt.elements)} target elements.", render_now(ep_id) if ep.surface == "html": text, _, _ = call_tool(ep_id, "write_html", json.dumps({"html": expert.html_page(gt)})) lines.append(f"write_html → {text[:120]}") else: calls = [c for c in expert.tool_calls(gt) if c["name"] != "submit"] for c in progress.tqdm(calls, desc="Replaying"): text, _, _ = call_tool(ep_id, c["name"], json.dumps(c["arguments"])) lines.append(f"{c['name']}({json.dumps(c['arguments'], ensure_ascii=False)[:90]}) → {text[:60]}") return "\n".join(lines), render_now(ep_id) def submit(ep_id): ep = STORE.maybe(ep_id) if ep is None: raise gr.Error("Start a task first.") pg.call(ep_id, "submit", {}) g = ep.result rows = [("Structure", g.structure, CONFIGS[g.mode].w_structure), ("Visual", g.visual, CONFIGS[g.mode].w_visual)] if g.mode == "description": rows.append(("Coverage", g.coverage, CONFIGS[g.mode].w_coverage)) rows += [("Order", g.order, CONFIGS[g.mode].w_order), ("Background", g.background, CONFIGS[g.mode].w_background)] table = "\n".join(f"| {n} | {v:.3f} | × {w:.2f} |" for n, v, w in rows) md = (f"### Reward {g.total:.3f}\n\nMatched **{g.n_matched}/{g.n_gt}** target elements, " f"{g.n_submitted} submitted.\n\n| Term | Score | Weight |\n|---|---|---|\n{table}") return md, g.as_dict(), render_now(ep_id) def trajectory(ep_id): ep = STORE.maybe(ep_id) return {"episode_id": ep_id, "steps": len(ep.log), "log": ep.log} if ep else {} with gr.Blocks() as demo: # OpenEnv wraps this Blocks in its own app, which drops a nested css=; # a style tag still applies. It lets the playground (and the editor in # it) use the full window width instead of Gradio's centred column. gr.HTML("", padding=False) gr.Markdown("# DesignGym — try it") gr.Markdown(INTRO) episode = gr.State(None) with gr.Row(): split = gr.Dropdown(splits, value=default_split, label="Split", scale=1) index = gr.Number(value=0, precision=0, label="Task index", minimum=0, scale=1) mode = gr.Radio(MODE_CHOICES, value="reference", label="Mode", scale=2) surface = gr.Radio(SURFACE_CHOICES, value="tools", label="Surface", scale=2) skin = gr.Dropdown(skin_choices("tools"), value="figma", label="Editor", scale=2, info="Real apps (Penpot, Impress, Inkscape, GIMP) are listed under Computer use") with gr.Row(): prev_btn = gr.Button("← Previous") load_btn = gr.Button("Load index") next_btn = gr.Button("Next →") shuffle_btn = gr.Button("🎲 Shuffle task", variant="primary") info = gr.Markdown("*No task yet — click “Shuffle task”.*") # The editor gets the whole width; everything else sits around it. with gr.Tabs(): with gr.Tab("Editor"): editor = gr.HTML(editor_frame(None, "figma")) # a real app runs behind the server: its screen, driven through the same GUI tools the agent has with gr.Column(visible=False) as app_box: screen = gr.Image(label="App screen (what the agent sees; click it to act there)", type="pil", interactive=False, buttons=[]) with gr.Row(equal_height=False): click_kind = gr.Radio(CLICK_CHOICES, value="left_click", label="Clicking the screen does", scale=2) type_box = gr.Textbox(label="Text", placeholder="typed at the keyboard focus", scale=3) type_btn = gr.Button("Type", scale=1, size="md") key_box = gr.Textbox(label="Key", placeholder="enter, escape, ctrl+z, shift+up", scale=2) key_btn = gr.Button("Press", scale=1, size="md") screen_btn = gr.Button("Refresh screen", scale=1, size="md") screen_out = gr.Markdown() with gr.Tab("Tool call"): with gr.Row(): tool = gr.Dropdown([], label="Tool", scale=3) run_btn = gr.Button("Call tool", variant="primary", scale=1) with gr.Row(equal_height=False): with gr.Column(scale=1): args = gr.Code(label="Arguments (JSON)", language="json", value="{}", lines=10) tool_out = gr.Markdown() tool_img = gr.Image(label="Tool image output", type="pil", height=420, interactive=False, scale=1) with gr.Tab("HTML"): html_hint = gr.Markdown("Load a task with **Surface = HTML** to use `write_html`.", visible=True) html_code = gr.Code(label="Page", language="html", lines=22) with gr.Row(): read_btn = gr.Button("Read current page") write_btn = gr.Button("write_html", variant="primary") html_out = gr.Markdown() with gr.Tab("Expert"): gr.Markdown("Replays the ground truth as the tool calls (or the one HTML page) that build it: the SFT " "trajectory, which scores 1.000. On computer use it sets the editor's document; in a real " "app it writes the design into the app's own document, which Submit then reads back.") expert_btn = gr.Button("Replay expert trajectory", variant="primary") expert_out = gr.Textbox(label="Calls", lines=16) with gr.Tab("Trajectory"): traj_btn = gr.Button("Refresh") traj = gr.JSON() with gr.Row(equal_height=False): with gr.Column(scale=1, min_width=260): target = gr.Image(label="Target (render of the ground truth)", type="pil", height=360, interactive=False) brief = gr.Textbox(label="Design brief", lines=16, visible=False, interactive=False) with gr.Column(scale=1, min_width=260): canvas = gr.Image(label="Current canvas (as scored)", type="pil", height=360, interactive=False) with gr.Column(scale=1, min_width=260): refresh_btn = gr.Button("↻ Re-render canvas") read_app_btn = gr.Button("Read the document from the app", visible=False) submit_btn = gr.Button("✅ Submit & score", variant="primary") result = gr.Markdown("*Not submitted yet.*") with gr.Accordion("Score breakdown (per element)", open=False): details = gr.JSON() with gr.Accordion("Assets", open=False): assets = gr.Gallery(label="Assets (id under each)", columns=8, height=240, object_fit="contain", allow_preview=True) with gr.Accordion("Real apps on this server", open=False): apps_md = gr.Markdown(apps_table(False)) apps_btn = gr.Button("Check what is up") with gr.Accordion("Instructions the agent receives", open=False): instructions = gr.Markdown() with gr.Accordion("How the reward is computed", open=False): gr.Markdown(SCORING) outputs = [episode, index, info, target, brief, assets, editor, canvas, instructions, tool, tool_out, tool_img, html_out, result, details, html_hint, app_box, screen, read_app_btn] load_btn.click(load, [split, index, mode, surface, skin, episode], outputs) shuffle_btn.click(shuffle, [split, mode, surface, skin, episode], outputs) prev_btn.click(lambda s, i, m, su, sk, e: step_index(s, i, -1, m, su, sk, e), [split, index, mode, surface, skin, episode], outputs) next_btn.click(lambda s, i, m, su, sk, e: step_index(s, i, 1, m, su, sk, e), [split, index, mode, surface, skin, episode], outputs) tool.change(tool_template, [tool], [args]) run_btn.click(call_tool, [episode, tool, args], [tool_out, tool_img, canvas]).then( screen_now, [episode], [screen]) read_btn.click(read_html, [episode], [html_code]) write_btn.click(write_html, [episode, html_code], [html_out, canvas]) expert_btn.click(replay_expert, [episode], [expert_out, canvas]) traj_btn.click(trajectory, [episode], [traj]) refresh_btn.click(render_now, [episode], [canvas]) submit_btn.click(submit, [episode], [result, details, canvas]) def pick_skin(ep_id, sk): # the built-in editor's layout can change on a live episode; a real app needs a new one (Load) ep = STORE.maybe(ep_id) live = ep is not None and ep.skin not in REAL_SKINS and sk not in REAL_SKINS return editor_frame(ep_id, sk) if live else editor_frame(None, sk) def pick_surface(su, sk): # only offer the editors this surface can use; a real app falls back to the default layout return gr.update(choices=skin_choices(su), value=sk if su == "gui" or sk not in REAL_SKINS else "figma") skin.change(pick_skin, [episode, skin], [editor]) surface.change(pick_surface, [surface, skin], [skin]) screen.select(click_screen, [episode, click_kind], [screen, screen_out]) type_btn.click(lambda e, t: act_on_screen(e, "type", {"text": t}), [episode, type_box], [screen, screen_out]) key_btn.click(lambda e, k: act_on_screen(e, "key", {"keys": k}), [episode, key_box], [screen, screen_out]) screen_btn.click(lambda e: (screen_now(e), ""), [episode], [screen, screen_out]) read_app_btn.click(read_from_app, [episode], [canvas]) apps_btn.click(lambda: apps_table(True), [], [apps_md]) return demo