openpencil/templates/step0/_generators/scene_preview_cards.py
Fini 267a08f7aa feat(editor): register the six deck templates in the picker
Wires the new decks into the catalog, the preview grid and all fifteen
locales, so they are reachable rather than merely present on disk.

The preview cards ship as JPEGs like the rest, which grows the staged
scene-template payload; both figures in the asset notes are refreshed
so the wasm budget stays auditable against a real number.

Claude-Session: https://claude.ai/code/session_01FqKQqNj8exYwopGDpYUU7x
2026-08-09 19:54:23 +08:00

295 lines
13 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env python3
"""Bake scene-template card previews for the Scene Template Center.
`previews.sh` renders one PNG per frame plus a wide overview; those are the
authoring artefacts — full resolution, arbitrary aspect. The template center
paints every card through one fixed rect with `ImageDrawMode::Fill`, so a
tall or ultra-wide source would be cropped to an unrecognisable strip.
This bakes each source onto the card aspect with the whole design still
visible (contain, not cover), padded with the document's own background
colour so the card reads as a framed thumbnail rather than a letterboxed
photo. Output is a 16:10 JPEG at 2x the largest size the gallery paints; see
CARD_W/CARD_H below for how that size is derived.
Usage:
python3 scene_preview_cards.py # write into the UI crate
python3 scene_preview_cards.py --check # verify without writing
"""
from __future__ import annotations
import argparse
import pathlib
import sys
from typing import NamedTuple
from PIL import Image
class Top(NamedTuple):
"""Take the card's aspect off the top of this render, full width."""
name: str
REPO = pathlib.Path(__file__).resolve().parents[3]
SRC = REPO / "templates" / "step0" / "previews"
DST = REPO / "crates" / "op-editor-ui" / "assets" / "scene_template_previews"
# 2x the widest preview the gallery can paint, and no more — every byte here
# is embedded in the wasm bundle, which has a hard ceiling.
#
# The Asset Center is a full-canvas gallery now, so the card width is derived
# rather than fixed. Its maximum falls out of the column breakpoints: a
# two-column grid tops out just under 1000px of viewport, giving a ~490px card
# and a ~470px preview, so 940 device px is the worst case on a 2x display.
# The old 640px bake was sized for a 720px dialog and went visibly soft.
CARD_W, CARD_H = 1024, 640
JPEG_QUALITY = 82
# Keep a small margin so the design never bleeds into the card's rounded
# corners — the panel clips to a 9px radius.
INSET = 20
# (card id, source preview). The overview render is preferred wherever a
# template has several frames: what makes a multi-page template legible at
# thumbnail size is seeing that it HAS pages, not reading one of them.
# (card id, source). A string names one render; a list is tiled into a grid
# whose column count is chosen to land near the card's own aspect — a 16:9
# deck laid out 1x6 is a 10:1 strip that shrinks each slide to ~100px inside
# the card, which reads as noise rather than as slides. `Top(name)` takes the
# card's own aspect off the top of a very tall render instead of fitting the
# whole thing: a 1:4.5 marketing page contained inside a 16:10 card is a
# 140px-wide sliver, where its masthead and hero at full width are exactly
# what makes it recognisable.
#
# 多板模板一律走帧列表,**不要**拿 `<id>-overview.png` 当源overview 是整画布
# 导出,板与板之间的间隙和最后一行缺的格子都是画布底色,缩进卡片里就是几道黑
# 条。帧列表这条路由 `tile()` 自己按文档底色铺画布(`background_colour` 从首帧
# 四角取),缺的格子跟着一起变成文档底色,所以永远不会露黑。
CARDS = [
("screenshot-tutorial",
[f"screenshot-tutorial-{index:02d}.png" for index in range(1, 6)]),
("knowledge-carousel",
[f"knowledge-carousel-{index:02d}.png" for index in range(1, 6)]),
("before-after", "before-after.png"),
("slide-deck", [f"slide-deck-{i:02d}.png" for i in range(1, 7)]),
("knowledge-card-vertical", "knowledge-card-vertical.png"),
("knowledge-card-square", "knowledge-card-square.png"),
("pitch-deck-dark", [f"pitch-deck-dark-{i:02d}.png" for i in range(1, 7)]),
("lecture-deck-light",
[f"lecture-deck-light-{i:02d}.png" for i in range(1, 7)]),
("minimal-keynote", [f"minimal-keynote-{i:02d}.png" for i in range(1, 10)]),
("gradient-tech", [f"gradient-tech-{i:02d}.png" for i in range(1, 7)]),
("saas-landing-orange", Top("saas-landing-orange.png")),
("product-landing-light", Top("product-landing-light.png")),
("punch-quote-card", "punch-quote-card.png"),
("journal-checklist-card", "journal-checklist-card.png"),
# The infographics are 1:2.5 and 1:3 — contained in a 16:10 card each
# would be a ~250px sliver. `Top` keeps the masthead and the first
# section at full width, which is what makes a long-form graphic
# recognisable as one.
("data-report-infographic", Top("data-report-infographic.png")),
("steps-flow-infographic", Top("steps-flow-infographic.png")),
("pitfall-list-infographic", Top("pitfall-list-infographic.png")),
("spine-culture-card", "spine-culture-card.png"),
("metric-single-card", "metric-single-card.png"),
("quote-frame-card", "quote-frame-card.png"),
("daily-sign-card", "daily-sign-card.png"),
("price-tier-card", "price-tier-card.png"),
("notice-board-card", "notice-board-card.png"),
("milestone-timeline-infographic", Top("milestone-timeline-infographic.png")),
("concept-contrast-infographic", Top("concept-contrast-infographic.png")),
("ranking-board-infographic", Top("ranking-board-infographic.png")),
("faq-thread-infographic", Top("faq-thread-infographic.png")),
("data-story-infographic", Top("data-story-infographic.png")),
("challenge-tracker-infographic", Top("challenge-tracker-infographic.png")),
("ecosystem-map-infographic", Top("ecosystem-map-infographic.png")),
("do-dont-comparison", "do-dont-comparison.png"),
("myth-truth-comparison", Top("myth-truth-comparison.png")),
("pricing-tiers-comparison", "pricing-tiers-comparison.png"),
("scenario-guide-comparison", Top("scenario-guide-comparison.png")),
("spec-table-comparison", Top("spec-table-comparison.png")),
("three-way-comparison", Top("three-way-comparison.png")),
("time-shift-comparison", "time-shift-comparison.png"),
("tradeoff-scale-comparison", "tradeoff-scale-comparison.png"),
("version-diff-comparison", "version-diff-comparison.png"),
("app-onboarding-triptych", "app-onboarding-triptych.png"),
("diy-blueprint-guide", Top("diy-blueprint-guide.png")),
("photo-composition-tutorial",
[f"photo-composition-tutorial-{index:02d}.png" for index in range(1, 6)]),
("recipe-four-step", "recipe-four-step.png"),
("skincare-routine-cards",
[f"skincare-routine-cards-{index:02d}.png" for index in range(1, 7)]),
("software-step-tutorial", "software-step-tutorial.png"),
("storage-makeover-steps",
[f"storage-makeover-steps-{index:02d}.png" for index in range(1, 7)]),
("weekly-report-lesson", Top("weekly-report-lesson.png")),
("workout-breakdown-guide", Top("workout-breakdown-guide.png")),
("bookreview-silk-carousel",
[f"bookreview-silk-carousel-{index:02d}.png" for index in range(1, 6)]),
("cityguide-film-carousel",
[f"cityguide-film-carousel-{index:02d}.png" for index in range(1, 8)]),
("datareport-grid-carousel",
[f"datareport-grid-carousel-{index:02d}.png" for index in range(1, 7)]),
("opinion-longform-carousel",
[f"opinion-longform-carousel-{index:02d}.png" for index in range(1, 7)]),
("qa-chalkboard-carousel",
[f"qa-chalkboard-carousel-{index:02d}.png" for index in range(1, 7)]),
("story-night-carousel",
[f"story-night-carousel-{index:02d}.png" for index in range(1, 8)]),
("toolkit-notebook-carousel",
[f"toolkit-notebook-carousel-{index:02d}.png" for index in range(1, 7)]),
("tutorial-journal-carousel",
[f"tutorial-journal-carousel-{index:02d}.png" for index in range(1, 7)]),
("yearreview-mineral-carousel",
[f"yearreview-mineral-carousel-{index:02d}.png" for index in range(1, 9)]),
("event-poster-deck",
[f"event-poster-deck-{i:02d}.png" for i in range(1, 7)]),
("sounding-navy-deck",
[f"sounding-navy-deck-{i:02d}.png" for i in range(1, 8)]),
("tidemark-slate-deck",
[f"tidemark-slate-deck-{i:02d}.png" for i in range(1, 8)]),
("banxin-rule-deck",
[f"banxin-rule-deck-{i:02d}.png" for i in range(1, 8)]),
("gridpaper-graphite-deck",
[f"gridpaper-graphite-deck-{i:02d}.png" for i in range(1, 9)]),
("dossier-linen-deck",
[f"dossier-linen-deck-{i:02d}.png" for i in range(1, 9)]),
("ledger-tick-deck",
[f"ledger-tick-deck-{i:02d}.png" for i in range(1, 8)]),
]
# Gap between tiles, in source pixels — scaled down with everything else.
TILE_GAP = 48
def background_colour(image: Image.Image) -> tuple[int, int, int]:
"""The document's own backdrop, sampled from the render's corners.
Every corner agreeing means the render has a uniform backdrop and we can
extend it. Disagreement means the design bleeds to its edges, and any
single sample would be an arbitrary tint — a neutral card wins there.
"""
rgb = image.convert("RGB")
w, h = rgb.size
corners = [
rgb.getpixel((0, 0)),
rgb.getpixel((w - 1, 0)),
rgb.getpixel((0, h - 1)),
rgb.getpixel((w - 1, h - 1)),
]
if all(c == corners[0] for c in corners):
return corners[0]
return (245, 245, 245)
def grid_columns(tile_aspect: float, count: int) -> int:
"""Column count whose tiled aspect sits closest to the card's.
Chosen rather than fixed so a 16:9 deck tiles 3x2 while a 3:4 carousel
tiles wide — the goal is filling the card, not a particular shape.
"""
card_aspect = CARD_W / CARD_H
best, best_error = count, float("inf")
for columns in range(1, count + 1):
rows = -(-count // columns)
aspect = (columns * tile_aspect) / rows
error = abs(aspect - card_aspect)
if error < best_error:
best, best_error = columns, error
return best
def tile(sources: list[pathlib.Path]) -> Image.Image:
images = [Image.open(path).convert("RGB") for path in sources]
width = max(i.width for i in images)
height = max(i.height for i in images)
columns = grid_columns(width / height, len(images))
rows = -(-len(images) // columns)
canvas = Image.new(
"RGB",
(
columns * width + (columns - 1) * TILE_GAP,
rows * height + (rows - 1) * TILE_GAP,
),
background_colour(images[0]),
)
for index, image in enumerate(images):
column, row = index % columns, index // columns
canvas.paste(image, (column * (width + TILE_GAP), row * (height + TILE_GAP)))
return canvas
def crop_to_card_top(source: pathlib.Path) -> Image.Image:
"""Full-width band off the top of a render, already at the card's aspect.
Returned pre-cropped so the shared fit path below only ever scales it —
the band is the whole picture, so it fills the card edge to edge with no
letterboxing to pad.
"""
image = Image.open(source).convert("RGB")
band = min(image.height, round(image.width * CARD_H / CARD_W))
return image.crop((0, 0, image.width, band))
def bake(source: pathlib.Path | list[pathlib.Path] | Top) -> Image.Image:
if isinstance(source, Top):
image = crop_to_card_top(SRC / source.name)
elif isinstance(source, list):
image = tile(source)
else:
image = Image.open(source)
canvas = Image.new("RGB", (CARD_W, CARD_H), background_colour(image))
fitted = image.convert("RGB")
fitted.thumbnail((CARD_W - 2 * INSET, CARD_H - 2 * INSET), Image.LANCZOS)
canvas.paste(
fitted,
((CARD_W - fitted.width) // 2, (CARD_H - fitted.height) // 2),
)
return canvas
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--check", action="store_true")
args = parser.parse_args()
if not args.check:
DST.mkdir(parents=True, exist_ok=True)
failed = False
for card_id, source_name in CARDS:
if isinstance(source_name, Top):
names = [source_name.name]
elif isinstance(source_name, list):
names = source_name
else:
names = [source_name]
sources = [SRC / name for name in names]
missing = [path for path in sources if not path.exists()]
if missing:
for path in missing:
print(f"missing source: {path}", file=sys.stderr)
failed = True
continue
if isinstance(source_name, Top):
source = source_name
elif isinstance(source_name, list):
source = sources
else:
source = sources[0]
target = DST / f"{card_id}.jpg"
if args.check:
status = "ok" if target.exists() else "MISSING"
print(f"{status}: {target.relative_to(REPO)}")
failed = failed or status == "MISSING"
continue
bake(source).save(target, "JPEG", quality=JPEG_QUALITY, optimize=True)
print(f"{target.relative_to(REPO)} {target.stat().st_size // 1024} KB")
return 1 if failed else 0
if __name__ == "__main__":
raise SystemExit(main())