from __future__ import annotations from pathlib import Path from PIL import Image, ImageDraw, ImageEnhance, ImageFilter, ImageFont ROOT = Path(__file__).resolve().parents[4] / "svdailab-home" ASSET_DIR = ROOT / "blog" / "assets" / "comsol-hbm-hbn-reproduction" PAPER_DIR = ASSET_DIR / "paper-frames" OUT = ASSET_DIR / "hbm-hbn-comsol-demo.gif" W, H = 1280, 720 BG = (8, 8, 13) FG = (245, 245, 247) MUTED = (180, 184, 195) CYAN = (125, 249, 255) VIOLET = (192, 132, 252) GREEN = (108, 255, 181) def font(size: int, bold: bool = False, mono: bool = False) -> ImageFont.FreeTypeFont: candidates = [ r"C:\Windows\Fonts\consolab.ttf" if mono and bold else r"C:\Windows\Fonts\consola.ttf" if mono else "", r"C:\Windows\Fonts\segoeuib.ttf" if bold else r"C:\Windows\Fonts\segoeui.ttf", r"C:\Windows\Fonts\arialbd.ttf" if bold else r"C:\Windows\Fonts\arial.ttf", ] for candidate in candidates: if not candidate: continue path = Path(candidate) if path.exists(): return ImageFont.truetype(str(path), size) return ImageFont.load_default() FONT_TITLE = font(30, True) FONT_LABEL = font(14, True) FONT_BODY = font(16) FONT_SMALL = font(13) FONT_TINY = font(10) FONT_MONO = font(14, mono=True) FONT_MONO_BOLD = font(15, bold=True, mono=True) def cover(img: Image.Image, box: tuple[int, int], focus: tuple[float, float] = (0.5, 0.5)) -> Image.Image: img = img.convert("RGB") bw, bh = box scale = max(bw / img.width, bh / img.height) resized = img.resize((round(img.width * scale), round(img.height * scale)), Image.Resampling.LANCZOS) x = round((resized.width - bw) * focus[0]) y = round((resized.height - bh) * focus[1]) return resized.crop((x, y, x + bw, y + bh)) def contain(img: Image.Image, box: tuple[int, int]) -> Image.Image: img = img.convert("RGB") bw, bh = box scale = min(bw / img.width, bh / img.height) return img.resize((round(img.width * scale), round(img.height * scale)), Image.Resampling.LANCZOS) def rounded_paste(base: Image.Image, img: Image.Image, xy: tuple[int, int], radius: int = 14) -> None: mask = Image.new("L", img.size, 0) d = ImageDraw.Draw(mask) d.rounded_rectangle((0, 0, img.width, img.height), radius=radius, fill=255) base.paste(img, xy, mask) d = ImageDraw.Draw(base) x, y = xy d.rounded_rectangle((x, y, x + img.width - 1, y + img.height - 1), radius=radius, outline=(255, 255, 255, 44), width=2) def background() -> Image.Image: im = Image.new("RGB", (W, H), BG) overlay = Image.new("RGBA", (W, H), (0, 0, 0, 0)) d = ImageDraw.Draw(overlay) d.ellipse((-120, -150, 650, 580), fill=(39, 69, 89, 60)) d.ellipse((350, -180, 1130, 620), fill=(88, 49, 126, 42)) overlay = overlay.filter(ImageFilter.GaussianBlur(70)) im = Image.alpha_composite(im.convert("RGBA"), overlay).convert("RGB") d = ImageDraw.Draw(im) for x in range(0, W, 32): for y in range(0, H, 32): d.point((x + 10, y + 8), fill=(48, 54, 72)) return im def label(draw: ImageDraw.ImageDraw, text: str, x: int, y: int, color=CYAN) -> None: width = draw.textbbox((0, 0), text, font=FONT_LABEL)[2] + 24 draw.rounded_rectangle((x, y, x + width, y + 24), radius=5, fill=(14, 18, 25), outline=color) draw.text((x + 12, y + 4), text, fill=color, font=FONT_LABEL) def wrap(draw: ImageDraw.ImageDraw, text: str, width: int, fnt: ImageFont.ImageFont) -> list[str]: words = text.split() lines: list[str] = [] current = "" for word in words: trial = f"{current} {word}".strip() if draw.textbbox((0, 0), trial, font=fnt)[2] <= width: current = trial else: if current: lines.append(current) current = word if current: lines.append(current) return lines def draw_body(draw: ImageDraw.ImageDraw, body: str, x: int, y: int, width: int, max_lines: int = 6) -> None: for line in wrap(draw, body, width, FONT_BODY)[:max_lines]: draw.text((x, y), line, fill=MUTED, font=FONT_BODY) y += 23 def paste_panel( im: Image.Image, image_path: Path, box: tuple[int, int, int, int], *, fit: str = "contain", focus: tuple[float, float] = (0.5, 0.5), radius: int = 14, ) -> None: x, y, bw, bh = box src = Image.open(image_path) panel = cover(src, (bw, bh), focus) if fit == "cover" else contain(src, (bw, bh)) panel = ImageEnhance.Sharpness(panel).enhance(1.16) panel = ImageEnhance.Contrast(panel).enhance(1.04) px = x + (bw - panel.width) // 2 py = y + (bh - panel.height) // 2 rounded_paste(im, panel, (px, py), radius) def footer(draw: ImageDraw.ImageDraw, paper: bool = False) -> None: text = "Paper excerpts adapted from Wang, Yan & Huang, arXiv:2510.11461, CC BY 4.0" if paper else "COMSOL/Codex reproduction artifacts by svd-ai-lab" draw.text((54, H - 34), text, fill=(150, 155, 165), font=FONT_TINY) def make_paper_context_slide() -> Image.Image: im = background() d = ImageDraw.Draw(im) label(d, "paper context", 54, 36, VIOLET) d.text((54, 70), "Paper Target in One Frame", fill=FG, font=FONT_TITLE) d.text((54, 110), "Title, abstract, and key figure stay visible, then the video moves into COMSOL.", fill=MUTED, font=FONT_SMALL) paste_panel(im, PAPER_DIR / "paper-title-abstract.png", (54, 146, 560, 460), fit="contain") paste_panel(im, PAPER_DIR / "paper-key-figure.png", (666, 146, 560, 460), fit="contain") footer(d, paper=True) return im def make_slide( title: str, body: str, image_path: Path, *, image_box: tuple[int, int, int, int], fit: str = "contain", label_text: str = "", focus: tuple[float, float] = (0.5, 0.5), label_color=CYAN, ) -> Image.Image: im = background() d = ImageDraw.Draw(im) if label_text: label(d, label_text, 54, 42, label_color) d.text((54, 78), title, fill=FG, font=FONT_TITLE) draw_body(d, body, 54, 122, 286, max_lines=7) paste_panel(im, image_path, image_box, fit=fit, focus=focus) footer(d) return im def make_terminal_slide() -> Image.Image: im = background() d = ImageDraw.Draw(im) label(d, "CLI control", 54, 42, GREEN) d.text((54, 78), "Scripts Drive the Visible Model", fill=FG, font=FONT_TITLE) d.text((54, 118), "The workflow is not a passive screenshot: commands mutate the same live COMSOL model.", fill=MUTED, font=FONT_SMALL) panel = (54, 154, 430, 430) x, y, bw, bh = panel d.rounded_rectangle((x, y, x + bw, y + bh), radius=12, fill=(7, 12, 18), outline=(75, 245, 169), width=2) d.ellipse((x + 18, y + 16, x + 28, y + 26), fill=(255, 95, 87)) d.ellipse((x + 36, y + 16, x + 46, y + 26), fill=(255, 189, 46)) d.ellipse((x + 54, y + 16, x + 64, y + 26), fill=(39, 201, 63)) d.text((x + 82, y + 12), "sim exec trace", fill=(150, 160, 175), font=FONT_MONO) lines = [ "> sim connect comsol --shared-desktop", "ok: live Model Builder bound", "> exec 20_geometry_probe.py", "selections: gpu, lid, interposer", "> exec 30_build_solve_baseline.py", "solve: hbn p100w t300um", "export: MPH + CSV + JSON", ] yy = y + 58 for idx, line in enumerate(lines): color = GREEN if line.startswith(">") else (196, 205, 218) d.text((x + 24, yy), line, fill=color, font=FONT_MONO_BOLD if line.startswith(">") else FONT_MONO) yy += 39 if idx in (0, 2, 4) else 28 paste_panel(im, ASSET_DIR / "01_shared_desktop_identity-focus.png", (528, 92, 680, 520), fit="cover", focus=(0.44, 0.45)) footer(d) return im def make_result_pair_slide() -> Image.Image: im = background() d = ImageDraw.Draw(im) label(d, "postprocess", 54, 42, CYAN) d.text((54, 78), "Rendered Results, Then Trend Checks", fill=FG, font=FONT_TITLE) d.text((54, 118), "COMSOL gives the model state; Python plots make the reproduction claim easier to inspect.", fill=MUTED, font=FONT_SMALL) paste_panel(im, ASSET_DIR / "tdp-material-comparison.png", (54, 158, 560, 420), fit="contain") paste_panel(im, ASSET_DIR / "hbn-thickness-sweep.png", (666, 158, 560, 420), fit="contain") footer(d) return im slides = [ make_paper_context_slide(), make_slide( "Attach Live COMSOL", "Shared Desktop connects Codex, the CLI, and the open COMSOL window into one visible run loop.", ASSET_DIR / "01_shared_desktop_identity.png", image_box=(350, 76, 870, 550), fit="cover", label_text="live desktop", focus=(0.52, 0.50), ), make_terminal_slide(), make_slide( "Build Geometry", "The first scripted checkpoint creates the representative 3D stack before any sweep work.", ASSET_DIR / "02_geometry_probe-comsol-window.png", image_box=(360, 78, 850, 548), fit="cover", label_text="COMSOL model", focus=(0.50, 0.44), ), make_slide( "Probe Selections", "Generated domain and boundary selections are inspected before assigning materials and heat paths.", ASSET_DIR / "02_geometry_probe-stack-detail.png", image_box=(430, 82, 700, 560), fit="contain", label_text="COMSOL API", ), make_slide( "Run Sweeps", "The driver switches interposer material, GPU power, and h-BN thickness while saving repeatable artifacts.", ASSET_DIR / "03_sweep_complete.png", image_box=(350, 76, 870, 550), fit="cover", label_text="automation", focus=(0.55, 0.50), ), make_slide( "Render in COMSOL", "The GUI plot gives a sanity check that the model solved and the thermal field is visible.", ASSET_DIR / "04_comsol_temperature_plot_attempt.png", image_box=(350, 76, 870, 550), fit="cover", label_text="COMSOL render", focus=(0.58, 0.44), ), make_result_pair_slide(), make_slide( "Publish the Reproduction", "The final blog carries screenshots, scripts, CLI trace, and the caveat: this is trend-level, not author-calibrated.", ASSET_DIR / "result-story.png", image_box=(380, 116, 820, 455), fit="contain", label_text="results", ), ] video_effects = [ {"duration": 1.7, "start_zoom": 1.000, "end_zoom": 1.012, "start_focus": (0.50, 0.50), "end_focus": (0.51, 0.50), "overlay": "paper"}, {"duration": 2.35, "start_zoom": 1.000, "end_zoom": 1.030, "start_focus": (0.47, 0.50), "end_focus": (0.60, 0.50), "overlay": "spotlight", "rects": [(344, 70, 1228, 634)]}, {"duration": 2.45, "start_zoom": 1.000, "end_zoom": 1.020, "start_focus": (0.45, 0.50), "end_focus": (0.56, 0.50), "overlay": "terminal"}, {"duration": 2.30, "start_zoom": 1.000, "end_zoom": 1.026, "start_focus": (0.50, 0.50), "end_focus": (0.57, 0.45), "overlay": "spotlight", "rects": [(610, 155, 990, 545)]}, {"duration": 2.25, "start_zoom": 1.000, "end_zoom": 1.026, "start_focus": (0.50, 0.45), "end_focus": (0.53, 0.50), "overlay": "spotlight", "rects": [(520, 146, 1030, 604)]}, {"duration": 2.45, "start_zoom": 1.000, "end_zoom": 1.030, "start_focus": (0.48, 0.50), "end_focus": (0.62, 0.50), "overlay": "spotlight", "rects": [(344, 70, 1228, 634)]}, {"duration": 2.45, "start_zoom": 1.000, "end_zoom": 1.035, "start_focus": (0.46, 0.48), "end_focus": (0.66, 0.45), "overlay": "spotlight", "rects": [(820, 126, 1160, 470)]}, {"duration": 2.30, "start_zoom": 1.000, "end_zoom": 1.018, "start_focus": (0.50, 0.50), "end_focus": (0.52, 0.50), "overlay": "results"}, {"duration": 2.20, "start_zoom": 1.000, "end_zoom": 1.020, "start_focus": (0.50, 0.50), "end_focus": (0.52, 0.50), "overlay": "results"}, ] def gif_frames() -> tuple[list[Image.Image], list[int]]: frames: list[Image.Image] = [] durations: list[int] = [] for slide, effect in zip(slides, video_effects): frames.append(slide) durations.append(round(effect["duration"] * 1000)) return frames, durations def write_gif() -> None: frames, durations = gif_frames() frames[0].save( OUT, save_all=True, append_images=frames[1:], duration=durations, loop=0, optimize=True, disposal=2, ) print(f"wrote {OUT} ({OUT.stat().st_size / 1024 / 1024:.2f} MiB)") if __name__ == "__main__": write_gif()