diff --git a/.gitignore b/.gitignore index 640da70..1a2b92c 100644 --- a/.gitignore +++ b/.gitignore @@ -97,3 +97,6 @@ TestResults/ # Local maintainer follow-ups, not part of the public examples. /backlog.md + +# Author-supplied full manuscript; publish only the extracted front cover. +/figures/book cover.pdf diff --git a/README.md b/README.md index b7b04c1..17f4d4a 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,9 @@ Companion examples for the Apress book by **Bob Ward** [Code: aka.ms/sqlhyperscalebook](https://aka.ms/sqlhyperscalebook) • [GitHub repository](https://github.com/rgward/sqlhyperscalebook) -[View or download the book cover (PDF)](figures/book-cover.pdf) +[High-resolution book cover (PNG)](figures/book-cover.png) • +[Book GIF](figures/book-chapters.gif) • +[4K book video](figures/book-chapters-4k.mp4) Special thanks to **Kendal Van Dyke** for reviewing both the book and this repository. diff --git a/figures/README.md b/figures/README.md index 2c83f7d..5ff87d7 100644 --- a/figures/README.md +++ b/figures/README.md @@ -24,7 +24,8 @@ map, compute-stage chart, and Azure ecosystem hub. Charts are illustrative, not measured performance results. Chapter 1 honors Project Socrates and the engineering of a new distributed, cloud-native SQL architecture, tracing SQL Server through Socrates to Hyperscale. -The opening cover emphasizes the book and its subject, with a smaller note +The actual front cover opens the animation, followed by an inside title page. +That title page emphasizes the book and its subject, with a smaller note explicitly identifying the book examples as AI-assisted creation and guided learning, not describing Hyperscale itself as built with AI. The cover note and Chapter 2 illustrate both roles of AI: AI helped build the @@ -33,8 +34,12 @@ including guided provisioning, connection, and schema setup. Chapter pages omit repetitive fine-print notes to keep the animation readable; the book and repository documentation provide the synthetic-data and illustrative-chart context. -The architecture page distinguishes HA and named replicas, showing page-server -reads from all compute types, the primary's log write, and replica log redo. +The architecture page distinguishes HA and named replicas. Brown arrows show +the primary writing to the log service and both the log service and page servers +writing to Azure Storage. Teal arrows show log data flowing from the log service +to HA replicas, named replicas, and page servers; blue arrows show pages flowing +from page servers to the primary and replicas. Read arrows follow the returned +data, not the requests. Shared read lanes keep these paths separate from writes. Chapter 5 shows vectors, DiskANN retrieval, and RAG orchestration inside the SQL engine, with the external model request and response routed through the APIM AI gateway @@ -45,25 +50,37 @@ Chapter 10 highlights serverless, elastic pools, optimized locking, and automati index compaction alongside JSON indexing, memory grant feedback, and illustrative vCore scaling through 8, 16, 40, and 192. The 192-vCore point is not a measured demo stage; the repository's compute demo remains 8, 16, and 40 vCores. -Chapter 11 includes change event streaming in its Azure ecosystem diagram. +Chapter 11 surrounds Azure SQL Hyperscale with six talking points: Azure, +Microsoft Foundry, Migrate, Azure App Service, Change Event Streaming, and +Fabric Mirroring. Chapter 9 separates built-in HA, geo-replication, and point-in-time restore into three illustrations, with failover and application retries called out. +The PITR caption emphasizes recovery: "Recover from a mistake quickly." Chapter 7 includes Database Hub, DMVs, and database agents alongside Azure Monitor, Query Store, and engine events. -The opening and closing pages show both the book and code links. -Square format, 768 x 768 pixels, about 110 seconds, looping, no audio. -The opening page remains visible for twelve seconds. -Each chapter remains visible for six seconds before the page turns. -The summary page remains visible for fourteen seconds so readers can read the +Chapter 6 places the handoff agent inside the Ward General app, connected to an +external LLM for orchestration and to SQL MCP for database tool execution. +Human approval gates saving the handoff. +The inside title page and closing page show both the book and code links. +Square format, 640 x 640 pixels, about 69 seconds, looping, no audio. +The GIF uses 96 colors and fewer intermediate turn frames to stay below 5 MB. +It starts slowly, then moves through the chapters faster; the 4K video is unchanged. +The actual front cover remains visible for six seconds and opens smoothly over +2.4 seconds; the inside title spread remains visible for six seconds. +Each chapter remains visible for 2.5 seconds before the page turns. +The summary page remains visible for seven seconds so readers can read the quote excerpt, attributed to Arun Ulag, Vice President of Azure (title updated from the book at the author's request). -The book has a visible binding and layered paper edges; curved page turns -show softly mirrored ink on the reverse and a moving shadow. +Its subtitle is "Hyperscale is your database now and for the future." +The camera pulls back as the actual front cover opens against black, then stays +on the open two-page book. Pages lift toward the viewer and turn right to left +around the center spine. The final open-book version is the primary animation; +older chapter animations and opening-preview videos have been removed. Chapter titles and prose use Georgia serif typography for a printed-book feel; diagram labels retain sans-serif type for clarity. The editable generator is [make-book-chapters.py](../tools/make-book-chapters.py). -On Windows, with Python, Pillow, PyMuPDF, and the Segoe UI and Georgia fonts available, +On Windows, with Python, Pillow, and the Segoe UI and Georgia fonts available, regenerate it from the repository root: ```powershell @@ -73,21 +90,92 @@ python .\tools\make-book-chapters.py Pass `--preview-dir` with a local directory to also save a storyboard and page-turn preview outside the repository. -### Short promotional version +### Book cover -[View or download the book promo GIF](book-promo.gif): a square, looping -animation themed **"Hyperscale: The cloud-native database for today and your -future."** It explores the distributed architecture, SQL Server engine, -scaling, security, and recovery; follows Ward General into AI assistance and -approval-gated agents; and closes with why Azure is the best cloud for SQL. -Suitable for sharing on social media (768 x 768 pixels, 40 seconds; no audio). +The actual front cover, including the foreword credit, is available as a +[300-dpi PNG](book-cover.png), 2100 x 3000 pixels, extracted from the author's +supplied PDF. The generator uses this PNG directly; neither the cover PDF nor +the full manuscript is distributed with these examples. The older short +promotional GIF has been removed in favor of the chapter animation above. -The opening and closing frames show separate destinations for the -[book](https://aka.ms/sqlhyperscaleunveiled) and -[code](https://aka.ms/sqlhyperscalebook), plus the full -[GitHub repository address](https://github.com/rgward/sqlhyperscalebook). +### Event-screen video -The original [book cover PDF](book-cover.pdf) is also available. +[Download the 4K chapter video](book-chapters-4k.mp4): a silent H.264 MP4 at +3840 x 2160, 25 fps, lasting approximately 146 seconds. It follows the same +chapter sequence as the GIF, with longer holds and page turns for presentations. +The open book and turning pages are rendered natively at 4K +against black, not upscaled from the GIF. Enable repeat/loop in the event's +video player; MP4 does not carry +the GIF's infinite-loop setting. Test playback on the actual event screen +beforehand, since older players may not support 4K H.264. + +With FFmpeg and the chapter generator's Python dependencies installed, regenerate using +[make-book-video.ps1](../tools/make-book-video.ps1): + +```powershell +.\tools\make-book-video.ps1 +``` + +Use `-FfmpegPath` for an encoder outside PATH and `-OutputPath` for a different +destination. The script renders temporary widescreen PNG frames, removes them +after encoding, refuses to overwrite existing output, and decodes the entire +resulting video to check for errors. It does not transcode the square GIF. + +### Camera pullback and open-book spreads + +The primary [chapter GIF](book-chapters.gif) and +[4K video](book-chapters-4k.mp4) begin close to the actual cover against black. +The camera pulls back as +the cover opens, then stays on the open two-page book throughout the chapter +sequence. The first inside left page contains the book's exact dedication to the +Microsoft team who brought Project Socrates to life, the technical-reviewer credit +for Kendal Van Dyke, and thanks to quote contributors Shireesh Thota, Slava Oks, +Krystyna Reisteter, Anna Hoffman, Kendal Van Dyke, Naveen Prakash, and Arun Ulag. +The right page retains the cloud-native database theme, SQL Server and AI +highlights, AI-assisted book examples, and book/code links. The full manuscript is +not included in the repository. There is no separate invitation page. +Each chapter has its title and takeaways at the top and a large illustration +across the spread below. Pages lift toward the viewer and turn right to left +around the center spine; the book never transforms into a full-screen slide. +After Arun's summary and the closing links, the cover closes and the camera +returns to its starting view. + +The video lasts about 146 seconds. The cover opens for 2.4 seconds; each +chapter turn lasts 1.8 seconds. The first inside spread remains fourteen seconds. +Chapter holds remain six seconds and Arun's summary remains fourteen seconds. +The video runs at 25 fps. The square GIF lasts 68.8 seconds, uses approximately +17 fps for motion, loops continuously, and has no audio. It holds the cover for +six seconds and opens it over 2.4 seconds before accelerating to 0.9-second +chapter turns and 2.5-second chapter holds. The final summary holds for seven +seconds. The closing spread holds for four seconds, the cover closes over +1.2 seconds, and the final cover holds for three seconds. Video-export +timings remain unchanged. +All text stays inside the book; the black background has no captions or overlays. +The book URL, `aka.ms/sqlhyperscaleunveiled`, is printed at the bottom of every +inside page, including both sides of each spread. The introduction and closing +spreads also retain the prominent book and code links. The original cover art +is unchanged. + +The 4K video renders the book, typography, diagrams, and rotating sheets from +high-resolution page artwork, rather than switching from low-resolution turns +to high-resolution held pages. The cover uses the high-resolution PNG. +The Unit board uses the original screenshot crop, not the earlier thumbnail; +its detail is still limited by that source image. The shareable GIF is 640 x 640 +with a 96-color palette; the generator validates that it stays below 5,000,000 bytes. + +### Microsoft product icons + +Chapters 2 and 11 use Microsoft's official Azure SQL Database icon, labeled +"Azure SQL Hyperscale" beneath it. The unmodified [source SVG](icons/azure-sql-database.svg) +is `10130-icon-service-SQL-Database.svg` from the official Azure architecture +icons V24 package. Its [transparent 1024-pixel PNG](icons/azure-sql-database.png) +preserves the original colors and proportions and keeps the generator's existing +Python dependencies unchanged. + +These Microsoft icons are used under the +[Azure architecture icon terms](https://learn.microsoft.com/azure/architecture/icons/#icon-terms), +which permit use in architecture diagrams, training materials, and documentation. +They are Microsoft assets, not artwork licensed under this repository's MIT license. | Chapter | Figures | | --- | --- | diff --git a/figures/book-chapters-4k.mp4 b/figures/book-chapters-4k.mp4 new file mode 100644 index 0000000..fca89b5 Binary files /dev/null and b/figures/book-chapters-4k.mp4 differ diff --git a/figures/book-chapters.gif b/figures/book-chapters.gif index 3c3b7cd..98e48d8 100644 Binary files a/figures/book-chapters.gif and b/figures/book-chapters.gif differ diff --git a/figures/book-cover.pdf b/figures/book-cover.pdf deleted file mode 100644 index e09cfde..0000000 Binary files a/figures/book-cover.pdf and /dev/null differ diff --git a/figures/book-cover.png b/figures/book-cover.png new file mode 100644 index 0000000..7b5c853 Binary files /dev/null and b/figures/book-cover.png differ diff --git a/figures/book-promo.gif b/figures/book-promo.gif deleted file mode 100644 index b823352..0000000 Binary files a/figures/book-promo.gif and /dev/null differ diff --git a/figures/icons/azure-sql-database.png b/figures/icons/azure-sql-database.png new file mode 100644 index 0000000..4716e08 Binary files /dev/null and b/figures/icons/azure-sql-database.png differ diff --git a/figures/icons/azure-sql-database.svg b/figures/icons/azure-sql-database.svg new file mode 100644 index 0000000..4271bd1 --- /dev/null +++ b/figures/icons/azure-sql-database.svg @@ -0,0 +1 @@ +Icon-databases-130 \ No newline at end of file diff --git a/tools/make-book-chapters.py b/tools/make-book-chapters.py index c00a9f7..5c9d4e0 100644 --- a/tools/make-book-chapters.py +++ b/tools/make-book-chapters.py @@ -1,22 +1,50 @@ from pathlib import Path import argparse +from functools import lru_cache import math import re +import sys -import pymupdf -from PIL import Image, ImageChops, ImageDraw, ImageFont, ImageOps +from PIL import Image, ImageChops, ImageDraw, ImageEnhance, ImageFont, ImageOps parser = argparse.ArgumentParser(description="Render the chapter-by-chapter book GIF.") parser.add_argument("--preview-dir", type=Path, help="Optional directory for preview PNGs.") +parser.add_argument("--landscape", action="store_true", help="Show the physical book on a 16:9 canvas.") +parser.add_argument("--frames-dir", type=Path, help="Export PNG frames and a video timing manifest.") +# Accept the former version flag while making the approved open-book layout the default. +parser.add_argument("--v2", action="store_true", default=True, help=argparse.SUPPRESS) args = parser.parse_args() +if args.landscape and not args.frames_dir: + parser.error("--landscape requires --frames-dir") ROOT = Path(__file__).resolve().parents[1] S = 768 -PAPER = (248, 244, 233) +W = 1366 if args.landscape else S +PAPER = (255, 255, 255) INK = (22, 39, 56) MUTED = (80, 99, 112) GOLD = (255, 198, 28) TEAL = (0, 118, 126) BG = (14, 22, 34) +BOOK_URL = "aka.ms/sqlhyperscaleunveiled" +GIF_SIZE = 640 +GIF_COLORS = 96 +GIF_MAX_BYTES = 5_000_000 +SUMMARY_SUBTITLE = "Hyperscale is your database now and for the future." +BOOK_DEDICATION = ( + "This book is dedicated to all at Microsoft who spent countless hours " + "to take Project Socrates from a dream to the reality that is " + "Azure SQL Hyperscale today." +) +QUOTE_CONTRIBUTORS = ( + "Shireesh Thota", "Slava Oks", "Krystyna Reisteter", "Anna Hoffman", + "Kendal Van Dyke", "Naveen Prakash", "Arun Ulag", +) +ARUN_QUOTE = ( + '"Customers can begin with what they need today and expand over time ' + 'without redesigning the application or moving to a different platform. ' + 'That freedom to build once and evolve with confidence makes Hyperscale ' + 'a critical part of Microsoft\u2019s database strategy."' +) def font(size, bold=False, serif=False): @@ -28,8 +56,50 @@ def font(size, bold=False, serif=False): ) +class ScaledDraw: + """Draw in layout coordinates without raster-upscaling text or shapes.""" + + def __init__(self, image, scale=1): + self.draw = ImageDraw.Draw(image) + self.scale = scale + self.width = image.width / scale + + def coordinates(self, values): + return [self.coordinates(value) if isinstance(value, (tuple, list)) + else value * self.scale for value in values] + + def text(self, xy, value, font, **kwargs): + self.draw.text(self.coordinates(xy), value, + font=font.font_variant(size=round(font.size * self.scale)), **kwargs) + + def textlength(self, value, font): + return self.draw.textlength( + value, font=font.font_variant(size=round(font.size * self.scale)) + ) / self.scale + + def shape(self, name, xy, **kwargs): + if "width" in kwargs: + kwargs["width"] = max(1, round(kwargs["width"] * self.scale)) + getattr(self.draw, name)(self.coordinates(xy), **kwargs) + + def line(self, xy, **kwargs): + self.shape("line", xy, **kwargs) + + def rectangle(self, xy, **kwargs): + self.shape("rectangle", xy, **kwargs) + + def ellipse(self, xy, **kwargs): + self.shape("ellipse", xy, **kwargs) + + def polygon(self, xy, **kwargs): + self.shape("polygon", xy, **kwargs) + + def rounded_rectangle(self, xy, radius, **kwargs): + self.shape("rounded_rectangle", xy, radius=radius * self.scale, **kwargs) + + def text(d, xy, value, size=24, fill=INK, bold=False, serif=False): - assert xy[0] + d.textlength(value, font=font(size, bold, serif)) <= 740, value + assert xy[0] + d.textlength(value, font=font(size, bold, serif)) <= getattr(d, "width", W) - 28, value d.text(xy, value, font=font(size, bold, serif), fill=fill) @@ -75,7 +145,7 @@ def wrapped(d, xy, value, size, width, fill=INK, bold=False, serif=False): "Vectors + RAG in the SQL engine; governed calls via APIM."), ("Turn insight into action.", ["SQL MCP TOOLS", "HANDOFF AGENT", "APPROVAL"], ["Data API Builder", "Prepare handoff", "Gated save"], - "Find candidates. Prepare a handoff. Approve the write."), + "An app-hosted agent uses an LLM and SQL MCP; you approve the save."), ("See what the engine sees.", ["AZURE MONITOR", "QUERY STORE", "ENGINE EVENTS"], ["Service metrics", "Plans + runtime", "Extended Events"], "Database Hub, DMVs, and database agents."), @@ -88,59 +158,75 @@ def wrapped(d, xy, value, size, width, fill=INK, bold=False, serif=False): ("Unleash the engine.", ["SCALE COMPUTE", "GROW DATA", "TUNE QUERIES"], ["8 / 16 / 40 / 192 vCores", "Write workload", "JSON + feedback"], "Serverless. Elastic pools. Smarter scaling and tuning."), - ("Azure is the best cloud for SQL.", ["AZURE PLATFORM", "MIGRATION", "AI ECOSYSTEM"], - ["Identity + Monitor", "Move to Hyperscale", "Microsoft Foundry"], - "Change event streaming. Azure integration. Your AI future."), + ("Azure is the best cloud for SQL.", ["AZURE", "MICROSOFT FOUNDRY", "MIGRATE"], + ["Azure App Service", "Change Event Streaming", "Fabric Mirroring"], + "Azure. Microsoft Foundry. Migrate. Azure App Service. " + "Change Event Streaming. Fabric Mirroring."), ] -with pymupdf.open(ROOT / "figures" / "book-cover.pdf") as pdf: - pix = pdf[0].get_pixmap(matrix=pymupdf.Matrix(1.5, 1.5), - clip=pymupdf.Rect(606, 42, 1124, 791), alpha=False) - cover = Image.frombytes("RGB", (pix.width, pix.height), pix.samples) +with Image.open(ROOT / "figures" / "book-cover.png") as image: + cover = image.convert("RGB") -def background(): - im = Image.new("RGB", (S, S), BG) - d = ImageDraw.Draw(im) - text(d, (44, 24), "AZURE SQL HYPERSCALE UNVEILED", 21, GOLD, True) - text(d, (44, 730), "BOB WARD / APRESS", 17, (161, 180, 199), True) - text(d, (506, 730), "TODAY + YOUR FUTURE", 16, (69, 215, 210), True) - d.rounded_rectangle((28, 78, 740, 719), 10, fill=(5, 10, 18)) - d.rounded_rectangle((30, 72, 737, 713), 7, fill=(82, 53, 32)) - d.line((35, 83, 35, 700), fill=(142, 101, 57), width=2) - for i in range(7): - d.rounded_rectangle((43, 81 + i * 2, 733 - i, 710 - i * 2), 3, - fill=(211 + (i % 2) * 22, 207 + (i % 2) * 22, 195 + (i % 2) * 22)) - d.rectangle((39, 81, 44, 699), fill=(48, 33, 26)) +@lru_cache(maxsize=8) +def azure_sql_database_icon(size): + with Image.open(ROOT / "figures" / "icons" / "azure-sql-database.png") as icon: + return icon.convert("RGBA").resize((size, size), Image.Resampling.LANCZOS) + + +def background(scale=1): + im = Image.new("RGB", (round(W * scale), round(S * scale)), PAPER) + d = ScaledDraw(im, scale) + text(d, (44, 24), "AZURE SQL HYPERSCALE UNVEILED", 18, MUTED, serif=True) + d.line((44, 60, W - 44, 60), fill=(217, 222, 224), width=1) + text(d, (44, 730), "BOB WARD / APRESS", 15, MUTED) + text(d, (W - 262, 730), "TODAY + YOUR FUTURE", 15, MUTED) return im def cover_page(closing=False): im = background() d = ImageDraw.Draw(im) - d.rounded_rectangle((44, 80, 724, 696), 5, fill=(28, 31, 36)) + if args.landscape: + im.paste(cover.resize((330, 477), Image.Resampling.LANCZOS), (100, 110)) + text(d, (500, 105), "HYPERSCALE:", 36, TEAL, True, serif=True) + wrapped(d, (500, 166), "The cloud-native database for today and your future.", + 42, 745, INK, True, serif=True) + if closing: + text(d, (500, 317), "Your next chapter starts here.", 32, TEAL, serif=True) + else: + text(d, (500, 317), "Powered by SQL Server.", 32, TEAL, serif=True) + text(d, (500, 367), "Cloud-native scale. AI built in.", 28, INK, serif=True) + text(d, (500, 440), "Architecture. Engine. Applications. AI. Azure.", 27, INK, serif=True) + text(d, (500, 486), "Book examples: AI-assisted creation and guided learning.", 20, MUTED) + text(d, (500, 549), "BOOK", 18, TEAL, True) + text(d, (595, 539), "aka.ms/sqlhyperscaleunveiled", 31, INK, True) + text(d, (500, 610), "CODE", 18, TEAL, True) + text(d, (595, 600), "aka.ms/sqlhyperscalebook", 31, INK, True) + text(d, (595, 646), "github.com/rgward/sqlhyperscalebook", 19, MUTED) + return im thumb = cover.resize((265, 383), Image.Resampling.LANCZOS) im.paste(thumb, (70, 112)) - text(d, (366, 114), "HYPERSCALE:", 30, GOLD, True) + text(d, (366, 114), "HYPERSCALE:", 30, TEAL, True, serif=True) for i, line in enumerate(["The cloud-native", "database for today", "and your future."]): - text(d, (366, 169 + 42 * i), line, 27, PAPER, True) + text(d, (366, 169 + 42 * i), line, 27, INK, True, serif=True) if closing: for i, line in enumerate(["Your next chapter", "starts here."]): - text(d, (366, 354 + i * 40), line, 23, (69, 215, 210), True) + text(d, (366, 354 + i * 40), line, 23, TEAL, serif=True) else: wrapped(d, (366, 342), "Powered by SQL Server.", - 23, 326, (69, 215, 210), True) + 23, 326, TEAL, True, serif=True) wrapped(d, (366, 418), "Cloud-native scale. AI built in.", - 21, 326, PAPER) - text(d, (70, 506), "Architecture. Engine. Applications. AI. Azure.", 25, PAPER, True) - text(d, (70, 536), "Book examples: AI-assisted creation and guided learning.", 17, (161, 180, 199)) + 21, 326, INK, serif=True) + text(d, (70, 506), "Architecture. Engine. Applications. AI. Azure.", 24, INK, serif=True) + text(d, (70, 536), "Book examples: AI-assisted creation and guided learning.", 17, MUTED) d.rounded_rectangle((70, 563, 700, 613), 10, fill=GOLD) text(d, (85, 575), "BOOK", 18, INK, True) text(d, (176, 568), "aka.ms/sqlhyperscaleunveiled", 28, INK, True) - d.rounded_rectangle((70, 622, 700, 684), 10, outline=(69, 215, 210), width=2) - text(d, (85, 634), "CODE", 18, (69, 215, 210), True) - text(d, (176, 623), "aka.ms/sqlhyperscalebook", 26, PAPER, True) - text(d, (176, 660), "github.com/rgward/sqlhyperscalebook", 16, (161, 180, 199)) + d.rounded_rectangle((70, 622, 700, 684), 10, outline=(207, 218, 220), width=1) + text(d, (85, 634), "CODE", 18, TEAL, True) + text(d, (176, 623), "aka.ms/sqlhyperscalebook", 26, INK, True) + text(d, (176, 660), "github.com/rgward/sqlhyperscalebook", 16, MUTED) return im @@ -151,8 +237,65 @@ def cover_page(closing=False): ] -def chapter_art(im, number, accent): - d = ImageDraw.Draw(im) +def architecture_art(accent, scale=1): + pale = tuple(round(c * 0.10 + 255 * 0.90) for c in accent) + im = Image.new("RGB", (1200 * scale, 360 * scale), PAPER) + d = ScaledDraw(im, scale) + d.rounded_rectangle((0, 0, 1199, 359), 18, fill=pale) + log_color = (0, 117, 103) + read_color = (0, 111, 173) + + def arrow(points, color): + d.line(points, fill=color, width=4) + end, previous = points[-1], points[-2] + angle = math.atan2(end[1] - previous[1], end[0] - previous[0]) + d.polygon([end] + [ + (end[0] - 12 * math.cos(angle + a), end[1] - 12 * math.sin(angle + a)) + for a in (-0.5, 0.5) + ], fill=color) + + for bounds, label in [ + ((40, 10, 310, 65), "PRIMARY"), + ((460, 10, 730, 65), "HA REPLICAS"), + ((880, 10, 1150, 65), "NAMED REPLICAS"), + ((40, 150, 310, 210), "LOG SERVICE"), + ((700, 150, 970, 210), "PAGE SERVERS"), + ((440, 280, 750, 335), "AZURE STORAGE"), + ]: + d.rounded_rectangle(bounds, 10, fill=accent) + d.text(((bounds[0] + bounds[2]) / 2, (bounds[1] + bounds[3]) / 2), + label, font=font(25, True), fill=PAPER, anchor="mm") + + # Arrows follow data: log and page reads point toward their consumers. + arrow([(100, 65), (100, 150)], accent) + d.text((116, 100), "Write log", font=font(19), fill=accent) + d.line([(835, 150), (835, 80), (175, 80)], fill=read_color, width=4) + d.line([(835, 80), (1100, 80)], fill=read_color, width=4) + for x in (175, 650, 1100): + arrow([(x, 80), (x, 65)], read_color) + + d.line([(240, 150), (240, 125), (1000, 125)], fill=log_color, width=4) + # White gaps distinguish crossings from shared read-lane junctions. + d.line([(835, 118), (835, 132)], fill=pale, width=10) + d.line([(835, 118), (835, 132)], fill=read_color, width=4) + for x in (550, 1000): + d.line([(x, 73), (x, 87)], fill=pale, width=10) + arrow([(x, 125), (x, 65)], log_color) + arrow([(310, 180), (700, 180)], log_color) + d.text((410, 149), "Read log", font=font(20), fill=log_color) + arrow([(175, 210), (175, 305), (440, 305)], accent) + arrow([(835, 210), (835, 305), (750, 305)], accent) + for x, label, color in [ + (190, "Brown: writes", accent), + (595, "Teal: log reads", log_color), + (1000, "Blue: page reads", read_color), + ]: + d.text((x, 346), label, font=font(19, True), fill=color, anchor="mm") + return im + + +def chapter_art(im, number, accent, scale=1): + d = ScaledDraw(im, scale) pale = tuple(round(c * 0.10 + 255 * 0.90) for c in accent) d.rounded_rectangle((80, 342, 690, 512), 12, fill=pale) @@ -176,6 +319,13 @@ def arrow(points, color=None): for a in (-0.5, 0.5) ], fill=color) + def sql_icon(bounds): + side = round(min(bounds[2] - bounds[0], bounds[3] - bounds[1]) * scale) + icon = azure_sql_database_icon(side) + position = (round((bounds[0] + bounds[2]) * scale / 2 - side / 2), + round((bounds[1] + bounds[3]) * scale / 2 - side / 2)) + im.paste(icon, position, icon) + if number == 1: d.line((125, 404, 642, 404), fill=accent, width=4) for x, top, bottom in [(170, "SQL SERVER", "Proven engine"), @@ -191,29 +341,12 @@ def arrow(points, color=None): "> Provision Hyperscale", "> Connect with Entra ID", "> Build schema + seed data"]): text(d, (116, 363 + 31 * i), value, 19, (125, 235, 210)) - d.rectangle((533, 383, 648, 459), fill=accent) - d.ellipse((533, 367, 648, 400), fill=(62, 151, 210)) - d.ellipse((533, 443, 648, 476), fill=accent) - centered(590, 411, "SQL", 28, PAPER) + sql_icon((535, 360, 660, 478)) + centered(598, 484, "Azure SQL Hyperscale", 15, accent) arrow([(480, 424), (523, 424)]) elif number == 3: - read_color = (0, 111, 173) - box((97, 351, 260, 391), "PRIMARY", 17) - box((308, 351, 463, 391), "HA REPLICAS", 16) - box((495, 351, 675, 391), "NAMED REPLICAS", 16) - box((105, 431, 285, 469), "LOG SERVICE", 17) - box((344, 431, 496, 469), "PAGE SERVERS", 15) - box((541, 431, 675, 469), "AZURE STORAGE", 13) - arrow([(178, 391), (178, 431)]) - arrow([(285, 450), (320, 450), (320, 409), (385, 409), (385, 391)]) - arrow([(320, 409), (585, 409), (585, 391)]) - arrow([(285, 450), (344, 450)]) - arrow([(496, 450), (541, 450)]) - arrow([(240, 391), (240, 419), (365, 419), (365, 431)], read_color) - arrow([(440, 391), (440, 431)], read_color) - arrow([(510, 391), (510, 443), (496, 443)], read_color) - centered(385, 477, "Blue: page reads from primary, HA, and named replicas", 15, read_color) - centered(385, 497, "Brown: log write / redo and durable storage paths", 14, accent) + im.paste(architecture_art(accent, scale).resize((610 * scale, 180 * scale), + Image.Resampling.LANCZOS), (80 * scale, 342 * scale)) elif number == 4: with Image.open(ROOT / "figures" / "ch04-first-app" / "ch04-fig-01-the-digital-chart-application.png") as screenshot: @@ -223,8 +356,8 @@ def arrow(points, color=None): right = round(screenshot.width * 0.99) bottom = top + round((right - left) * 154 / 390) preview = screenshot.crop((left, top, right, bottom)) - preview.thumbnail((390, 154), Image.Resampling.LANCZOS) - im.paste(preview, (92, 350)) + preview.thumbnail((390 * scale, 154 * scale), Image.Resampling.LANCZOS) + im.paste(preview, (92 * scale, 350 * scale)) box((480, 366, 674, 411), "BLAZOR + SQL") text(d, (493, 430), "Ward General", 22, accent, True) text(d, (493, 463), "The working app", 17, MUTED) @@ -251,16 +384,24 @@ def arrow(points, color=None): text(d, (112, 485), "Models stay external", 14, MUTED) centered(613, 358, "AI GATEWAY", 15, accent) elif number == 6: - box((101, 366, 261, 414), "SQL MCP TOOLS") - box((316, 366, 484, 414), "HANDOFF AGENT") - arrow([(261, 390), (316, 390)]) - d.polygon([(578, 357), (635, 390), (578, 423), (521, 390)], fill=accent) - centered(578, 376, "Approve?", 16, PAPER) - arrow([(484, 390), (521, 390)]) - arrow([(578, 423), (578, 457)]) - box((497, 458, 662, 498), "SAVE HANDOFF", 16) - text(d, (110, 450), "Draft a patient handoff.", 20, accent, True) - text(d, (110, 481), "A human stays in the loop.", 17, MUTED) + d.rounded_rectangle((92, 349, 366, 497), 8, outline=accent, width=2) + centered(229, 355, "WARD GENERAL APP", 17, accent) + box((106, 386, 351, 421), "HANDOFF AGENT", 17) + box((405, 350, 515, 385), "LLM", 19) + arrow([(351, 398), (383, 398), (383, 362), (405, 362)]) + arrow([(405, 375), (394, 375), (394, 411), (351, 411)]) + centered(229, 443, "LLM-led orchestration", 16, accent) + centered(229, 473, "SQL MCP tool execution", 15, MUTED, False) + box((405, 410, 515, 450), "SQL MCP", 17) + box((557, 410, 677, 450), "HYPERSCALE", 14) + arrow([(366, 431), (405, 431)]) + arrow([(405, 443), (366, 443)]) + arrow([(515, 424), (557, 424)]) + arrow([(557, 438), (515, 438)]) + arrow([(366, 486), (394, 486)]) + box((394, 477, 535, 510), "HUMAN APPROVAL", 12) + arrow([(535, 493), (557, 493)]) + box((557, 477, 677, 510), "SAVE HANDOFF", 12) elif number == 7: d.line((110, 363, 110, 478, 430, 478), fill=MUTED, width=2) d.line([(110, 463), (150, 449), (182, 456), (219, 387), (244, 420), @@ -304,7 +445,7 @@ def arrow(points, color=None): x, y = 588 + 25 * math.cos(radians), 413 + 25 * math.sin(radians) d.ellipse((x - 2, y - 2, x + 2, y + 2), fill=accent) centered(588, 451, "Point-in-time restore", 15, accent, False) - centered(588, 479, "Recover a test copy", 14, MUTED, False) + centered(588, 479, "Recover from a mistake quickly", 14, MUTED, False) elif number == 10: for x, height, label in [(105, 24, "8"), (173, 48, "16"), (241, 74, "40"), (309, 115, "192")]: @@ -319,28 +460,73 @@ def arrow(points, color=None): box((390, y, 675, y + 24), label, 14) elif number == 11: center = (385, 425) - for bounds, label in [((100, 352, 282, 394), "ENTRA ID"), - ((488, 352, 672, 394), "MONITOR"), - ((100, 458, 260, 500), "MIGRATION"), - ((510, 458, 672, 500), "FOUNDRY AI")]: + for bounds, label in [((90, 352, 300, 394), "AZURE"), + ((470, 352, 680, 394), "MICROSOFT FOUNDRY"), + ((90, 405, 300, 447), "MIGRATE"), + ((470, 405, 680, 447), "AZURE APP SERVICE"), + ((90, 458, 300, 500), "CHANGE EVENT STREAMING"), + ((470, 458, 680, 500), "FABRIC MIRRORING")]: mid = ((bounds[0] + bounds[2]) / 2, (bounds[1] + bounds[3]) / 2) d.line((center, mid), fill=accent, width=3) - box(bounds, label) - d.ellipse((321, 363, 449, 465), fill=accent) - centered(385, 377, "AZURE SQL", 18, PAPER) - centered(385, 412, "HYPERSCALE", 16, PAPER) - arrow([(385, 465), (385, 478)]) - box((272, 478, 498, 506), "CHANGE EVENT STREAMING", 14) + box(bounds, label, 14) + d.rectangle((302, 450, 468, 478), fill=pale) + sql_icon((335, 350, 435, 450)) + centered(385, 456, "AZURE SQL HYPERSCALE", 13, accent) -def chapter_page(number): - im = background() - d = ImageDraw.Draw(im) - d.rectangle((44, 80, 724, 696), fill=PAPER) - for x in range(44, 65): - shade = round(209 + (x - 44) / 20 * 39) - d.line((x, 80, x, 696), fill=(shade, shade - 3, shade - 12)) +def chapter_illustration(number, accent, target_width): + if number == 3: + return architecture_art(accent, max(1, math.ceil(target_width / 1200))) + scale = max(1, math.ceil(target_width / 610)) + artwork = Image.new("RGB", (S * scale, S * scale), PAPER) + chapter_art(artwork, number, accent, scale) + return artwork.crop(tuple(value * scale for value in (80, 342, 690, 522))) + + +def chapter_page(number, scale=1): + im = background(scale) + d = ScaledDraw(im, scale) accent = ACCENTS[number - 1] + if args.v2: + margin = 85 if args.landscape else 44 + available = W - margin * 2 + text(d, (margin, 92), f"CHAPTER {number:02d}", 22, accent, True) + end = wrapped(d, (margin, 133), titles[number], + 36 if args.landscape else 30, available, + bold=True, serif=True) + assert end <= 250, (number, end) + hero, _, _, takeaway = chapters[number - 1] + end = wrapped(d, (margin, 225 if args.landscape else 253), hero, + 27, available, accent, serif=True) + assert end <= 323, (number, end) + artwork = chapter_illustration(number, accent, available * scale) + height = round(available * artwork.height / artwork.width) + top = 280 if args.landscape else 345 + im.paste(artwork.resize((round(available * scale), round(height * scale)), + Image.Resampling.LANCZOS), (round(margin * scale), round(top * scale))) + end = wrapped(d, (margin, top + height + 23), takeaway, + 24, available, serif=True) + assert end <= 696, (number, end) + d.line((margin, 705, W - margin, 705), fill=(217, 222, 224)) + text(d, (W - margin - 90, 709), f"{number:02d} / 11", 16, accent) + im.info["chapter_number"] = number + return im + if args.landscape: + text(d, (85, 93), f"CHAPTER {number:02d}", 22, accent, True) + end = wrapped(d, (85, 138), titles[number], 40, W - 170, bold=True, serif=True) + assert end <= 250, (number, end) + hero, labels, details, takeaway = chapters[number - 1] + text(d, (85, 265), hero, 30, accent, serif=True) + artwork = Image.new("RGB", (S, S), PAPER) + chapter_art(artwork, number, accent) + im.paste(artwork.crop((80, 342, 690, 522)).resize((780, 230), + Image.Resampling.LANCZOS), (85, 345)) + end = wrapped(d, (925, 345), takeaway, 28, 350, serif=True) + assert end < 650, (number, end) + d.line((85, 665, W - 85, 665), fill=(217, 222, 224), width=1) + text(d, (85, 680), "AZURE SQL HYPERSCALE UNVEILED", 16, MUTED, serif=True) + text(d, (W - 173, 680), f"{number:02d} / 11", 18, accent) + return im d.rectangle((80, 108, 147, 113), fill=accent) text(d, (164, 96), f"CHAPTER {number:02d}", 21, accent, True) end = wrapped(d, (80, 143), titles[number], 32, 602, bold=True, serif=True) @@ -357,23 +543,28 @@ def chapter_page(number): def turn(old, new, progress): im = new.copy() + im.info.pop("chapter_number", None) + page_width, page_height = old.size + if args.v2 and args.landscape and old.size == (W, S): + im.info["page_turn"] = (old, new, progress) + unit = page_height / S if page_width >= W else 1 # A slanted, bowed fold exposes the next page; the translucent reverse # retains a hint of mirrored ink instead of becoming an opaque wipe. bend = math.sin(math.pi * progress) - edge = 724 - 680 * (0.5 - 0.5 * math.cos(math.pi * progress)) + edge = page_width * (0.5 + 0.5 * math.cos(math.pi * progress)) reverse = ImageOps.mirror(old) reverse = Image.blend(reverse, Image.new("RGB", old.size, PAPER), 0.92) - for y in range(80, 696): - fraction = (y - 80) / 616 + for y in range(page_height): + fraction = y / page_height bow = math.sin(math.pi * fraction) - left = round(max(44, min(724, edge + bend * (46 * (1 - 2 * fraction) - 28 * bow)))) - width = max(1, round(bend * (110 + 44 * bow))) - right = min(724, left + width) - im.paste(old.crop((44, y, left, y + 1)), (44, y)) + left = round(max(0, min(page_width, edge + bend * unit * (46 * (1 - 2 * fraction) - 28 * bow)))) + width = max(1, round(bend * (110 + 44 * bow) * page_width / page_height * unit)) + right = min(page_width, left + width) + im.paste(old.crop((0, y, left, y + 1)), (0, y)) if right <= left: continue # The shadow falls on the exposed page, following the paper's curve. - shadow_end = min(724, right + round(25 * bend)) + shadow_end = min(page_width, right + round(25 * bend * unit)) if shadow_end > right: shadow = im.crop((right, y, shadow_end, y + 1)) shade = Image.new("RGB", shadow.size) @@ -382,7 +573,7 @@ def turn(old, new, progress): value = round(160 + 95 * x / shadow.width) sd.point((x, 0), fill=(value, value, value)) im.paste(ImageChops.multiply(shadow, shade), (right, y)) - strip = reverse.crop((max(44, left - 150), y, max(45, left), y + 1)) + strip = reverse.crop((max(0, left - round(150 * unit)), y, max(1, left), y + 1)) strip = strip.resize((right - left, 1), Image.Resampling.BICUBIC) light = Image.new("RGB", strip.size) ld = ImageDraw.Draw(light) @@ -397,20 +588,27 @@ def turn(old, new, progress): def summary_page(): im = background() d = ImageDraw.Draw(im) - d.rectangle((44, 80, 724, 696), fill=PAPER) d.rectangle((80, 108, 147, 113), fill=TEAL) text(d, (164, 96), "THE STORY CONTINUES", 21, TEAL, True) wrapped(d, (80, 143), "Build once. Evolve with confidence.", 36, 590, bold=True, serif=True) - text(d, (80, 254), "Scale independently. Keep familiar SQL.", 23, TEAL, serif=True) - text(d, (80, 289), "Grow into AI. Stay on one platform.", 23, TEAL, serif=True) + end = wrapped(d, (80, 254), SUMMARY_SUBTITLE, 23, 602, TEAL, serif=True) + assert end <= 330, end text(d, (80, 337), "FROM CHAPTER 11 / QUOTE EXCERPT", 16, MUTED, True) - quote = ( - '"Customers can begin with what they need today and expand over time ' - 'without redesigning the application or moving to a different platform. ' - 'That freedom to build once and evolve with confidence makes Hyperscale ' - 'a critical part of Microsoft\u2019s database strategy."' - ) + quote = ARUN_QUOTE + if args.landscape: + im = background() + d = ImageDraw.Draw(im) + text(d, (90, 100), "THE STORY CONTINUES", 22, TEAL, True) + text(d, (90, 158), "Build once. Evolve with confidence.", 44, INK, True, serif=True) + text(d, (90, 237), SUMMARY_SUBTITLE, 29, TEAL, serif=True) + text(d, (110, 310), "FROM CHAPTER 11 / QUOTE EXCERPT", 17, MUTED, True) + end = wrapped(d, (110, 355), quote, 31, 1130, serif=True) + assert end <= 610, end + d.line((88, 360, 88, end - 5), fill=TEAL, width=3) + text(d, (110, 631), "Arun Ulag", 26, INK, True, serif=True) + text(d, (110, 671), "Vice President of Azure", 20, MUTED) + return im end = wrapped(d, (96, 371), quote, 23, 574, serif=True) assert end <= 588, end d.line((80, 376, 80, end - 6), fill=TEAL, width=3) @@ -421,21 +619,367 @@ def summary_page(): return im -pages = ([cover_page()] + [chapter_page(i) for i in range(1, 12)] - + [summary_page(), cover_page(True)]) -frames = [pages[0]] -delays = [12000] -for index in range(1, len(pages)): - for step in range(1, 13): - frames.append(turn(pages[index - 1], pages[index], step / 13)) - delays.append(80) - frames.append(pages[index]) - delays.append(14000 if index == len(pages) - 2 else 6000) -palette_sheet = Image.new("RGB", (S * len(pages), S)) +def book_sheet(number=None, closing=False): + """Typeset one portrait sheet at the front cover's proportions.""" + page = Image.new("RGB", (540, 780), PAPER) + d = ImageDraw.Draw(page) + text(d, (40, 28), "AZURE SQL HYPERSCALE UNVEILED", 14, MUTED, serif=True) + d.line((40, 58, 500, 58), fill=(218, 220, 218)) + if number is not None: + accent = ACCENTS[number - 1] + text(d, (40, 83), f"CHAPTER {number:02d}", 18, accent, True) + end = wrapped(d, (40, 125), titles[number], 27, 460, bold=True, serif=True) + assert end <= 265, (number, end) + hero, _, _, takeaway = chapters[number - 1] + end = wrapped(d, (40, 281), hero, 23, 460, accent, serif=True) + assert end <= 345, (number, end) + artwork = Image.new("RGB", (768, 768), PAPER) + chapter_art(artwork, number, accent) + page.paste(artwork.crop((80, 342, 690, 522)).resize((460, 136), + Image.Resampling.LANCZOS), (40, 365)) + wrapped(d, (40, 537), takeaway, 23, 460, serif=True) + text(d, (439, 732), f"{number:02d} / 11", 16, accent) + elif closing: + wrapped(d, (40, 105), "Your next chapter starts here.", 37, 455, + TEAL, True, serif=True) + wrapped(d, (40, 264), "Powered by SQL Server. Cloud-native scale. AI built in.", + 28, 450, serif=True) + text(d, (40, 444), "BOOK", 17, TEAL, True) + text(d, (40, 480), "aka.ms/sqlhyperscaleunveiled", 25, INK, True) + text(d, (40, 550), "CODE", 17, TEAL, True) + text(d, (40, 586), "aka.ms/sqlhyperscalebook", 25, INK, True) + text(d, (40, 632), "github.com/rgward/sqlhyperscalebook", 17, MUTED) + else: + wrapped(d, (40, 94), "Build once. Evolve with confidence.", 33, 460, + bold=True, serif=True) + end = wrapped(d, (40, 233), SUMMARY_SUBTITLE, 23, 460, TEAL, serif=True) + assert end <= 342, end + text(d, (40, 352), "CHAPTER 11 / QUOTE EXCERPT", 15, MUTED, True) + quote = ARUN_QUOTE + end = wrapped(d, (40, 389), quote, 22, 460, serif=True) + assert end <= 660, end + text(d, (40, 675), "Arun Ulag", 23, INK, True, serif=True) + text(d, (40, 711), "Vice President of Azure", 17, MUTED) + if number is not None: + d.line((40, 714, 500, 714), fill=(218, 220, 218)) + text(d, (40, 735), "Bob Ward", 15, MUTED, serif=True) + return page + + +def opening_sheet(): + page = book_sheet(closing=True) + d = ImageDraw.Draw(page) + d.rectangle((0, 80, 540, 425), fill=PAPER) + text(d, (40, 96), "HYPERSCALE:", 30, TEAL, True, serif=True) + wrapped(d, (40, 154), "The cloud-native database for today and your future.", + 32, 460, bold=True, serif=True) + text(d, (40, 320), "Powered by SQL Server.", 25, TEAL, serif=True) + text(d, (40, 365), "Cloud-native scale. AI built in.", 23, INK, serif=True) + text(d, (40, 414), "Book examples: AI-assisted creation and learning.", 16, MUTED) + return page + + +def mount_book(sheet, turned): + stage = Image.new("RGB", (W, S), (0, 0, 0) if args.v2 else (240, 238, 233)) + d = ImageDraw.Draw(stage) + left = (W - 470) // 2 + 12 + top = 40 + # Unread sheets sit on the right; a smaller turned stack collects at the spine. + stack = 2 + min(turned, 14) + unread = max(3, 28 - min(turned, 14) * 2) + if not args.v2: + d.rounded_rectangle((left - stack - 13, top - 6, left + 476 + unread, top + 691), + 5, fill=(197, 191, 181)) + d.rectangle((left - stack - 11, top - 4, left + 474 + unread, top + 686), fill=(65, 48, 36)) + for edge in range(stack, 0, -2): + x = left - edge + d.rectangle((x, top + edge // 3, left + 471, top + 682), + fill=(254, 252, 246), outline=(202, 198, 188)) + d.rectangle((left - stack - 7, top, left - stack - 3, top + 680), fill=(102, 77, 53)) + for edge in range(unread, 0, -2): + d.rectangle((left, top + edge // 3, left + 470 + edge, top + 680 + edge // 3), + fill=(254, 252, 246), outline=(193, 188, 177)) + stage.paste(sheet.resize((470, 680), Image.Resampling.LANCZOS), (left, top)) + for x in range(6): + shade = 215 + x * 7 + d.line((left + x, top, left + x, top + 679), fill=(shade, shade, shade - 2)) + return stage + + +class OpenBook: + def __init__(self, size, detail=1): + self.size = size + self.detail = detail + + @lru_cache(maxsize=1) + def front_cover(self): + return cover.resize((540 * self.detail, 780 * self.detail), + Image.Resampling.LANCZOS) + + @lru_cache(maxsize=4) + def spread(self, kind): + scale = self.detail + page = Image.new("RGB", (1080 * scale, 780 * scale), PAPER) + d = ScaledDraw(page, scale) + if isinstance(kind, int): + accent = ACCENTS[kind - 1] + text(d, (40, 35), f"CHAPTER {kind:02d}", 20, accent, True) + end = wrapped(d, (40, 95), titles[kind], 30, 455, + INK, True, serif=True) + assert end <= 290, (kind, end) + hero, _, _, takeaway = chapters[kind - 1] + end = wrapped(d, (585, 95), hero, 27, 450, accent, serif=True) + end = wrapped(d, (585, end + 25), takeaway, 23, 450, serif=True) + assert end <= 300, (kind, end) + artwork = chapter_illustration(kind, accent, 1000 * scale) + height = round(1000 * artwork.height / artwork.width) + page.paste(artwork.resize((1000 * scale, height * scale), + Image.Resampling.LANCZOS), (40 * scale, 320 * scale)) + text(d, (40, 726), "AZURE SQL HYPERSCALE UNVEILED", 15, TEAL, serif=True) + text(d, (960, 726), f"{kind:02d} / 11", 17, accent) + elif kind == "summary": + text(d, (40, 45), "THE STORY CONTINUES", 20, TEAL, True) + end = wrapped(d, (40, 110), "Build once. Evolve with confidence.", + 34, 450, INK, True, serif=True) + assert end <= 250, end + end = wrapped(d, (40, 280), SUMMARY_SUBTITLE, 27, 450, TEAL, serif=True) + assert end <= 490, end + text(d, (585, 50), "CHAPTER 11 / QUOTE EXCERPT", 16, MUTED, True) + end = wrapped(d, (585, 110), ARUN_QUOTE, 23, 450, serif=True) + assert end <= 630, end + text(d, (585, 670), "Arun Ulag", 25, INK, True, serif=True) + text(d, (585, 714), "Vice President of Azure", 19, MUTED) + else: + assert kind in ("intro", "closing"), kind + text(d, (40, 30), "AZURE SQL HYPERSCALE UNVEILED", 15, MUTED, serif=True) + if kind == "intro": + text(d, (40, 85), "DEDICATION", 18, TEAL, True) + end = wrapped(d, (40, 124), BOOK_DEDICATION, 23, 450, serif=True) + assert end <= 330, end + text(d, (40, 350), "TECHNICAL REVIEWER", 16, MUTED, True) + text(d, (40, 384), "Kendal Van Dyke", 26, INK, True, serif=True) + text(d, (40, 458), "QUOTE CONTRIBUTORS", 16, MUTED, True) + for index, name in enumerate(QUOTE_CONTRIBUTORS): + end = wrapped(d, (40, 495 + index * 29), name, 21, 450, serif=True) + assert end <= 730, (name, end) + else: + page.paste(self.front_cover().resize((330 * scale, 477 * scale), + Image.Resampling.LANCZOS), (100 * scale, 110 * scale)) + text(d, (585, 70), "HYPERSCALE:", 30, TEAL, True, serif=True) + end = wrapped(d, (585, 125), + "The cloud-native database for today and your future.", + 31, 450, INK, True, serif=True) + assert end <= 310, end + if kind == "closing": + end = wrapped(d, (585, 335), "Your next chapter starts here.", + 27, 450, TEAL, serif=True) + assert end <= 420, end + else: + text(d, (585, 335), "Powered by SQL Server.", 25, TEAL, serif=True) + text(d, (585, 380), "Cloud-native scale. AI built in.", 23, INK, serif=True) + end = wrapped(d, (585, 435), "Architecture. Engine. Applications. AI. Azure.", + 20, 450, serif=True) + assert end <= 495, end + end = wrapped(d, (585, 502), "Book examples: AI-assisted creation and guided learning.", + 17, 450, MUTED) + assert end <= 560, end + text(d, (585, 575), "BOOK", 16, TEAL, True) + text(d, (585, 606), BOOK_URL, 23, INK, True) + text(d, (585, 658), "CODE", 16, TEAL, True) + text(d, (585, 689), "aka.ms/sqlhyperscalebook", 23, INK, True) + text(d, (585, 720), "github.com/rgward/sqlhyperscalebook", 14, MUTED) + footer_width = d.textlength(BOOK_URL, font=font(16)) + for left in (0, 540): + text(d, (left + (540 - footer_width) / 2, 750), BOOK_URL, 16, TEAL) + return (page.crop((0, 0, 540 * scale, 780 * scale)), + page.crop((540 * scale, 0, 1080 * scale, 780 * scale))) + + def base(self, left, right): + scale = self.detail + world = Image.new("RGB", (2400 * scale, 1400 * scale), (0, 0, 0)) + d = ScaledDraw(world, scale) + for edge in (10, 7, 4): + d.rectangle((1200, 310 + edge // 2, 1740 + edge, 1090 + edge // 2), + fill=(243, 241, 235), outline=(172, 169, 160), width=1) + if left is not None: + d.rectangle((660 - edge, 310 + edge // 2, 1200, 1090 + edge // 2), + fill=(243, 241, 235), outline=(172, 169, 160), width=1) + if left is not None: + world.paste(left, (660 * scale, 310 * scale)) + world.paste(right, (1200 * scale, 310 * scale)) + return world + + def gutter(self, world): + scale = self.detail + for offset in range(-5, 6): + shade = 203 + abs(offset) * 10 + bounds = ((1200 + offset) * scale, 310 * scale, + (1201 + offset) * scale, 1090 * scale) + band = world.crop(bounds) + tint = Image.new("RGB", band.size, (shade, shade, shade)) + world.paste(ImageChops.multiply(band, tint), bounds[:2]) + + def leaf(self, world, front, back, progress): + scale = self.detail + cosine, sine = math.cos(math.pi * progress), math.sin(math.pi * progress) + if abs(cosine) < 0.002: + ScaledDraw(world, scale).line((1200, 310, 1200, 1090), + fill=(203, 201, 194), width=2) + return + texture = front if cosine > 0 else ImageOps.mirror(back) + texture = ImageEnhance.Brightness(texture).enhance(0.79 + 0.21 * abs(cosine)) + for x in range(540 * scale): + fraction = (x + 0.5) / scale + perspective = 1 / (1 - fraction * sine / 4000) + a = round((1200 + (x / scale) * cosine / + (1 - (x / scale) * sine / 4000)) * scale) + b = round((1200 + ((x + 1) / scale) * cosine / + (1 - ((x + 1) / scale) * sine / 4000)) * scale) + if a == b: + continue + height = round(780 * scale * perspective) + strip = texture.crop((x, 0, x + 1, 780 * scale)).resize( + (abs(b - a), height), Image.Resampling.BICUBIC) + world.paste(strip, (min(a, b), round(700 * scale - height / 2))) + perspective = 1 / (1 - 540 * sine / 4000) + edge_x = 1200 + 540 * cosine * perspective + ScaledDraw(world, scale).line( + [(1200, 310), (edge_x, 700 - 390 * perspective), + (edge_x, 700 + 390 * perspective), (1200, 1090)], + fill=(185, 181, 170), width=1) + + def render(self, descriptor): + mode, kind, previous, progress = descriptor + left, right = self.spread(kind) + if mode == "cover": + world = self.base(left if progress == 1 else None, right) + self.leaf(world, self.front_cover(), left, progress) + if progress == 1: + self.gutter(world) + camera = progress + elif mode == "turn": + old_left, old_right = self.spread(previous) + world = self.base(old_left, right) + self.leaf(world, old_right, left, progress) + self.gutter(world) + camera = 1 + else: + assert mode == "spread", mode + world = self.base(left, right) + self.gutter(world) + camera = 1 + width, height = self.size + view_height = (820 + (max(900, 1160 * height / width) - 820) * camera + + 45 * math.sin(math.pi * camera)) + view_width = view_height * width / height + center_x = 1200 + 270 * (1 - camera) + box = tuple(value * self.detail for value in ( + center_x - view_width / 2, 700 - view_height / 2, + center_x + view_width / 2, 700 + view_height / 2)) + frame = world.resize(self.size, Image.Resampling.LANCZOS, box=box) + return frame + + +def v2_sequence(): + renderer = OpenBook((W, S)) + result, timing = [], [] + gif_output = args.frames_dir is None + + def append(descriptor, delay): + frame = (Image.new("RGB", (1, 1)) if args.frames_dir + else renderer.render(descriptor)) + frame.info["open_book"] = descriptor + result.append(frame) + timing.append(delay) + + delay = 40 if args.landscape else 60 + cover_steps = 60 if args.landscape else 40 + turn_steps = 15 if gif_output else (45 if args.landscape else 30) + closing_steps = 20 if gif_output else cover_steps + append(("cover", "intro", None, 0), 6000 if gif_output else 12000) + for step in range(1, cover_steps + 1): + t = step / cover_steps + t = t * t * (3 - 2 * t) + append(("cover", "intro", None, t), delay) + append(("spread", "intro", None, 1), 6000 if gif_output else 14000) + previous = "intro" + for kind in list(range(1, 12)) + ["summary", "closing"]: + for step in range(1, turn_steps + 1): + t = step / turn_steps + t = t * t * (3 - 2 * t) + append(("turn", kind, previous, t), delay) + if gif_output: + hold = 7000 if kind == "summary" else 4000 if kind == "closing" else 2500 + else: + hold = 14000 if kind == "summary" else 6000 + append(("spread", kind, None, 1), hold) + previous = kind + for step in range(1, closing_steps + 1): + t = step / closing_steps + t = t * t * (3 - 2 * t) + append(("cover", "closing", None, 1 - t), delay) + append(("cover", "closing", None, 0), 3000 if gif_output else 6000) + pages = [renderer.render(("cover", "intro", None, 0))] + pages += [renderer.render(("spread", kind, None, 1)) + for kind in ["intro"] + list(range(1, 12)) + ["summary", "closing"]] + return result, timing, pages + + +if args.v2: + frames, delays, pages = v2_sequence() +else: + sheets = ([cover.resize((540, 780), Image.Resampling.LANCZOS), opening_sheet()] + + [book_sheet(i) for i in range(1, 12)] + + [book_sheet(), book_sheet(closing=True)]) + pages = [mount_book(sheet, i) for i, sheet in enumerate(sheets)] + frames = [pages[0]] + delays = [12000] + for index in range(1, len(pages)): + for step in range(1, 13): + frames.append(mount_book(turn(sheets[index - 1], sheets[index], step / 13), index)) + delays.append(80) + frames.append(pages[index]) + delays.append(14000 if index == len(pages) - 2 else 6000) +if args.preview_dir: + args.preview_dir.mkdir(parents=True, exist_ok=True) + thumb_width = 512 if args.landscape else 384 + thumb_height = round(S * thumb_width / W) + sheet = Image.new("RGB", (thumb_width * 4, thumb_height * 4), PAPER) + for i, page in enumerate(pages): + sheet.paste(page.resize((thumb_width, thumb_height)), + ((i % 4) * thumb_width, (i // 4) * thumb_height)) + suffix = "landscape" if args.landscape else "square" + sheet.save(args.preview_dir / f"chapter-storyboard-{suffix}.png") + preview = frames[6] + if "open_book" in preview.info: + preview = OpenBook((W, S)).render(preview.info["open_book"]) + preview.save(args.preview_dir / f"page-turn-{suffix}.png") + +if args.frames_dir: + args.frames_dir.mkdir(parents=True, exist_ok=True) + manifest = ["ffconcat version 1.0"] + native_book = (OpenBook((3840, 2160), detail=3) if args.landscape + else OpenBook((W, S))) if args.v2 else None + for i, (frame, delay) in enumerate(zip(frames, delays)): + name = f"frame-{i:03d}.png" + if args.v2: + frame = native_book.render(frame.info["open_book"]) + frame.save(args.frames_dir / name) + manifest.extend([f"file '{name}'", f"duration {delay / 1000:.3f}"]) + manifest.append(f"file 'frame-{len(frames) - 1:03d}.png'") + (args.frames_dir / "frames.ffconcat").write_text("\n".join(manifest) + "\n", encoding="ascii") + (args.frames_dir / "duration.txt").write_text(f"{sum(delays) / 1000:.3f}", encoding="ascii") + size = "3840 x 2160" if args.v2 and args.landscape else f"{W} x {S}" + print(f"Exported {len(frames)} {size} frames; duration {sum(delays) / 1000:.2f}s.") + sys.exit(0) + +palette_sheet = Image.new("RGB", (GIF_SIZE * len(pages), GIF_SIZE)) for i, page in enumerate(pages): - palette_sheet.paste(page, (i * S, 0)) -palette = palette_sheet.quantize(colors=256, method=Image.Quantize.MEDIANCUT) -indexed = [frame.quantize(palette=palette, dither=Image.Dither.NONE) for frame in frames] + palette_sheet.paste(page.resize((GIF_SIZE, GIF_SIZE), Image.Resampling.LANCZOS), + (i * GIF_SIZE, 0)) +palette = palette_sheet.quantize(colors=GIF_COLORS, method=Image.Quantize.MEDIANCUT) +indexed = [frame.resize((GIF_SIZE, GIF_SIZE), Image.Resampling.LANCZOS).quantize( + palette=palette, dither=Image.Dither.NONE) for frame in frames] output = ROOT / "figures" / "book-chapters.gif" indexed[0].save(output, save_all=True, append_images=indexed[1:], duration=delays, loop=0, optimize=True, disposal=1) @@ -445,18 +989,15 @@ def summary_page(): gif.seek(frame) total += gif.info["duration"] gif.load() - assert gif.size == (S, S) and gif.info["loop"] == 0 + assert gif.size == (GIF_SIZE, GIF_SIZE) and gif.info["loop"] == 0 assert total == sum(delays) - assert delays[13::13] == [6000] * 11 + [14000, 6000] - assert total == 110480 - assert output.stat().st_size < 10_000_000 + if args.v2: + assert total == 68800 + assert delays[0] == 6000 and delays[-1] == 3000 + else: + assert delays[13::13] == [6000] * 12 + [14000, 6000] + assert total == 117440 + assert output.stat().st_size < GIF_MAX_BYTES print(f"Verified: 11 chapters, {gif.n_frames} frames, {total / 1000:.2f}s, " f"{output.stat().st_size:,} bytes, infinite loop; all frames decoded.") -if args.preview_dir: - args.preview_dir.mkdir(parents=True, exist_ok=True) - sheet = Image.new("RGB", (384 * 4, 384 * 4), BG) - for i, page in enumerate(pages): - sheet.paste(page.resize((384, 384)), ((i % 4) * 384, (i // 4) * 384)) - sheet.save(args.preview_dir / "chapter-promo-storyboard.png") - frames[6].save(args.preview_dir / "chapter-page-turn-preview.png") print(output) diff --git a/tools/make-book-video.ps1 b/tools/make-book-video.ps1 new file mode 100644 index 0000000..ed3292c --- /dev/null +++ b/tools/make-book-video.ps1 @@ -0,0 +1,52 @@ +#Requires -Version 7.0 +[CmdletBinding()] +param( + [string] $FfmpegPath = 'ffmpeg', + [string] $OutputPath, + # Retained for compatibility; the open-book layout is now the default. + [switch] $V2 +) + +$ErrorActionPreference = 'Stop' +if (-not $OutputPath) { + $OutputPath = Join-Path $PSScriptRoot '..\figures\book-chapters-4k.mp4' +} +$generator = Join-Path $PSScriptRoot 'make-book-chapters.py' +if (-not (Get-Command $FfmpegPath -ErrorAction SilentlyContinue)) { + throw "FFmpeg not found: $FfmpegPath. Install FFmpeg or pass -FfmpegPath." +} +if (Test-Path -LiteralPath $OutputPath) { + throw "Output already exists: $OutputPath. Choose another -OutputPath or remove it explicitly." +} + +$frameDirectory = Join-Path ([System.IO.Path]::GetTempPath()) ("book-video-" + [guid]::NewGuid()) +New-Item -ItemType Directory -Path $frameDirectory | Out-Null +try { + $renderArguments = @($generator, '--landscape', '--frames-dir', $frameDirectory) + & python @renderArguments + if ($LASTEXITCODE -ne 0) { + throw "Landscape page rendering failed with exit code $LASTEXITCODE." + } + $duration = (Get-Content -LiteralPath (Join-Path $frameDirectory 'duration.txt') -Raw).Trim() + & $FfmpegPath -hide_banner -loglevel warning -nostdin -n ` + -f concat -safe 0 -i (Join-Path $frameDirectory 'frames.ffconcat') ` + -map 0:v:0 -an -t $duration ` + -vf 'fps=25,scale=3840:2160:flags=lanczos,setsar=1' ` + -c:v libx264 -preset fast -crf 18 -pix_fmt yuv420p -profile:v high -level:v 5.1 ` + -movflags +faststart $OutputPath + if ($LASTEXITCODE -ne 0) { + throw "FFmpeg encoding failed with exit code $LASTEXITCODE." + } +} +finally { + Get-ChildItem -LiteralPath $frameDirectory -File | ForEach-Object { + Remove-Item -LiteralPath $_.FullName + } + Remove-Item -LiteralPath $frameDirectory +} + +& $FfmpegPath -hide_banner -nostdin -v error -xerror -i $OutputPath -map 0:v:0 -f null - +if ($LASTEXITCODE -ne 0) { + throw "MP4 decode validation failed with exit code $LASTEXITCODE." +} +Write-Output "Created and decode-validated silent 3840 x 2160 MP4 at 25 fps: $OutputPath"