From ae7e2d51f39fa76deec78312c97d6222e02621ea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Przemys=C5=82aw=20K=C5=82ys?= Date: Sat, 10 Oct 2026 22:44:52 +0200 Subject: [PATCH 01/11] Add a purpose-built media diagram for format map recordings, and its recorder Build/FormatMapMedia/index.html draws the format map for video and social media: a full-bleed ring with large labels, a slow camera, routes that draw and carry pulses, a number that counts up, and surface pills lit only for the surfaces that run what is on screen. Compositions for 16:9, 1:1, 4:5 and 9:16. It is a pure function of time (window.imoMedia.render(t)) and all vector, so it renders frame by frame at any frame rate and resolution, with --scale=2 giving sharp true 4K. It reads only the generated format_map.json and surfaces.json plus the site font, so rendering needs no website build. Build/FormatMapRecorder serves the page on a loopback port, steps it through time with the shared HtmlTinkerX browser and encodes mp4, h265, av1, webm, mov, gif, webp or apng through ffmpeg. A cut whose scenes have nothing to show fails with the unmatched scenes instead of rendering the wrong thing; a failed cut leaves no frames behind. Build/Record-FormatMap.ps1 regenerates the data and renders every cut; AGENTS.md documents the workflow. --- AGENTS.md | 10 + Build/FormatMapMedia/cuts.json | 41 ++ Build/FormatMapMedia/index.html | 437 ++++++++++++++++++ .../OfficeIMO.FormatMapRecorder.csproj | 12 + Build/FormatMapRecorder/Program.cs | 291 ++++++++++++ Build/Record-FormatMap.ps1 | 108 +++++ 6 files changed, 899 insertions(+) create mode 100644 Build/FormatMapMedia/cuts.json create mode 100644 Build/FormatMapMedia/index.html create mode 100644 Build/FormatMapRecorder/OfficeIMO.FormatMapRecorder.csproj create mode 100644 Build/FormatMapRecorder/Program.cs create mode 100644 Build/Record-FormatMap.ps1 diff --git a/AGENTS.md b/AGENTS.md index 020b816153..815ed1993e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -67,6 +67,16 @@ - Keep upstream integration deliberate and regular. Inspect all worktrees before branch operations, use one coordinator for integration history, preserve unrelated work and avoid history rewrites on shared branches. Do not leave a giant unvalidated merge until the end of the program. - Keep test references and optional provider comparisons outside default runtime dependencies. Store bulky corpus outputs in a named task-owned location, retain compact manifests and decisive evidence, and remove superseded output before repeated runs. +## Format map recordings + +`Build/FormatMapMedia/index.html` is a purpose-built diagram of the format map for video and social media; `Build/FormatMapRecorder` renders it into clips. Nothing in a clip is hand-made, so re-record from the data whenever the catalog, the PowerShell mapping or the design changes. The website's own format map (`Website/themes/officeimo/partials/sections/format-map.html`, `Website/static/js/format-map.js`) is separate and is not used for recordings. + +- Re-record with `./Build/Record-FormatMap.ps1`. It regenerates `Website/data/format_map.json` and renders every cut in `Build/FormatMapMedia/cuts.json` into `Artefacts/FormatMapVideos` (ignored). Useful switches: `-Cut `, `-Scale 2` (true 3840x2160), `-Formats mp4,h265,mov,gif,...`, `-SkipCatalog`, `-Still 2000,8000` (PNG stills of those moments, for design work), `-List`. It needs ffmpeg and the shared Playwright browsers (HtmlTinkerX); it restores generated files that differ only in line endings. No website build is involved. +- The diagram draws everything as a pure function of time (`window.imoMedia.render(t)`), so the recorder steps it frame by frame at any frame rate and resolution. Keep it free of timers, transitions and hover state, and keep it vector so `-Scale 2` stays sharp. It reads only `Website/data/format_map.json`, `surfaces.json` and the site font; presentation-only short labels (CHM, MEDLINE, XPS) live in its `ALIAS` table and the data names are untouched. +- It has compositions for 16:9, 1:1, 4:5 and 9:16; surface pills light only for the surfaces that really run what is shown. +- Cuts name formats and surfaces with the site tour's grammar (`intro`, `spot:DOCX`, `path:Markdown>DOCX`, `surface:powershell`, `@powershell`, `outro`). The recorder fails with the unmatched scenes when a format is renamed or loses its routes; update `cuts.json`, do not weaken the check. +- PowerShell routes come from `Build/CompatibilityCatalog/powershell-routes.json`, a reviewed route-to-cmdlet list with source evidence. Add an entry only after reading the PSWriteOffice cmdlet that performs exactly that conversion. The generator rejects unknown routes and cmdlets missing from `Website/data/apidocs/powershell/command-metadata.json`. +- Do not commit rendered clips or frames. Render frames go under `EVOTEC_SCRATCH_ROOT` and are removed after a successful encode (and after a failed cut); remove superseded clips before repeating a large run. Keep the recorder outside the normal solution. ## Agent plugin and MCP Registry maintenance diff --git a/Build/FormatMapMedia/cuts.json b/Build/FormatMapMedia/cuts.json new file mode 100644 index 0000000000..70f3c780ad --- /dev/null +++ b/Build/FormatMapMedia/cuts.json @@ -0,0 +1,41 @@ +{ + "cuts": [ + { + "name": "media-16x9", + "ratio": "16x9", + "width": 1920, + "height": 1080, + "scenes": "intro,spot:DOCX,spot:XLSX,path:DOCX>Markdown,path:XLSX>PDF,surface:browser,surface:powershell,outro", + "formats": ["mp4"], + "posterMs": 6800 + }, + { + "name": "media-9x16", + "ratio": "9x16", + "width": 1080, + "height": 1920, + "scenes": "intro,spot:XLSX,path:XLSX>PDF,surface:powershell,outro", + "formats": ["mp4"], + "posterMs": 7000 + }, + { + "name": "media-1x1", + "ratio": "1x1", + "width": 1080, + "height": 1080, + "scenes": "intro,spot:DOCX,path:DOCX>Markdown,outro", + "formats": ["mp4", "gif"], + "gifWidth": 720, + "posterMs": 6800 + }, + { + "name": "media-4x5", + "ratio": "4x5", + "width": 1080, + "height": 1350, + "scenes": "intro,spot:XLSX,path:XLSX>PDF,surface:browser,outro", + "formats": ["mp4"], + "posterMs": 7000 + } + ] +} diff --git a/Build/FormatMapMedia/index.html b/Build/FormatMapMedia/index.html new file mode 100644 index 0000000000..12e55c0456 --- /dev/null +++ b/Build/FormatMapMedia/index.html @@ -0,0 +1,437 @@ + + + + + +OfficeIMO format map: media + + + + +
+ + OfficeIMOofficeimo.com +
+

+ + + diff --git a/Build/FormatMapRecorder/OfficeIMO.FormatMapRecorder.csproj b/Build/FormatMapRecorder/OfficeIMO.FormatMapRecorder.csproj new file mode 100644 index 0000000000..49963532b6 --- /dev/null +++ b/Build/FormatMapRecorder/OfficeIMO.FormatMapRecorder.csproj @@ -0,0 +1,12 @@ + + + Exe + net10.0 + enable + enable + false + + + + + diff --git a/Build/FormatMapRecorder/Program.cs b/Build/FormatMapRecorder/Program.cs new file mode 100644 index 0000000000..d6373adfce --- /dev/null +++ b/Build/FormatMapRecorder/Program.cs @@ -0,0 +1,291 @@ +using System.Diagnostics; +using System.Globalization; +using System.Net; +using System.Net.Sockets; +using System.Text.Json; +using HtmlTinkerX; +using Microsoft.Playwright; + +/// +/// Renders the format-map media diagram (Build/FormatMapMedia/index.html) into marketing clips: one clip per entry of its cuts.json, +/// framed for its platform (16:9, 1:1, 4:5, 9:16), plus a poster frame. +/// +/// The diagram is a pure function of time (window.imoMedia.render(t)), so the render is frame by frame, not a screen recording. +/// A private loopback server serves the page, the generated Website/data/format_map.json and the site font; each frame renders one +/// moment and is screenshotted at the cut's device scale; ffmpeg encodes the frames. The result is the same on any machine at any +/// frame rate and resolution (--scale=2 renders 1920x1080 as true 3840x2160, sharp because the diagram is vector). +/// +/// dotnet run --project Build/FormatMapRecorder -- [--output=Artefacts/FormatMapVideos] [--cut=name,name] [--scale=2] [--fps=60] +/// [--formats=mp4,mov,...] [--still=2000,8000] [--cuts=file] [--media-dir=dir] +/// [--ffmpeg=path] [--list] +/// Output formats: mp4 (H.264), h265, av1, webm (VP9), mov (ProRes 422 HQ), gif, webp, apng. +/// --still writes PNG stills of those moments (milliseconds) instead of video, for design work. +/// Frames are written under EVOTEC_SCRATCH_ROOT (or the output folder) and removed after a successful encode. +/// +internal static class Program { + private static readonly JsonSerializerOptions JsonOptions = new() { PropertyNameCaseInsensitive = true, ReadCommentHandling = JsonCommentHandling.Skip }; + + private static async Task Main(string[] args) { + string root = Directory.GetCurrentDirectory(); + string output = Path.GetFullPath(Option(args, "--output") ?? Path.Combine(root, "Artefacts", "FormatMapVideos")); + string mediaDir = Path.GetFullPath(Option(args, "--media-dir") ?? Path.Combine(root, "Build", "FormatMapMedia")); + string cutsPath = Option(args, "--cuts") ?? Path.Combine(mediaDir, "cuts.json"); + string dataDir = Path.Combine(root, "Website", "data"); + string fontDir = Path.Combine(root, "Website", "static", "fonts"); + if (!File.Exists(Path.Combine(mediaDir, "index.html")) || !File.Exists(Path.Combine(dataDir, "format_map.json")) || !File.Exists(cutsPath)) { + Console.Error.WriteLine($"Needed: {mediaDir}/index.html, its cuts.json and Website/data/format_map.json. Run this from the repository root."); + return 2; + } + Cut[] all = JsonSerializer.Deserialize(File.ReadAllText(cutsPath), JsonOptions)?.Cuts ?? []; + + if (args.Contains("--list", StringComparer.OrdinalIgnoreCase)) { + foreach (Cut cut in all) Console.WriteLine($"{cut.Name,-24} {cut.Width}x{cut.Height} {string.Join('+', cut.Formats)} {cut.Scenes ?? "(all scenes)"}"); + return 0; + } + + string[] wanted = (Option(args, "--cut") ?? "").Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + string[] unknown = wanted.Where(name => !all.Any(cut => string.Equals(cut.Name, name, StringComparison.OrdinalIgnoreCase))).ToArray(); + if (unknown.Length > 0) { + Console.Error.WriteLine("Unknown cut: " + string.Join(", ", unknown)); + return 2; + } + Cut[] cuts = wanted.Length == 0 ? all : all.Where(cut => wanted.Contains(cut.Name, StringComparer.OrdinalIgnoreCase)).ToArray(); + // --scale=2 renders the same layout at twice the pixels (1920x1080 becomes 3840x2160); --formats replaces each cut's formats. + int scale = int.TryParse(Option(args, "--scale"), out int parsedScale) ? Math.Clamp(parsedScale, 1, 4) : 1; + int? fps = int.TryParse(Option(args, "--fps"), out int parsedFps) ? Math.Clamp(parsedFps, 10, 120) : null; + string[]? formats = Option(args, "--formats")?.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + cuts = cuts.Select(cut => cut with { + Name = scale > 1 ? $"{cut.Name}-{scale}x" : cut.Name, + Width = cut.Width * scale, Height = cut.Height * scale, Scale = cut.Scale * scale, + Fps = fps ?? cut.Fps, + Formats = formats is { Length: > 0 } ? formats : cut.Formats + }).ToArray(); + double[] stills = (Option(args, "--still") ?? "").Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Select(static s => double.TryParse(s, NumberStyles.Float, CultureInfo.InvariantCulture, out double ms) ? ms : -1).Where(static ms => ms >= 0).ToArray(); + + string? ffmpeg = FindFfmpeg(Option(args, "--ffmpeg")); + if (ffmpeg is null) { + Console.Error.WriteLine("ffmpeg not found. It encodes the rendered frames: install it (winget install Gyan.FFmpeg) or pass --ffmpeg=."); + return 2; + } + Console.WriteLine("ffmpeg: " + ffmpeg); + + Directory.CreateDirectory(output); + string scratch = Path.Combine(Environment.GetEnvironmentVariable("EVOTEC_SCRATCH_ROOT") is { Length: > 0 } scratchRoot ? scratchRoot : Path.Combine(output, ".frames"), "format-map-recorder"); + using var server = new StaticSite(("/data/", dataDir), ("/fonts/", fontDir), ("/", mediaDir)); + int failures = 0; + foreach (Cut cut in cuts) { + try { + await RecordAsync(server.BaseUrl, cut, output, Path.Combine(scratch, cut.Name), ffmpeg, stills).ConfigureAwait(false); + } catch (Exception ex) { + failures++; + Console.Error.WriteLine($"[{cut.Name}] failed: {ex.Message}"); + // A failed cut leaves no half-rendered frames behind. + string leftover = Path.Combine(scratch, cut.Name); + if (Directory.Exists(leftover)) Directory.Delete(leftover, recursive: true); + } + } + // Leave no empty scratch folder behind. + if (Directory.Exists(scratch) && !Directory.EnumerateFileSystemEntries(scratch).Any()) Directory.Delete(scratch); + return failures == 0 ? 0 : 1; + } + /// + /// The media page (Build/FormatMapMedia/index.html) draws the diagram as a pure function of time, so nothing has to be frozen or stepped: + /// each frame is imoMedia.render(t) followed by a screenshot. + /// + private static async Task RecordAsync(string baseUrl, Cut cut, string output, string frames, string ffmpeg, double[] stills) { + var query = new List { "ratio=" + cut.Ratio }; + if (!string.IsNullOrWhiteSpace(cut.Scenes)) query.Add("scenes=" + Uri.EscapeDataString(cut.Scenes)); + if (Math.Abs(cut.Speed - 1) > 0.001) query.Add("speed=" + cut.Speed.ToString("0.##", CultureInfo.InvariantCulture)); + string url = $"{baseUrl}/index.html?{string.Join('&', query)}"; + Console.WriteLine($"[{cut.Name}] rendering media {cut.Width}x{cut.Height} at {cut.Fps} fps ({cut.Scale}x) {url}"); + + if (Directory.Exists(frames)) Directory.Delete(frames, recursive: true); + Directory.CreateDirectory(frames); + var options = new HtmlBrowserLaunchOptions { + Headless = true, Timeout = 120000, ViewportWidth = cut.Width / cut.Scale, ViewportHeight = cut.Height / cut.Scale, DeviceScaleFactor = cut.Scale + }; + int count; + var timer = Stopwatch.StartNew(); + await using (HtmlBrowserSession session = await HtmlBrowser.OpenSessionAsync("about:blank", options).ConfigureAwait(false)) { + IPage page = session.Page; + var problems = new List(); + page.PageError += (_, error) => problems.Add(error); + await page.GotoAsync(url, new() { WaitUntil = WaitUntilState.Load }).ConfigureAwait(false); + await page.EvaluateAsync("() => window.imoMedia.ready").ConfigureAwait(false); + if (problems.Count > 0) throw new InvalidOperationException("The media page raised an error: " + problems[0]); + string[] skipped = await page.EvaluateAsync("() => window.imoMedia.skipped").ConfigureAwait(false); + if (skipped.Length > 0) throw new InvalidOperationException($"The page has nothing to show for: {string.Join(", ", skipped)}. Update the scenes of cut '{cut.Name}' in Build/FormatMapMedia/cuts.json."); + double duration = await page.EvaluateAsync("() => window.imoMedia.duration").ConfigureAwait(false); + if (stills.Length > 0) { + foreach (double at in stills) { + await page.EvaluateAsync("(t) => window.imoMedia.render(t)", Math.Min(at, duration)).ConfigureAwait(false); + string still = Path.Combine(output, $"{cut.Name}-t{(int)at}.png"); + await page.ScreenshotAsync(new() { Type = ScreenshotType.Png, Path = still }).ConfigureAwait(false); + Report(still); + } + Console.WriteLine($" (duration {duration / 1000.0:0.0}s)"); + Directory.Delete(frames, recursive: true); + return; + } + count = (int)Math.Ceiling(duration / 1000.0 * cut.Fps) + 1; + for (int index = 0; index < count; index++) { + await page.EvaluateAsync("(t) => window.imoMedia.render(t)", index * 1000.0 / cut.Fps).ConfigureAwait(false); + byte[] jpeg = await page.ScreenshotAsync(new() { Type = ScreenshotType.Jpeg, Quality = 95 }).ConfigureAwait(false); + await File.WriteAllBytesAsync(Path.Combine(frames, $"{index:D6}.jpg"), jpeg).ConfigureAwait(false); + if (index % Math.Max(1, count / 10) == 0) Console.WriteLine($" frame {index}/{count} {timer.Elapsed.TotalSeconds:0}s"); + } + if (problems.Count > 0) throw new InvalidOperationException("The media page raised an error while rendering: " + problems[0]); + } + await EncodeAsync(cut, output, frames, count, ffmpeg).ConfigureAwait(false); + Console.WriteLine($" rendered in {timer.Elapsed.TotalSeconds:0}s"); + } + + /// Encodes the numbered JPEG frames into every format of the cut, writes the poster frame and removes the frames. + private static async Task EncodeAsync(Cut cut, string output, string frames, int count, string ffmpeg) { + string[] input = ["-framerate", cut.Fps.ToString(CultureInfo.InvariantCulture), "-i", Path.Combine(frames, "%06d.jpg")]; + foreach (string format in cut.Formats.Select(static f => f.ToLowerInvariant()).Distinct()) { + (string extension, string[] encode) = Encoding(format, cut); + string target = Path.Combine(output, cut.Name + extension); + await RunAsync(ffmpeg, ["-y", "-hide_banner", "-loglevel", "error", .. input, "-an", .. encode, target]).ConfigureAwait(false); + Report(target); + } + string poster = Path.Combine(output, cut.Name + ".png"); + int posterFrame = Math.Clamp((int)(cut.PosterMs / 1000.0 * cut.Fps), 0, count - 1); + await RunAsync(ffmpeg, ["-y", "-hide_banner", "-loglevel", "error", "-i", Path.Combine(frames, $"{posterFrame:D6}.jpg"), poster]).ConfigureAwait(false); + Report(poster); + Directory.Delete(frames, recursive: true); + } + + /// + /// File name suffix and ffmpeg encoder arguments per output format. + /// mp4 H.264, plays everywhere (LinkedIn, X, Discord, YouTube) h265 HEVC, smaller at the same quality + /// av1 AV1, smallest, newer players webm VP9 + /// mov ProRes 422 HQ 10-bit, an editing master (very large) gif / webp / apng silent animated loops + /// + private static (string Extension, string[] Arguments) Encoding(string format, Cut cut) { + string loop = $"fps={cut.GifFps},scale={cut.GifWidth}:-1:flags=lanczos"; + string fps = cut.Fps.ToString(CultureInfo.InvariantCulture); + return format switch { + "mp4" => (".mp4", ["-c:v", "libx264", "-preset", "slow", "-crf", "17", "-pix_fmt", "yuv420p", "-r", fps, "-movflags", "+faststart"]), + "h265" => (".h265.mp4", ["-c:v", "libx265", "-preset", "medium", "-crf", "20", "-pix_fmt", "yuv420p", "-tag:v", "hvc1", "-r", fps, "-movflags", "+faststart"]), + "av1" => (".av1.mp4", ["-c:v", "libsvtav1", "-preset", "6", "-crf", "30", "-pix_fmt", "yuv420p", "-r", fps, "-movflags", "+faststart"]), + "webm" => (".webm", ["-c:v", "libvpx-vp9", "-crf", "26", "-b:v", "0", "-r", fps]), + "mov" => (".mov", ["-c:v", "prores_ks", "-profile:v", "3", "-vendor", "apl0", "-pix_fmt", "yuv422p10le", "-r", fps]), + "gif" => (".gif", ["-vf", $"{loop},split[a][b];[a]palettegen=max_colors=128[p];[b][p]paletteuse=dither=bayer:bayer_scale=4", "-loop", "0"]), + "webp" => (".webp", ["-vf", loop, "-c:v", "libwebp_anim", "-q:v", "75", "-loop", "0"]), + "apng" => (".apng", ["-vf", loop, "-plays", "0"]), + _ => throw new InvalidOperationException($"Unknown format '{format}' in cut '{cut.Name}'. Use mp4, h265, av1, webm, mov, gif, webp or apng.") + }; + } + + private static void Report(string path) => Console.WriteLine($" {Path.GetFileName(path),-38} {new FileInfo(path).Length / 1024.0 / 1024.0,7:0.00} MB"); + + private static string? Option(string[] args, string name) => + args.FirstOrDefault(a => a.StartsWith(name + "=", StringComparison.OrdinalIgnoreCase))?[(name.Length + 1)..]; + + private static string? FindFfmpeg(string? explicitPath) { + if (!string.IsNullOrWhiteSpace(explicitPath)) return File.Exists(explicitPath) ? Path.GetFullPath(explicitPath) : null; + string exe = OperatingSystem.IsWindows() ? "ffmpeg.exe" : "ffmpeg"; + foreach (string directory in (Environment.GetEnvironmentVariable("PATH") ?? "").Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries)) { + string candidate = Path.Combine(directory.Trim('"'), exe); + if (File.Exists(candidate)) return candidate; + } + string winget = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "Microsoft", "WinGet", "Links", exe); + return File.Exists(winget) ? winget : null; + } + + private static async Task RunAsync(string executable, string[] arguments) { + var info = new ProcessStartInfo(executable) { RedirectStandardError = true, RedirectStandardOutput = true, UseShellExecute = false, CreateNoWindow = true }; + foreach (string argument in arguments) info.ArgumentList.Add(argument); + using Process process = Process.Start(info) ?? throw new InvalidOperationException("Could not start " + executable); + Task error = process.StandardError.ReadToEndAsync(); + Task output = process.StandardOutput.ReadToEndAsync(); + await process.WaitForExitAsync().ConfigureAwait(false); + await output.ConfigureAwait(false); + if (process.ExitCode != 0) throw new InvalidOperationException($"ffmpeg exited with {process.ExitCode}: {(await error.ConfigureAwait(false)).Trim()}"); + } +} + +internal sealed record CutFile(Cut[] Cuts); + +/// File name stem of the outputs. +/// 16x9, 1x1, 4x5 or 9x16: the stage layout (data-ratio). +/// Scene selection (?scenes=); null plays the whole tour. +/// mp4, h265, av1, webm, mov, gif, webp and/or apng. +/// Where in the finished clip the poster frame is taken. +/// Device scale factor: Width and Height are output pixels, the layout uses Width/Scale CSS pixels. +/// Frames per second of the render. +internal sealed record Cut(string Name, string Ratio, int Width, int Height, string[] Formats, string? Scenes = null, double Speed = 1, int PosterMs = 2500, int GifWidth = 640, int GifFps = 12, int Scale = 1, int Fps = 30); + +/// Serves a built website directory on a loopback port, so the render never needs a separate web server. +internal sealed class StaticSite : IDisposable { + private static readonly Dictionary ContentTypes = new(StringComparer.OrdinalIgnoreCase) { + [".html"] = "text/html; charset=utf-8", [".css"] = "text/css; charset=utf-8", [".js"] = "text/javascript; charset=utf-8", + [".json"] = "application/json", [".svg"] = "image/svg+xml", [".png"] = "image/png", [".jpg"] = "image/jpeg", [".jpeg"] = "image/jpeg", + [".webp"] = "image/webp", [".woff2"] = "font/woff2", [".woff"] = "font/woff", [".ico"] = "image/x-icon", [".xml"] = "application/xml", [".txt"] = "text/plain; charset=utf-8" + }; + + private readonly HttpListener _listener = new(); + private readonly (string Prefix, string Root)[] _mounts; + + internal string BaseUrl { get; } + + /// Serves each directory under its URL prefix; the longest matching prefix wins ("/" matches everything else). + internal StaticSite(params (string Prefix, string Root)[] mounts) { + _mounts = mounts + .Select(static m => (m.Prefix, Root: Path.GetFullPath(m.Root).TrimEnd(Path.DirectorySeparatorChar) + Path.DirectorySeparatorChar)) + .OrderByDescending(static m => m.Prefix.Length) + .ToArray(); + var probe = new TcpListener(IPAddress.Loopback, 0); + probe.Start(); + int port = ((IPEndPoint)probe.LocalEndpoint).Port; + probe.Stop(); + BaseUrl = $"http://127.0.0.1:{port}"; + _listener.Prefixes.Add(BaseUrl + "/"); + _listener.Start(); + _ = Task.Run(ServeAsync); + } + + private async Task ServeAsync() { + while (_listener.IsListening) { + HttpListenerContext context; + try { + context = await _listener.GetContextAsync().ConfigureAwait(false); + } catch (HttpListenerException) { + return; + } catch (ObjectDisposedException) { + return; + } + _ = Task.Run(() => RespondAsync(context)); + } + } + + private async Task RespondAsync(HttpListenerContext context) { + try { + string requested = Uri.UnescapeDataString(context.Request.Url?.AbsolutePath ?? "/"); + (string prefix, string root) = _mounts.First(m => requested.StartsWith(m.Prefix, StringComparison.Ordinal)); + string file = Path.GetFullPath(Path.Combine(root, requested[prefix.Length..].TrimStart('/'))); + // The resolved path must stay inside the served folder before anything touches the file system. + if (!file.StartsWith(root, StringComparison.OrdinalIgnoreCase)) { + context.Response.StatusCode = 404; + return; + } + if (Directory.Exists(file)) file = Path.Combine(file, "index.html"); + if (!File.Exists(file)) { + context.Response.StatusCode = 404; + } else { + byte[] bytes = await File.ReadAllBytesAsync(file).ConfigureAwait(false); + context.Response.ContentType = ContentTypes.GetValueOrDefault(Path.GetExtension(file), "application/octet-stream"); + context.Response.ContentLength64 = bytes.Length; + await context.Response.OutputStream.WriteAsync(bytes).ConfigureAwait(false); + } + } catch (Exception) { + context.Response.StatusCode = 500; + } finally { + context.Response.Close(); + } + } + + public void Dispose() => _listener.Close(); +} diff --git a/Build/Record-FormatMap.ps1 b/Build/Record-FormatMap.ps1 new file mode 100644 index 0000000000..cc0a3cf242 --- /dev/null +++ b/Build/Record-FormatMap.ps1 @@ -0,0 +1,108 @@ +<# +.SYNOPSIS + Re-records the format-map marketing clips from the current catalog. + +.DESCRIPTION + Runs two steps in order and stops at the first failure: + 1. Regenerates the catalog data (Website/data/format_map.json) from the conversion catalog and powershell-routes.json. + 2. Renders every cut in Build/FormatMapMedia/cuts.json frame by frame and encodes it with ffmpeg. + The diagram (Build/FormatMapMedia/index.html) reads the generated data directly, so no website build is needed. + Clips and poster frames land in Artefacts/FormatMapVideos (not tracked). Needs ffmpeg and the shared Playwright browsers. + +.PARAMETER Cut + Only these cuts (names from cuts.json). Default: all. + +.PARAMETER Scale + 2 renders 1920x1080 as true 3840x2160 (the diagram is vector, so text and lines stay sharp; about three times slower). + +.PARAMETER Fps + Frames per second of the render (10 to 120). Default: the cut's own, 30. + +.PARAMETER Formats + Replaces each cut's output formats: mp4, h265, av1, webm, mov, gif, webp, apng. + +.PARAMETER Output + Output folder. Default: Artefacts/FormatMapVideos. + +.PARAMETER SkipCatalog + Reuse the current Website/data/format_map.json (the catalog build takes minutes; skip it when only the design or the cuts changed). + +.PARAMETER Still + Write PNG stills of these moments (milliseconds) instead of rendering video. Useful when changing the design. + +.PARAMETER List + Print the cuts and exit. + +.EXAMPLE + ./Build/Record-FormatMap.ps1 + ./Build/Record-FormatMap.ps1 -Cut media-9x16 -SkipCatalog + ./Build/Record-FormatMap.ps1 -Cut media-16x9 -Scale 2 -SkipCatalog + ./Build/Record-FormatMap.ps1 -Cut media-9x16 -Still 2000,8000 -SkipCatalog + ./Build/Record-FormatMap.ps1 -Formats mp4,h265,gif,webp -SkipCatalog +#> +[CmdletBinding()] +param( + [string[]] $Cut, + [ValidateRange(1, 4)] + [int] $Scale = 1, + [ValidateRange(10, 120)] + [int] $Fps, + [string[]] $Formats, + [string] $Output, + [switch] $SkipCatalog, + [int[]] $Still, + [switch] $List +) + +$ErrorActionPreference = 'Stop' +$repository = Split-Path -Parent $PSScriptRoot +$recorder = Join-Path $repository 'Build/FormatMapRecorder/OfficeIMO.FormatMapRecorder.csproj' + +function Invoke-Step { + param([string] $Name, [scriptblock] $Action) + Write-Host "== $Name" -ForegroundColor Cyan + & $Action + if ($LASTEXITCODE -ne 0) { throw "$Name failed (exit code $LASTEXITCODE)." } +} + +# The catalog tool writes LF files, so git lists every generated file as modified on a CRLF checkout even when nothing changed. +# Restore the ones whose only difference is line endings; real changes (format_map.json, a changed catalog) are left alone. +function Restore-LineEndingOnlyChanges { + $changed = @(git diff --name-only 2>$null) + $restored = 0 + foreach ($file in $changed) { + git diff --ignore-cr-at-eol --quiet -- $file 2>$null + if ($LASTEXITCODE -eq 0) { git checkout -- $file 2>$null; $restored++ } + } + $global:LASTEXITCODE = 0 + if ($restored -gt 0) { Write-Host "Restored $restored generated files that differed only in line endings." } +} + +Push-Location $repository +try { + if ($List) { + dotnet run --project $recorder -c Release -- --list + exit $LASTEXITCODE + } + + if (-not $SkipCatalog) { + Invoke-Step 'Regenerate catalog data' { + dotnet run --project (Join-Path $repository 'Build/CompatibilityCatalog/OfficeIMO.CompatibilityCatalog.Tool.csproj') -c Release -f net10.0 + } + Restore-LineEndingOnlyChanges + } + + $arguments = @() + if ($Still) { $arguments += "--still=$($Still -join ',')" } + if ($Cut) { $arguments += "--cut=$($Cut -join ',')" } + if ($Scale -gt 1) { $arguments += "--scale=$Scale" } + if ($PSBoundParameters.ContainsKey('Fps')) { $arguments += "--fps=$Fps" } + if ($Formats) { $arguments += "--formats=$($Formats -join ',')" } + if ($Output) { $arguments += "--output=$Output" } + Invoke-Step 'Render clips' { + dotnet run --project $recorder -c Release -- @arguments + } +} +finally { + Pop-Location +} From f7c2984aac03e1c6cbe9c325c9414b37fe2e83ed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Przemys=C5=82aw=20K=C5=82ys?= Date: Sun, 11 Oct 2026 00:09:14 +0200 Subject: [PATCH 02/11] Validate media recorder inputs and fix compact compositions --- AGENTS.md | 2 +- Build/FormatMapMedia/index.html | 30 +++++++++--- Build/FormatMapRecorder/Program.cs | 74 +++++++++++++++++++++++++----- Build/Record-FormatMap.ps1 | 20 ++------ 4 files changed, 93 insertions(+), 33 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 815ed1993e..08c9a80f3f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -71,7 +71,7 @@ `Build/FormatMapMedia/index.html` is a purpose-built diagram of the format map for video and social media; `Build/FormatMapRecorder` renders it into clips. Nothing in a clip is hand-made, so re-record from the data whenever the catalog, the PowerShell mapping or the design changes. The website's own format map (`Website/themes/officeimo/partials/sections/format-map.html`, `Website/static/js/format-map.js`) is separate and is not used for recordings. -- Re-record with `./Build/Record-FormatMap.ps1`. It regenerates `Website/data/format_map.json` and renders every cut in `Build/FormatMapMedia/cuts.json` into `Artefacts/FormatMapVideos` (ignored). Useful switches: `-Cut `, `-Scale 2` (true 3840x2160), `-Formats mp4,h265,mov,gif,...`, `-SkipCatalog`, `-Still 2000,8000` (PNG stills of those moments, for design work), `-List`. It needs ffmpeg and the shared Playwright browsers (HtmlTinkerX); it restores generated files that differ only in line endings. No website build is involved. +- Re-record with `./Build/Record-FormatMap.ps1`. It regenerates `Website/data/format_map.json` and renders every cut in `Build/FormatMapMedia/cuts.json` into `Artefacts/FormatMapVideos` (ignored). Useful switches: `-Cut `, `-Scale 2` (true 3840x2160), `-Formats mp4,h265,mov,gif,...`, `-SkipCatalog`, `-Still 0,2000,8000` (PNG stills of those moments, for design work), `-List`. Video encoding needs ffmpeg; stills need only the shared Playwright browsers (HtmlTinkerX). Review generated changes after regeneration; the wrapper never restores working-tree edits. No website build is involved. - The diagram draws everything as a pure function of time (`window.imoMedia.render(t)`), so the recorder steps it frame by frame at any frame rate and resolution. Keep it free of timers, transitions and hover state, and keep it vector so `-Scale 2` stays sharp. It reads only `Website/data/format_map.json`, `surfaces.json` and the site font; presentation-only short labels (CHM, MEDLINE, XPS) live in its `ALIAS` table and the data names are untouched. - It has compositions for 16:9, 1:1, 4:5 and 9:16; surface pills light only for the surfaces that really run what is shown. - Cuts name formats and surfaces with the site tour's grammar (`intro`, `spot:DOCX`, `path:Markdown>DOCX`, `surface:powershell`, `@powershell`, `outro`). The recorder fails with the unmatched scenes when a format is renamed or loses its routes; update `cuts.json`, do not weaken the check. diff --git a/Build/FormatMapMedia/index.html b/Build/FormatMapMedia/index.html index 12e55c0456..40c10fc5b1 100644 --- a/Build/FormatMapMedia/index.html +++ b/Build/FormatMapMedia/index.html @@ -22,6 +22,8 @@ #title { margin: 0; font-weight: 800; letter-spacing: -.035em; line-height: 1.02; } #title .num { display: block; font-variant-numeric: tabular-nums; line-height: .95; } #title .unit { display: block; font-weight: 700; color: #c4d1ea; letter-spacing: -.02em; } + body[data-layout="1x1"] #title .num, body[data-layout="4x5"] #title .num { display: inline-block; margin-right: .15em; } + body[data-layout="1x1"] #title .unit, body[data-layout="4x5"] #title .unit { display: inline; } #note { font-weight: 550; color: #93a5c7; line-height: 1.35; } #pills { display: flex; flex-wrap: wrap; gap: 10px; } .pill { display: inline-flex; align-items: center; gap: 9px; padding: .42em .95em; border-radius: 999px; border: 1px solid rgba(255,255,255,.12); font-weight: 700; color: #7f90b0; white-space: nowrap; } @@ -80,10 +82,11 @@ return a > 1.3 ? '16x9' : a < .7 ? '9x16' : a < .9 ? '4x5' : '1x1'; } const layout = pickLayout(); + document.body.dataset.layout = layout; const COMP = { '16x9': { cx: W * .65, cy: H * .5, R: H * .292, brand: [W * .05, H * .065], cap: [W * .05, H * .3, W * .335], title: 84, note: 28, kick: 22, pill: 19, brandSize: 44 }, '9x16': { cx: W * .5, cy: H * .385, R: W * .30, brand: [W * .08, H * .04], cap: [W * .08, H * .685, W * .84], title: 96, note: 34, kick: 26, pill: 23, brandSize: 46 }, - '1x1': { cx: W * .5, cy: H * .425, R: W * .255, brand: [W * .06, H * .04], cap: [W * .07, H * .775, W * .86], title: 62, note: 26, kick: 20, pill: 19, brandSize: 38 }, + '1x1': { cx: W * .5, cy: H * .40, R: W * .235, brand: [W * .06, H * .04], cap: [W * .07, H * .735, W * .86], title: 62, note: 26, kick: 20, pill: 19, brandSize: 38 }, '4x5': { cx: W * .5, cy: H * .415, R: W * .295, brand: [W * .06, H * .035], cap: [W * .07, H * .76, W * .86], title: 74, note: 30, kick: 22, pill: 22, brandSize: 42 } }[layout]; const { cx, cy, R } = COMP; @@ -184,7 +187,12 @@ let token = raw.trim(), surface = 'all', before = out.length; if (!token) return; const at = token.lastIndexOf('@'); - if (at > 0) { const id = token.slice(at + 1).trim().toLowerCase(); if (counts[id]) surface = id; token = token.slice(0, at); } + if (at > 0) { + const id = token.slice(at + 1).trim().toLowerCase(); + if (!SURFACE_ORDER.includes(id) || !counts[id]) { skipped.push(raw.trim()); return; } + surface = id; + token = token.slice(0, at); + } const colon = token.indexOf(':'); const kind = (colon < 0 ? token : token.slice(0, colon)).replace(/\s+/g, '').toLowerCase(); const arg = colon < 0 ? '' : token.slice(colon + 1).trim(); @@ -403,16 +411,26 @@ } noteEl.textContent = scene.note; pillsEl.querySelectorAll('.pill').forEach(p => { - const lit = state.pills.has(p.dataset.surface); - p.style.color = lit ? SURFACE_COLOR[p.dataset.surface] : ''; - p.style.borderColor = lit ? SURFACE_COLOR[p.dataset.surface] + '88' : ''; + const id = p.dataset.surface; + // Match the routes still visible from the outgoing scene as well as the incoming scene. + const lit = state.pills.has(id) || (prev && b < 1 && prev.pills.has(id)); + p.style.color = lit ? SURFACE_COLOR[id] : ''; + p.style.borderColor = lit ? SURFACE_COLOR[id] + '88' : ''; p.style.opacity = lit ? 1 : .5; }); } // ---------------------------------------------------------------- start const ready = (async () => { - const [data, surfaceData] = await Promise.all([fetch('/data/format_map.json').then(r => r.json()), fetch('/data/surfaces.json').then(r => r.json()).catch(() => ({ items: [] }))]); + const load = async url => { + const response = await fetch(url); + if (!response.ok) throw new Error(`Could not load ${url}: HTTP ${response.status}`); + return response.json(); + }; + const [data, surfaceData] = await Promise.all([load('/data/format_map.json'), load('/data/surfaces.json')]); + if (!Array.isArray(surfaceData.items) || SURFACE_ORDER.some(id => !surfaceData.items.some(s => s.id === id && s.name && s.description))) { + throw new Error('Surface metadata must describe every supported surface.'); + } build(data, surfaceData); const info = layoutRing(); buildStatic(info); diff --git a/Build/FormatMapRecorder/Program.cs b/Build/FormatMapRecorder/Program.cs index d6373adfce..044dea6e62 100644 --- a/Build/FormatMapRecorder/Program.cs +++ b/Build/FormatMapRecorder/Program.cs @@ -26,6 +26,15 @@ internal static class Program { private static readonly JsonSerializerOptions JsonOptions = new() { PropertyNameCaseInsensitive = true, ReadCommentHandling = JsonCommentHandling.Skip }; private static async Task Main(string[] args) { + try { + return await RecordCutsAsync(args).ConfigureAwait(false); + } catch (Exception ex) when (ex is ArgumentException or InvalidOperationException or JsonException or OverflowException) { + Console.Error.WriteLine(ex.Message); + return 2; + } + } + + private static async Task RecordCutsAsync(string[] args) { string root = Directory.GetCurrentDirectory(); string output = Path.GetFullPath(Option(args, "--output") ?? Path.Combine(root, "Artefacts", "FormatMapVideos")); string mediaDir = Path.GetFullPath(Option(args, "--media-dir") ?? Path.Combine(root, "Build", "FormatMapMedia")); @@ -37,6 +46,12 @@ private static async Task Main(string[] args) { return 2; } Cut[] all = JsonSerializer.Deserialize(File.ReadAllText(cutsPath), JsonOptions)?.Cuts ?? []; + if (all.Length == 0) throw new ArgumentException("The cut file must contain at least one cut."); + var names = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (Cut cut in all) { + ValidateCut(cut); + if (!names.Add(cut.Name)) throw new ArgumentException($"Duplicate cut name: {cut.Name}"); + } if (args.Contains("--list", StringComparer.OrdinalIgnoreCase)) { foreach (Cut cut in all) Console.WriteLine($"{cut.Name,-24} {cut.Width}x{cut.Height} {string.Join('+', cut.Formats)} {cut.Scenes ?? "(all scenes)"}"); @@ -51,32 +66,34 @@ private static async Task Main(string[] args) { } Cut[] cuts = wanted.Length == 0 ? all : all.Where(cut => wanted.Contains(cut.Name, StringComparer.OrdinalIgnoreCase)).ToArray(); // --scale=2 renders the same layout at twice the pixels (1920x1080 becomes 3840x2160); --formats replaces each cut's formats. - int scale = int.TryParse(Option(args, "--scale"), out int parsedScale) ? Math.Clamp(parsedScale, 1, 4) : 1; - int? fps = int.TryParse(Option(args, "--fps"), out int parsedFps) ? Math.Clamp(parsedFps, 10, 120) : null; + int scale = ParseIntegerOption(args, "--scale", 1, 4) ?? 1; + int? fps = ParseIntegerOption(args, "--fps", 10, 120); string[]? formats = Option(args, "--formats")?.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + if (formats is { Length: 0 }) throw new ArgumentException("--formats requires at least one output format."); cuts = cuts.Select(cut => cut with { Name = scale > 1 ? $"{cut.Name}-{scale}x" : cut.Name, - Width = cut.Width * scale, Height = cut.Height * scale, Scale = cut.Scale * scale, + Width = checked(cut.Width * scale), Height = checked(cut.Height * scale), Scale = checked(cut.Scale * scale), Fps = fps ?? cut.Fps, Formats = formats is { Length: > 0 } ? formats : cut.Formats }).ToArray(); - double[] stills = (Option(args, "--still") ?? "").Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) - .Select(static s => double.TryParse(s, NumberStyles.Float, CultureInfo.InvariantCulture, out double ms) ? ms : -1).Where(static ms => ms >= 0).ToArray(); + foreach (Cut cut in cuts) ValidateCut(cut); + double[] stills = ParseStills(Option(args, "--still")); - string? ffmpeg = FindFfmpeg(Option(args, "--ffmpeg")); - if (ffmpeg is null) { + string? ffmpeg = stills.Length == 0 ? FindFfmpeg(Option(args, "--ffmpeg")) : null; + if (stills.Length == 0 && ffmpeg is null) { Console.Error.WriteLine("ffmpeg not found. It encodes the rendered frames: install it (winget install Gyan.FFmpeg) or pass --ffmpeg=."); return 2; } - Console.WriteLine("ffmpeg: " + ffmpeg); + if (ffmpeg is not null) Console.WriteLine("ffmpeg: " + ffmpeg); Directory.CreateDirectory(output); - string scratch = Path.Combine(Environment.GetEnvironmentVariable("EVOTEC_SCRATCH_ROOT") is { Length: > 0 } scratchRoot ? scratchRoot : Path.Combine(output, ".frames"), "format-map-recorder"); + // Own a unique invocation directory; never remove a previous run's or another recorder's frames. + string scratch = Path.Combine(Environment.GetEnvironmentVariable("EVOTEC_SCRATCH_ROOT") is { Length: > 0 } scratchRoot ? scratchRoot : Path.Combine(output, ".frames"), "format-map-recorder-" + Guid.NewGuid().ToString("N")); using var server = new StaticSite(("/data/", dataDir), ("/fonts/", fontDir), ("/", mediaDir)); int failures = 0; foreach (Cut cut in cuts) { try { - await RecordAsync(server.BaseUrl, cut, output, Path.Combine(scratch, cut.Name), ffmpeg, stills).ConfigureAwait(false); + await RecordAsync(server.BaseUrl, cut, output, Path.Combine(scratch, cut.Name), ffmpeg ?? "", stills).ConfigureAwait(false); } catch (Exception ex) { failures++; Console.Error.WriteLine($"[{cut.Name}] failed: {ex.Message}"); @@ -89,6 +106,40 @@ private static async Task Main(string[] args) { if (Directory.Exists(scratch) && !Directory.EnumerateFileSystemEntries(scratch).Any()) Directory.Delete(scratch); return failures == 0 ? 0 : 1; } + + private static int? ParseIntegerOption(string[] args, string name, int minimum, int maximum) { + string? value = Option(args, name); + if (value is null) return null; + if (!int.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out int number) || number < minimum || number > maximum) + throw new ArgumentException($"{name} must be an integer from {minimum} to {maximum}."); + return number; + } + + private static double[] ParseStills(string? value) { + if (value is null) return []; + string[] tokens = value.Split(',', StringSplitOptions.TrimEntries); + return tokens.Select(token => { + if (!double.TryParse(token, NumberStyles.Float, CultureInfo.InvariantCulture, out double ms) || !double.IsFinite(ms) || ms < 0 || ms > int.MaxValue) + throw new ArgumentException("--still requires comma-separated finite nonnegative timestamps in milliseconds."); + return ms; + }).Distinct().ToArray(); + } + + private static void ValidateCut(Cut cut) { + if (string.IsNullOrWhiteSpace(cut.Name) || !System.Text.RegularExpressions.Regex.IsMatch(cut.Name, @"\A[a-zA-Z0-9][a-zA-Z0-9_-]*\z")) + throw new ArgumentException("Cut names must be a single filename stem containing only letters, digits, underscores or hyphens."); + (int width, int height) = cut.Ratio switch { + "16x9" => (16, 9), "1x1" => (1, 1), "4x5" => (4, 5), "9x16" => (9, 16), + _ => throw new ArgumentException($"Invalid ratio for cut '{cut.Name}': use 16x9, 1x1, 4x5 or 9x16.") + }; + if (cut.Width < 2 || cut.Height < 2 || (long)cut.Width * height != (long)cut.Height * width || cut.Scale < 1 || cut.Width % cut.Scale != 0 || cut.Height % cut.Scale != 0 || cut.Fps < 10 || cut.Fps > 120 || cut.GifWidth < 2 || cut.GifFps < 1 || cut.GifFps > 120 || !double.IsFinite(cut.Speed) || cut.Speed < .4 || cut.Speed > 3) + throw new ArgumentException($"Cut '{cut.Name}' has invalid dimensions, scale, frame rate or speed."); + if (cut.Formats is not { Length: > 0 }) throw new ArgumentException($"Cut '{cut.Name}' needs an output format."); + foreach (string format in cut.Formats) { + if (string.IsNullOrWhiteSpace(format)) throw new ArgumentException($"Cut '{cut.Name}' has an empty output format."); + _ = Encoding(format.ToLowerInvariant(), cut); + } + } /// /// The media page (Build/FormatMapMedia/index.html) draws the diagram as a pure function of time, so nothing has to be frozen or stepped: /// each frame is imoMedia.render(t) followed by a screenshot. @@ -100,7 +151,8 @@ private static async Task RecordAsync(string baseUrl, Cut cut, string output, st string url = $"{baseUrl}/index.html?{string.Join('&', query)}"; Console.WriteLine($"[{cut.Name}] rendering media {cut.Width}x{cut.Height} at {cut.Fps} fps ({cut.Scale}x) {url}"); - if (Directory.Exists(frames)) Directory.Delete(frames, recursive: true); + if (Directory.Exists(frames)) throw new InvalidOperationException("The frame directory already exists: " + frames); + Directory.CreateDirectory(frames); var options = new HtmlBrowserLaunchOptions { Headless = true, Timeout = 120000, ViewportWidth = cut.Width / cut.Scale, ViewportHeight = cut.Height / cut.Scale, DeviceScaleFactor = cut.Scale diff --git a/Build/Record-FormatMap.ps1 b/Build/Record-FormatMap.ps1 index cc0a3cf242..04af09e210 100644 --- a/Build/Record-FormatMap.ps1 +++ b/Build/Record-FormatMap.ps1 @@ -50,6 +50,7 @@ param( [string[]] $Formats, [string] $Output, [switch] $SkipCatalog, + [ValidateRange(0, [int]::MaxValue)] [int[]] $Still, [switch] $List ) @@ -65,19 +66,6 @@ function Invoke-Step { if ($LASTEXITCODE -ne 0) { throw "$Name failed (exit code $LASTEXITCODE)." } } -# The catalog tool writes LF files, so git lists every generated file as modified on a CRLF checkout even when nothing changed. -# Restore the ones whose only difference is line endings; real changes (format_map.json, a changed catalog) are left alone. -function Restore-LineEndingOnlyChanges { - $changed = @(git diff --name-only 2>$null) - $restored = 0 - foreach ($file in $changed) { - git diff --ignore-cr-at-eol --quiet -- $file 2>$null - if ($LASTEXITCODE -eq 0) { git checkout -- $file 2>$null; $restored++ } - } - $global:LASTEXITCODE = 0 - if ($restored -gt 0) { Write-Host "Restored $restored generated files that differed only in line endings." } -} - Push-Location $repository try { if ($List) { @@ -89,11 +77,13 @@ try { Invoke-Step 'Regenerate catalog data' { dotnet run --project (Join-Path $repository 'Build/CompatibilityCatalog/OfficeIMO.CompatibilityCatalog.Tool.csproj') -c Release -f net10.0 } - Restore-LineEndingOnlyChanges } $arguments = @() - if ($Still) { $arguments += "--still=$($Still -join ',')" } + if ($PSBoundParameters.ContainsKey('Still')) { + if ($Still.Count -eq 0) { throw 'Still requires at least one timestamp.' } + $arguments += "--still=$($Still -join ',')" + } if ($Cut) { $arguments += "--cut=$($Cut -join ',')" } if ($Scale -gt 1) { $arguments += "--scale=$Scale" } if ($PSBoundParameters.ContainsKey('Fps')) { $arguments += "--fps=$Fps" } From 0f6b984de8970037a3badff3551c5fc19e4444b0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Przemys=C5=82aw=20K=C5=82ys?= Date: Sun, 11 Oct 2026 01:03:28 +0200 Subject: [PATCH 03/11] Validate media scene grammar and preserve capture identities --- Build/FormatMapMedia/index.html | 15 +++++++++++++-- Build/FormatMapRecorder/Program.cs | 13 ++++++++++++- 2 files changed, 25 insertions(+), 3 deletions(-) diff --git a/Build/FormatMapMedia/index.html b/Build/FormatMapMedia/index.html index 40c10fc5b1..3349c0a568 100644 --- a/Build/FormatMapMedia/index.html +++ b/Build/FormatMapMedia/index.html @@ -186,6 +186,7 @@ spec.split(',').forEach(raw => { let token = raw.trim(), surface = 'all', before = out.length; if (!token) return; + if (token.indexOf('@') !== token.lastIndexOf('@')) { skipped.push(token); return; } const at = token.lastIndexOf('@'); if (at > 0) { const id = token.slice(at + 1).trim().toLowerCase(); @@ -196,14 +197,24 @@ const colon = token.indexOf(':'); const kind = (colon < 0 ? token : token.slice(0, colon)).replace(/\s+/g, '').toLowerCase(); const arg = colon < 0 ? '' : token.slice(colon + 1).trim(); + if ((at >= 0 && !['spot', 'path', 'spots', 'paths'].includes(kind)) || + (['intro', 'outro', 'spots', 'paths', 'surfaces'].includes(kind) && colon >= 0)) { + skipped.push(raw.trim()); return; + } const add = s => { if (s) out.push(s); }; if (kind === 'intro') add(introScene()); else if (kind === 'outro') add(outroScene()); else if (kind === 'spots') defaultSpots(surface).forEach(add); else if (kind === 'paths') defaultPaths(surface).forEach(add); else if (kind === 'surfaces') defaultSurfaces().forEach(add); - else if (kind === 'spot') { const f = findFormat(arg); if (f) add(spotScene(f, surface)); } - else if (kind === 'path') { const [x, y] = arg.split(/>|~|→/); const a = findFormat(x || ''), b = findFormat(y || ''); if (a && b) add(pathScene(a, b, surface)); } + else if (kind === 'spot') { const f = /[:>@~→]/.test(arg) ? null : findFormat(arg); if (f) add(spotScene(f, surface)); } + else if (kind === 'path') { + const endpoints = arg.split(/>|~|→/); + if (endpoints.length === 2 && endpoints.every(x => x.trim() && !/[:@]/.test(x))) { + const a = findFormat(endpoints[0]), b = findFormat(endpoints[1]); + if (a && b) add(pathScene(a, b, surface)); + } + } else if (kind === 'surface') { const id = arg.toLowerCase(); if (SURFACE_ORDER.includes(id)) add(surfaceScene(id)); } if (out.length === before) skipped.push(raw.trim()); }); diff --git a/Build/FormatMapRecorder/Program.cs b/Build/FormatMapRecorder/Program.cs index 044dea6e62..a9ed6b185c 100644 --- a/Build/FormatMapRecorder/Program.cs +++ b/Build/FormatMapRecorder/Program.cs @@ -78,6 +78,16 @@ private static async Task RecordCutsAsync(string[] args) { }).ToArray(); foreach (Cut cut in cuts) ValidateCut(cut); double[] stills = ParseStills(Option(args, "--still")); + if (stills.Length == 0) { + foreach (Cut cut in cuts) { + foreach (string format in cut.Formats.Select(static f => f.ToLowerInvariant())) { + if ((format is "mp4" or "h265" or "av1") && (cut.Width % 2 != 0 || cut.Height % 2 != 0)) + throw new ArgumentException($"Cut '{cut.Name}' needs even width and height for {format} (YUV 4:2:0)."); + if (format == "h265" && (cut.Width < 16 || cut.Height < 16)) + throw new ArgumentException($"Cut '{cut.Name}' needs width and height of at least 16 pixels for h265."); + } + } + } string? ffmpeg = stills.Length == 0 ? FindFfmpeg(Option(args, "--ffmpeg")) : null; if (stills.Length == 0 && ffmpeg is null) { @@ -172,7 +182,8 @@ private static async Task RecordAsync(string baseUrl, Cut cut, string output, st if (stills.Length > 0) { foreach (double at in stills) { await page.EvaluateAsync("(t) => window.imoMedia.render(t)", Math.Min(at, duration)).ConfigureAwait(false); - string still = Path.Combine(output, $"{cut.Name}-t{(int)at}.png"); + string still = Path.Combine(output, $"{cut.Name}-t{at.ToString("R", CultureInfo.InvariantCulture)}.png"); + await page.ScreenshotAsync(new() { Type = ScreenshotType.Png, Path = still }).ConfigureAwait(false); Report(still); } From 33e007d213b281cb842681311add5226918d1ef7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Przemys=C5=82aw=20K=C5=82ys?= Date: Sun, 11 Oct 2026 01:54:55 +0200 Subject: [PATCH 04/11] Validate scene lists and show incoming route surfaces --- Build/FormatMapMedia/index.html | 13 +++++++++---- Build/FormatMapRecorder/Program.cs | 2 +- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/Build/FormatMapMedia/index.html b/Build/FormatMapMedia/index.html index 3349c0a568..bcc1fcc274 100644 --- a/Build/FormatMapMedia/index.html +++ b/Build/FormatMapMedia/index.html @@ -185,7 +185,7 @@ const out = []; spec.split(',').forEach(raw => { let token = raw.trim(), surface = 'all', before = out.length; - if (!token) return; + if (!token) { skipped.push(''); return; } if (token.indexOf('@') !== token.lastIndexOf('@')) { skipped.push(token); return; } const at = token.lastIndexOf('@'); if (at > 0) { @@ -245,7 +245,11 @@ mark(r.target, 1); r.surfaces.forEach(s => pills.add(s)); }); - i.forEach(r => { edges.push({ r, delay: 200, dur: 700, width: 1.6, alpha: .38, stops: stops(r), dashed: true }); mark(r.source, .55); }); + i.forEach(r => { + edges.push({ r, delay: 200, dur: 700, width: 1.6, alpha: .38, stops: stops(r), dashed: true }); + mark(r.source, .55); + r.surfaces.forEach(s => pills.add(s)); + }); if (scene.surface !== 'all') { pills.clear(); pills.add(scene.surface); } } else if (scene.type === 'path') { baseDim = .3; @@ -447,8 +451,9 @@ buildStatic(info); buildHtml(); const spec = query.get('scenes'); - const chosen = spec ? parseScenes(spec) : []; - scenes = chosen.length ? chosen : [introScene(), ...defaultSpots('all'), ...defaultPaths('all'), ...defaultSurfaces(), outroScene()]; + const chosen = spec !== null ? parseScenes(spec) : []; + if (spec !== null && (skipped.length || !chosen.length)) throw new Error('The page has nothing to show for: ' + skipped.join(', ')); + scenes = spec !== null ? chosen : [introScene(), ...defaultSpots('all'), ...defaultPaths('all'), ...defaultSurfaces(), outroScene()]; let at = 0; starts = scenes.map(s => { const s0 = at; at += s.dwell; return s0; }); total = at; diff --git a/Build/FormatMapRecorder/Program.cs b/Build/FormatMapRecorder/Program.cs index a9ed6b185c..b5b42b02e9 100644 --- a/Build/FormatMapRecorder/Program.cs +++ b/Build/FormatMapRecorder/Program.cs @@ -156,7 +156,7 @@ private static void ValidateCut(Cut cut) { /// private static async Task RecordAsync(string baseUrl, Cut cut, string output, string frames, string ffmpeg, double[] stills) { var query = new List { "ratio=" + cut.Ratio }; - if (!string.IsNullOrWhiteSpace(cut.Scenes)) query.Add("scenes=" + Uri.EscapeDataString(cut.Scenes)); + if (cut.Scenes is not null) query.Add("scenes=" + Uri.EscapeDataString(cut.Scenes)); if (Math.Abs(cut.Speed - 1) > 0.001) query.Add("speed=" + cut.Speed.ToString("0.##", CultureInfo.InvariantCulture)); string url = $"{baseUrl}/index.html?{string.Join('&', query)}"; Console.WriteLine($"[{cut.Name}] rendering media {cut.Width}x{cut.Height} at {cut.Fps} fps ({cut.Scale}x) {url}"); From efde2dbb8f1cbaf387e55f054cffa230f8680a3d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Przemys=C5=82aw=20K=C5=82ys?= Date: Sun, 11 Oct 2026 02:40:06 +0200 Subject: [PATCH 05/11] Validate recorder options and smooth media scene transitions --- Build/FormatMapMedia/index.html | 14 +++++++++----- Build/FormatMapRecorder/Program.cs | 25 ++++++++++++++++++++++--- Build/Record-FormatMap.ps1 | 4 ++-- 3 files changed, 33 insertions(+), 10 deletions(-) diff --git a/Build/FormatMapMedia/index.html b/Build/FormatMapMedia/index.html index bcc1fcc274..cede32af0e 100644 --- a/Build/FormatMapMedia/index.html +++ b/Build/FormatMapMedia/index.html @@ -89,6 +89,9 @@ '1x1': { cx: W * .5, cy: H * .40, R: W * .235, brand: [W * .06, H * .04], cap: [W * .07, H * .735, W * .86], title: 62, note: 26, kick: 20, pill: 19, brandSize: 38 }, '4x5': { cx: W * .5, cy: H * .415, R: W * .295, brand: [W * .06, H * .035], cap: [W * .07, H * .76, W * .86], title: 74, note: 30, kick: 22, pill: 22, brandSize: 42 } }[layout]; + // Keep each composition's typography proportional when a cut uses a smaller or larger viewport. + const textScale = W / (layout === '16x9' ? 1920 : 1080); + ['title', 'note', 'kick', 'pill', 'brandSize'].forEach(key => { COMP[key] *= textScale; }); const { cx, cy, R } = COMP; // ---------------------------------------------------------------- data @@ -404,11 +407,12 @@ } // camera: a slow push in, leaning toward what is lit const zoomMax = layout === '16x9' ? .06 : .035; - const k = easeInOut(local / scene.dwell); - const zoom = 1 + zoomMax * (scene.type === 'outro' ? 0 : k); - const target = state.camera ? pos[state.camera] : null; - const lean = target ? .12 * k : 0; - const tx = target ? (cx - target.x) * lean : 0, ty = target ? (cy - target.y) * lean : 0; + const camera = (s, current, elapsed) => { + const k = easeInOut(elapsed / s.dwell), target = current.camera ? pos[current.camera] : null; + return { zoom: 1 + zoomMax * (s.type === 'outro' ? 0 : k), tx: target ? (cx - target.x) * .12 * k : 0, ty: target ? (cy - target.y) * .12 * k : 0 }; + }; + const incoming = camera(scene, state, local), outgoing = prev ? camera(prevScene, prev, prevScene.dwell) : incoming; + const zoom = lerp(outgoing.zoom, incoming.zoom, b), tx = lerp(outgoing.tx, incoming.tx, b), ty = lerp(outgoing.ty, incoming.ty, b); ring.setAttribute('transform', `translate(${(cx + tx).toFixed(2)} ${(cy + ty).toFixed(2)}) scale(${zoom.toFixed(4)}) translate(${-cx} ${-cy})`); // caption const fadeIn = easeOut(local / 520), fadeOut = scene.dwell - local < 260 && i < scenes.length - 1 ? clamp((scene.dwell - local) / 260, 0, 1) : 1; diff --git a/Build/FormatMapRecorder/Program.cs b/Build/FormatMapRecorder/Program.cs index b5b42b02e9..ac808403c0 100644 --- a/Build/FormatMapRecorder/Program.cs +++ b/Build/FormatMapRecorder/Program.cs @@ -35,6 +35,7 @@ private static async Task Main(string[] args) { } private static async Task RecordCutsAsync(string[] args) { + ValidateArguments(args); string root = Directory.GetCurrentDirectory(); string output = Path.GetFullPath(Option(args, "--output") ?? Path.Combine(root, "Artefacts", "FormatMapVideos")); string mediaDir = Path.GetFullPath(Option(args, "--media-dir") ?? Path.Combine(root, "Build", "FormatMapMedia")); @@ -58,7 +59,9 @@ private static async Task RecordCutsAsync(string[] args) { return 0; } - string[] wanted = (Option(args, "--cut") ?? "").Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + string? selector = Option(args, "--cut"); + string[] wanted = selector is null ? [] : selector.Split(',', StringSplitOptions.TrimEntries); + if (wanted.Any(string.IsNullOrWhiteSpace)) throw new ArgumentException("--cut requires nonempty comma-separated cut names."); string[] unknown = wanted.Where(name => !all.Any(cut => string.Equals(cut.Name, name, StringComparison.OrdinalIgnoreCase))).ToArray(); if (unknown.Length > 0) { Console.Error.WriteLine("Unknown cut: " + string.Join(", ", unknown)); @@ -68,8 +71,8 @@ private static async Task RecordCutsAsync(string[] args) { // --scale=2 renders the same layout at twice the pixels (1920x1080 becomes 3840x2160); --formats replaces each cut's formats. int scale = ParseIntegerOption(args, "--scale", 1, 4) ?? 1; int? fps = ParseIntegerOption(args, "--fps", 10, 120); - string[]? formats = Option(args, "--formats")?.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); - if (formats is { Length: 0 }) throw new ArgumentException("--formats requires at least one output format."); + string[]? formats = Option(args, "--formats")?.Split(',', StringSplitOptions.TrimEntries); + if (formats is not null && formats.Any(string.IsNullOrWhiteSpace)) throw new ArgumentException("--formats requires nonempty comma-separated output formats."); cuts = cuts.Select(cut => cut with { Name = scale > 1 ? $"{cut.Name}-{scale}x" : cut.Name, Width = checked(cut.Width * scale), Height = checked(cut.Height * scale), Scale = checked(cut.Scale * scale), @@ -117,6 +120,22 @@ private static async Task RecordCutsAsync(string[] args) { return failures == 0 ? 0 : 1; } + private static void ValidateArguments(string[] args) { + string[] valueOptions = ["--output", "--cut", "--scale", "--fps", "--formats", "--still", "--cuts", "--media-dir", "--ffmpeg"]; + var seen = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (string argument in args) { + int equals = argument.IndexOf('='); + string name = equals < 0 ? argument : argument[..equals]; + if (name.Equals("--list", StringComparison.OrdinalIgnoreCase)) { + if (equals >= 0) throw new ArgumentException("--list does not accept a value."); + } else { + if (!valueOptions.Contains(name, StringComparer.OrdinalIgnoreCase)) throw new ArgumentException($"Unknown recorder argument: {name}"); + if (equals < 0 || string.IsNullOrWhiteSpace(argument[(equals + 1)..])) throw new ArgumentException($"{name} requires a value: {name}=."); + } + if (!seen.Add(name)) throw new ArgumentException($"Duplicate recorder option: {name}"); + } + } + private static int? ParseIntegerOption(string[] args, string name, int minimum, int maximum) { string? value = Option(args, name); if (value is null) return null; diff --git a/Build/Record-FormatMap.ps1 b/Build/Record-FormatMap.ps1 index 04af09e210..d885135aee 100644 --- a/Build/Record-FormatMap.ps1 +++ b/Build/Record-FormatMap.ps1 @@ -84,10 +84,10 @@ try { if ($Still.Count -eq 0) { throw 'Still requires at least one timestamp.' } $arguments += "--still=$($Still -join ',')" } - if ($Cut) { $arguments += "--cut=$($Cut -join ',')" } + if ($PSBoundParameters.ContainsKey('Cut')) { $arguments += "--cut=$($Cut -join ',')" } if ($Scale -gt 1) { $arguments += "--scale=$Scale" } if ($PSBoundParameters.ContainsKey('Fps')) { $arguments += "--fps=$Fps" } - if ($Formats) { $arguments += "--formats=$($Formats -join ',')" } + if ($PSBoundParameters.ContainsKey('Formats')) { $arguments += "--formats=$($Formats -join ',')" } if ($Output) { $arguments += "--output=$Output" } Invoke-Step 'Render clips' { dotnet run --project $recorder -c Release -- @arguments From 0f5f82a1b8c0119133fb1988a38b1ba5cb76c2a5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Przemys=C5=82aw=20K=C5=82ys?= Date: Sun, 11 Oct 2026 03:27:54 +0200 Subject: [PATCH 06/11] Validate media encoders and preserve completed output on failure --- Build/FormatMapMedia/index.html | 11 +++- Build/FormatMapRecorder/MediaEncoding.cs | 77 ++++++++++++++++++++++++ Build/FormatMapRecorder/Program.cs | 62 +++---------------- Build/Record-FormatMap.ps1 | 5 +- 4 files changed, 100 insertions(+), 55 deletions(-) create mode 100644 Build/FormatMapRecorder/MediaEncoding.cs diff --git a/Build/FormatMapMedia/index.html b/Build/FormatMapMedia/index.html index cede32af0e..09ce68c477 100644 --- a/Build/FormatMapMedia/index.html +++ b/Build/FormatMapMedia/index.html @@ -366,9 +366,18 @@ const base = { d, stroke: `url(#${id})`, 'stroke-width': e.width * (R / 330) * 1.0, opacity: e.alpha * fade }; const glow = el('path', { ...base, 'stroke-width': base['stroke-width'] * 3.4, opacity: e.alpha * fade * .11 }, dynGroup); const line = el('path', base, dynGroup); + // Reveal dashed incoming routes through a solid mask, preserving their dash pattern on every frame. + if (e.dashed && progress < 1) { + const len = line.getTotalLength(), maskId = id + '-reveal'; + const mask = el('mask', { id: maskId, maskUnits: 'userSpaceOnUse', x: -W, y: -H, width: W * 3, height: H * 3 }, defs); + el('path', { d, fill: 'none', stroke: '#fff', 'stroke-width': base['stroke-width'] * 3.4 + 2, + 'stroke-linecap': 'round', 'stroke-dasharray': `${len} ${len}`, 'stroke-dashoffset': len * (1 - progress) }, mask); + glow.setAttribute('mask', `url(#${maskId})`); + line.setAttribute('mask', `url(#${maskId})`); + } [glow, line].forEach(p => { const len = p.getTotalLength(); - if (e.dashed) { p.setAttribute('stroke-dasharray', `${6} ${7}`); p.style.clipPath = ''; if (progress < 1) { p.setAttribute('stroke-dasharray', `${len * progress} ${len}`); } } + if (e.dashed) p.setAttribute('stroke-dasharray', '6 7'); else { p.setAttribute('stroke-dasharray', `${len} ${len}`); p.setAttribute('stroke-dashoffset', String(len * (1 - progress))); } }); if (e.pulse && pulseOn && progress >= 1) { diff --git a/Build/FormatMapRecorder/MediaEncoding.cs b/Build/FormatMapRecorder/MediaEncoding.cs new file mode 100644 index 0000000000..5d5f372873 --- /dev/null +++ b/Build/FormatMapRecorder/MediaEncoding.cs @@ -0,0 +1,77 @@ +using System.Diagnostics; +using System.Globalization; + +/// Validates the selected FFmpeg encoders and stages complete cut artifacts before replacing existing output. +internal static class MediaEncoding { + internal static async Task ValidateAsync(string ffmpeg, Cut[] cuts) { + string listing = await RunAsync(ffmpeg, ["-hide_banner", "-encoders"]).ConfigureAwait(false); + var available = listing.Split('\n').Select(line => line.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries)) + .Where(parts => parts.Length >= 2 && parts[0].Length == 6 && parts[0][0] == 'V') + .Select(parts => parts[1]).ToHashSet(StringComparer.Ordinal); + var required = cuts.SelectMany(cut => cut.Formats.Select(format => { + string[] arguments = Settings(format.ToLowerInvariant(), cut).Arguments; + return arguments[Array.IndexOf(arguments, "-c:v") + 1]; + })).Append("png").Distinct(StringComparer.Ordinal); + string[] missing = required.Where(encoder => !available.Contains(encoder)).ToArray(); + if (missing.Length > 0) throw new ArgumentException("FFmpeg is missing required video encoders: " + string.Join(", ", missing)); + } + + internal static async Task EncodeAsync(Cut cut, string output, string frames, int count, string ffmpeg) { + // A unique sibling directory keeps partial artifacts away from publishable names and on the same volume. + string staging = Path.Combine(output, ".format-map-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(staging); + var artifacts = new List(); + try { + string[] input = ["-framerate", cut.Fps.ToString(CultureInfo.InvariantCulture), "-i", Path.Combine(frames, "%06d.jpg")]; + foreach (string format in cut.Formats.Select(f => f.ToLowerInvariant()).Distinct()) { + (string extension, string[] encode) = Settings(format, cut); + string name = cut.Name + extension; + await RunAsync(ffmpeg, ["-y", "-hide_banner", "-loglevel", "error", .. input, "-an", .. encode, Path.Combine(staging, name)]).ConfigureAwait(false); + artifacts.Add(name); + } + int posterFrame = Math.Clamp((int)(cut.PosterMs / 1000.0 * cut.Fps), 0, count - 1); + string poster = cut.Name + ".png"; + await RunAsync(ffmpeg, ["-y", "-hide_banner", "-loglevel", "error", "-i", Path.Combine(frames, $"{posterFrame:D6}.jpg"), "-c:v", "png", Path.Combine(staging, poster)]).ConfigureAwait(false); + artifacts.Add(poster); + // No final output is touched until every encoder and the poster have succeeded. + foreach (string name in artifacts) { + string target = Path.Combine(output, name); + File.Move(Path.Combine(staging, name), target, overwrite: true); + Console.WriteLine($" {name,-38} {new FileInfo(target).Length / 1024.0 / 1024.0,7:0.00} MB"); + } + Directory.Delete(frames, recursive: true); + } finally { + Directory.Delete(staging, recursive: true); + } + } + + /// File extension and explicit FFmpeg encoder arguments for each supported format. + internal static (string Extension, string[] Arguments) Settings(string format, Cut cut) { + string loop = $"fps={cut.GifFps},scale={cut.GifWidth}:-1:flags=lanczos"; + string fps = cut.Fps.ToString(CultureInfo.InvariantCulture); + return format switch { + "mp4" => (".mp4", ["-c:v", "libx264", "-preset", "slow", "-crf", "17", "-pix_fmt", "yuv420p", "-r", fps, "-movflags", "+faststart"]), + "h265" => (".h265.mp4", ["-c:v", "libx265", "-preset", "medium", "-crf", "20", "-pix_fmt", "yuv420p", "-tag:v", "hvc1", "-r", fps, "-movflags", "+faststart"]), + "av1" => (".av1.mp4", ["-c:v", "libsvtav1", "-preset", "6", "-crf", "30", "-pix_fmt", "yuv420p", "-r", fps, "-movflags", "+faststart"]), + "webm" => (".webm", ["-c:v", "libvpx-vp9", "-crf", "26", "-b:v", "0", "-r", fps]), + "mov" => (".mov", ["-c:v", "prores_ks", "-profile:v", "3", "-vendor", "apl0", "-pix_fmt", "yuv422p10le", "-r", fps]), + "gif" => (".gif", ["-c:v", "gif", "-vf", $"{loop},split[a][b];[a]palettegen=max_colors=128[p];[b][p]paletteuse=dither=bayer:bayer_scale=4", "-loop", "0"]), + "webp" => (".webp", ["-c:v", "libwebp_anim", "-vf", loop, "-q:v", "75", "-loop", "0"]), + "apng" => (".apng", ["-c:v", "apng", "-vf", loop, "-plays", "0"]), + _ => throw new InvalidOperationException($"Unknown format '{format}' in cut '{cut.Name}'. Use mp4, h265, av1, webm, mov, gif, webp or apng.") + }; + } + + private static async Task RunAsync(string executable, string[] arguments) { + var info = new ProcessStartInfo(executable) { RedirectStandardError = true, RedirectStandardOutput = true, UseShellExecute = false, CreateNoWindow = true }; + foreach (string argument in arguments) info.ArgumentList.Add(argument); + using Process process = Process.Start(info) ?? throw new InvalidOperationException("Could not start " + executable); + Task error = process.StandardError.ReadToEndAsync(); + Task output = process.StandardOutput.ReadToEndAsync(); + await process.WaitForExitAsync().ConfigureAwait(false); + string result = await output.ConfigureAwait(false); + string diagnostic = (await error.ConfigureAwait(false)).Trim(); + if (process.ExitCode != 0) throw new InvalidOperationException($"ffmpeg exited with {process.ExitCode}: {diagnostic}"); + return result; + } +} diff --git a/Build/FormatMapRecorder/Program.cs b/Build/FormatMapRecorder/Program.cs index ac808403c0..0d23694eb2 100644 --- a/Build/FormatMapRecorder/Program.cs +++ b/Build/FormatMapRecorder/Program.cs @@ -97,7 +97,10 @@ private static async Task RecordCutsAsync(string[] args) { Console.Error.WriteLine("ffmpeg not found. It encodes the rendered frames: install it (winget install Gyan.FFmpeg) or pass --ffmpeg=."); return 2; } - if (ffmpeg is not null) Console.WriteLine("ffmpeg: " + ffmpeg); + if (ffmpeg is not null) { + await MediaEncoding.ValidateAsync(ffmpeg, cuts).ConfigureAwait(false); + Console.WriteLine("ffmpeg: " + ffmpeg); + } Directory.CreateDirectory(output); // Own a unique invocation directory; never remove a previous run's or another recorder's frames. @@ -166,7 +169,7 @@ private static void ValidateCut(Cut cut) { if (cut.Formats is not { Length: > 0 }) throw new ArgumentException($"Cut '{cut.Name}' needs an output format."); foreach (string format in cut.Formats) { if (string.IsNullOrWhiteSpace(format)) throw new ArgumentException($"Cut '{cut.Name}' has an empty output format."); - _ = Encoding(format.ToLowerInvariant(), cut); + _ = MediaEncoding.Settings(format.ToLowerInvariant(), cut); } } /// @@ -199,8 +202,10 @@ private static async Task RecordAsync(string baseUrl, Cut cut, string output, st if (skipped.Length > 0) throw new InvalidOperationException($"The page has nothing to show for: {string.Join(", ", skipped)}. Update the scenes of cut '{cut.Name}' in Build/FormatMapMedia/cuts.json."); double duration = await page.EvaluateAsync("() => window.imoMedia.duration").ConfigureAwait(false); if (stills.Length > 0) { + if (stills.Any(at => at > duration)) throw new ArgumentException($"Cut '{cut.Name}' lasts {duration.ToString("R", CultureInfo.InvariantCulture)} ms; --still timestamps must not exceed its duration."); + foreach (double at in stills) { - await page.EvaluateAsync("(t) => window.imoMedia.render(t)", Math.Min(at, duration)).ConfigureAwait(false); + await page.EvaluateAsync("(t) => window.imoMedia.render(t)", at).ConfigureAwait(false); string still = Path.Combine(output, $"{cut.Name}-t{at.ToString("R", CultureInfo.InvariantCulture)}.png"); await page.ScreenshotAsync(new() { Type = ScreenshotType.Png, Path = still }).ConfigureAwait(false); @@ -219,48 +224,10 @@ private static async Task RecordAsync(string baseUrl, Cut cut, string output, st } if (problems.Count > 0) throw new InvalidOperationException("The media page raised an error while rendering: " + problems[0]); } - await EncodeAsync(cut, output, frames, count, ffmpeg).ConfigureAwait(false); + await MediaEncoding.EncodeAsync(cut, output, frames, count, ffmpeg).ConfigureAwait(false); Console.WriteLine($" rendered in {timer.Elapsed.TotalSeconds:0}s"); } - /// Encodes the numbered JPEG frames into every format of the cut, writes the poster frame and removes the frames. - private static async Task EncodeAsync(Cut cut, string output, string frames, int count, string ffmpeg) { - string[] input = ["-framerate", cut.Fps.ToString(CultureInfo.InvariantCulture), "-i", Path.Combine(frames, "%06d.jpg")]; - foreach (string format in cut.Formats.Select(static f => f.ToLowerInvariant()).Distinct()) { - (string extension, string[] encode) = Encoding(format, cut); - string target = Path.Combine(output, cut.Name + extension); - await RunAsync(ffmpeg, ["-y", "-hide_banner", "-loglevel", "error", .. input, "-an", .. encode, target]).ConfigureAwait(false); - Report(target); - } - string poster = Path.Combine(output, cut.Name + ".png"); - int posterFrame = Math.Clamp((int)(cut.PosterMs / 1000.0 * cut.Fps), 0, count - 1); - await RunAsync(ffmpeg, ["-y", "-hide_banner", "-loglevel", "error", "-i", Path.Combine(frames, $"{posterFrame:D6}.jpg"), poster]).ConfigureAwait(false); - Report(poster); - Directory.Delete(frames, recursive: true); - } - - /// - /// File name suffix and ffmpeg encoder arguments per output format. - /// mp4 H.264, plays everywhere (LinkedIn, X, Discord, YouTube) h265 HEVC, smaller at the same quality - /// av1 AV1, smallest, newer players webm VP9 - /// mov ProRes 422 HQ 10-bit, an editing master (very large) gif / webp / apng silent animated loops - /// - private static (string Extension, string[] Arguments) Encoding(string format, Cut cut) { - string loop = $"fps={cut.GifFps},scale={cut.GifWidth}:-1:flags=lanczos"; - string fps = cut.Fps.ToString(CultureInfo.InvariantCulture); - return format switch { - "mp4" => (".mp4", ["-c:v", "libx264", "-preset", "slow", "-crf", "17", "-pix_fmt", "yuv420p", "-r", fps, "-movflags", "+faststart"]), - "h265" => (".h265.mp4", ["-c:v", "libx265", "-preset", "medium", "-crf", "20", "-pix_fmt", "yuv420p", "-tag:v", "hvc1", "-r", fps, "-movflags", "+faststart"]), - "av1" => (".av1.mp4", ["-c:v", "libsvtav1", "-preset", "6", "-crf", "30", "-pix_fmt", "yuv420p", "-r", fps, "-movflags", "+faststart"]), - "webm" => (".webm", ["-c:v", "libvpx-vp9", "-crf", "26", "-b:v", "0", "-r", fps]), - "mov" => (".mov", ["-c:v", "prores_ks", "-profile:v", "3", "-vendor", "apl0", "-pix_fmt", "yuv422p10le", "-r", fps]), - "gif" => (".gif", ["-vf", $"{loop},split[a][b];[a]palettegen=max_colors=128[p];[b][p]paletteuse=dither=bayer:bayer_scale=4", "-loop", "0"]), - "webp" => (".webp", ["-vf", loop, "-c:v", "libwebp_anim", "-q:v", "75", "-loop", "0"]), - "apng" => (".apng", ["-vf", loop, "-plays", "0"]), - _ => throw new InvalidOperationException($"Unknown format '{format}' in cut '{cut.Name}'. Use mp4, h265, av1, webm, mov, gif, webp or apng.") - }; - } - private static void Report(string path) => Console.WriteLine($" {Path.GetFileName(path),-38} {new FileInfo(path).Length / 1024.0 / 1024.0,7:0.00} MB"); private static string? Option(string[] args, string name) => @@ -277,16 +244,7 @@ private static (string Extension, string[] Arguments) Encoding(string format, Cu return File.Exists(winget) ? winget : null; } - private static async Task RunAsync(string executable, string[] arguments) { - var info = new ProcessStartInfo(executable) { RedirectStandardError = true, RedirectStandardOutput = true, UseShellExecute = false, CreateNoWindow = true }; - foreach (string argument in arguments) info.ArgumentList.Add(argument); - using Process process = Process.Start(info) ?? throw new InvalidOperationException("Could not start " + executable); - Task error = process.StandardError.ReadToEndAsync(); - Task output = process.StandardOutput.ReadToEndAsync(); - await process.WaitForExitAsync().ConfigureAwait(false); - await output.ConfigureAwait(false); - if (process.ExitCode != 0) throw new InvalidOperationException($"ffmpeg exited with {process.ExitCode}: {(await error.ConfigureAwait(false)).Trim()}"); - } + } internal sealed record CutFile(Cut[] Cuts); diff --git a/Build/Record-FormatMap.ps1 b/Build/Record-FormatMap.ps1 index d885135aee..5b8944bd1d 100644 --- a/Build/Record-FormatMap.ps1 +++ b/Build/Record-FormatMap.ps1 @@ -7,7 +7,8 @@ 1. Regenerates the catalog data (Website/data/format_map.json) from the conversion catalog and powershell-routes.json. 2. Renders every cut in Build/FormatMapMedia/cuts.json frame by frame and encodes it with ffmpeg. The diagram (Build/FormatMapMedia/index.html) reads the generated data directly, so no website build is needed. - Clips and poster frames land in Artefacts/FormatMapVideos (not tracked). Needs ffmpeg and the shared Playwright browsers. + Clips and poster frames land in Artefacts/FormatMapVideos (not tracked). Video encoding needs ffmpeg with the selected codecs; + PNG stills need only the shared browser. Encoder availability is checked before rendering. Failed encodes preserve existing clips and posters. .PARAMETER Cut Only these cuts (names from cuts.json). Default: all. @@ -28,7 +29,7 @@ Reuse the current Website/data/format_map.json (the catalog build takes minutes; skip it when only the design or the cuts changed). .PARAMETER Still - Write PNG stills of these moments (milliseconds) instead of rendering video. Useful when changing the design. + Write PNG stills of these moments (milliseconds) instead of rendering video. Timestamps must fit the selected cuts' durations. .PARAMETER List Print the cuts and exit. From 195fd434564d0ede756585ecc86798de630df197 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Przemys=C5=82aw=20K=C5=82ys?= Date: Sun, 11 Oct 2026 05:02:36 +0200 Subject: [PATCH 07/11] fix: preserve recorded stills and blend outro transitions --- AGENTS.md | 2 +- Build/FormatMapMedia/index.html | 2 +- Build/FormatMapRecorder/MediaArtifacts.cs | 21 +++++++++++++++ Build/FormatMapRecorder/MediaEncoding.cs | 31 +++++++---------------- Build/FormatMapRecorder/Program.cs | 24 +++++++++--------- Build/Record-FormatMap.ps1 | 3 ++- 6 files changed, 46 insertions(+), 37 deletions(-) create mode 100644 Build/FormatMapRecorder/MediaArtifacts.cs diff --git a/AGENTS.md b/AGENTS.md index 08c9a80f3f..6086412eff 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -74,7 +74,7 @@ - Re-record with `./Build/Record-FormatMap.ps1`. It regenerates `Website/data/format_map.json` and renders every cut in `Build/FormatMapMedia/cuts.json` into `Artefacts/FormatMapVideos` (ignored). Useful switches: `-Cut `, `-Scale 2` (true 3840x2160), `-Formats mp4,h265,mov,gif,...`, `-SkipCatalog`, `-Still 0,2000,8000` (PNG stills of those moments, for design work), `-List`. Video encoding needs ffmpeg; stills need only the shared Playwright browsers (HtmlTinkerX). Review generated changes after regeneration; the wrapper never restores working-tree edits. No website build is involved. - The diagram draws everything as a pure function of time (`window.imoMedia.render(t)`), so the recorder steps it frame by frame at any frame rate and resolution. Keep it free of timers, transitions and hover state, and keep it vector so `-Scale 2` stays sharp. It reads only `Website/data/format_map.json`, `surfaces.json` and the site font; presentation-only short labels (CHM, MEDLINE, XPS) live in its `ALIAS` table and the data names are untouched. - It has compositions for 16:9, 1:1, 4:5 and 9:16; surface pills light only for the surfaces that really run what is shown. -- Cuts name formats and surfaces with the site tour's grammar (`intro`, `spot:DOCX`, `path:Markdown>DOCX`, `surface:powershell`, `@powershell`, `outro`). The recorder fails with the unmatched scenes when a format is renamed or loses its routes; update `cuts.json`, do not weaken the check. +- Cuts name formats and surfaces with the site tour's grammar (`intro`, `spot:DOCX`, `path:Markdown>DOCX`, `surface:powershell`, `outro`). A suffix such as `spot:XLSX@powershell` filters a format or path scene to that surface. The recorder fails with the unmatched scenes when a format is renamed or loses its routes; update `cuts.json`, do not weaken the check. - PowerShell routes come from `Build/CompatibilityCatalog/powershell-routes.json`, a reviewed route-to-cmdlet list with source evidence. Add an entry only after reading the PSWriteOffice cmdlet that performs exactly that conversion. The generator rejects unknown routes and cmdlets missing from `Website/data/apidocs/powershell/command-metadata.json`. - Do not commit rendered clips or frames. Render frames go under `EVOTEC_SCRATCH_ROOT` and are removed after a successful encode (and after a failed cut); remove superseded clips before repeating a large run. Keep the recorder outside the normal solution. ## Agent plugin and MCP Registry maintenance diff --git a/Build/FormatMapMedia/index.html b/Build/FormatMapMedia/index.html index 09ce68c477..889d915ad7 100644 --- a/Build/FormatMapMedia/index.html +++ b/Build/FormatMapMedia/index.html @@ -408,7 +408,7 @@ while (defs.firstChild) defs.removeChild(defs.firstChild); gradientSeq = 0; if (prev && b < 1) drawEdges(prev, 1e9, 1 - b, false, t); - drawEdges(state, local, 1, true, t); + drawEdges(state, local, b, true, t); if (state.focus) { const p = pos[state.focus]; const ringR = nodeEls[state.focus].r * (1.9 + .25 * Math.sin(local / 380)); diff --git a/Build/FormatMapRecorder/MediaArtifacts.cs b/Build/FormatMapRecorder/MediaArtifacts.cs new file mode 100644 index 0000000000..0d1e5d134a --- /dev/null +++ b/Build/FormatMapRecorder/MediaArtifacts.cs @@ -0,0 +1,21 @@ +/// Stages a cut's artifacts beside their final paths and publishes them after production succeeds. +internal static class MediaArtifacts { + internal static async Task PublishAsync(string output, string[] names, Func produce) { + // Sibling staging keeps replacements on the same volume, including still-only recordings. + string staging = Path.Combine(output, ".format-map-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(staging); + try { + await produce(staging).ConfigureAwait(false); + foreach (string name in names) { + if (!File.Exists(Path.Combine(staging, name))) throw new InvalidOperationException("Missing produced artifact: " + name); + } + foreach (string name in names) { + string target = Path.Combine(output, name); + File.Move(Path.Combine(staging, name), target, overwrite: true); + Console.WriteLine($" {name,-38} {new FileInfo(target).Length / 1024.0 / 1024.0,7:0.00} MB"); + } + } finally { + Directory.Delete(staging, recursive: true); + } + } +} diff --git a/Build/FormatMapRecorder/MediaEncoding.cs b/Build/FormatMapRecorder/MediaEncoding.cs index 5d5f372873..19652dee01 100644 --- a/Build/FormatMapRecorder/MediaEncoding.cs +++ b/Build/FormatMapRecorder/MediaEncoding.cs @@ -16,33 +16,20 @@ internal static async Task ValidateAsync(string ffmpeg, Cut[] cuts) { if (missing.Length > 0) throw new ArgumentException("FFmpeg is missing required video encoders: " + string.Join(", ", missing)); } - internal static async Task EncodeAsync(Cut cut, string output, string frames, int count, string ffmpeg) { - // A unique sibling directory keeps partial artifacts away from publishable names and on the same volume. - string staging = Path.Combine(output, ".format-map-" + Guid.NewGuid().ToString("N")); - Directory.CreateDirectory(staging); - var artifacts = new List(); - try { + internal static async Task EncodeAsync(Cut cut, string output, string frames, string ffmpeg) { + var formats = cut.Formats.Select(f => Settings(f.ToLowerInvariant(), cut)).DistinctBy(f => f.Extension).ToArray(); + string poster = cut.Name + ".png"; + string[] names = formats.Select(f => cut.Name + f.Extension).Append(poster).ToArray(); + await MediaArtifacts.PublishAsync(output, names, async staging => { string[] input = ["-framerate", cut.Fps.ToString(CultureInfo.InvariantCulture), "-i", Path.Combine(frames, "%06d.jpg")]; - foreach (string format in cut.Formats.Select(f => f.ToLowerInvariant()).Distinct()) { - (string extension, string[] encode) = Settings(format, cut); + foreach ((string extension, string[] encode) in formats) { string name = cut.Name + extension; await RunAsync(ffmpeg, ["-y", "-hide_banner", "-loglevel", "error", .. input, "-an", .. encode, Path.Combine(staging, name)]).ConfigureAwait(false); - artifacts.Add(name); } - int posterFrame = Math.Clamp((int)(cut.PosterMs / 1000.0 * cut.Fps), 0, count - 1); - string poster = cut.Name + ".png"; + int posterFrame = (int)(cut.PosterMs / 1000.0 * cut.Fps); await RunAsync(ffmpeg, ["-y", "-hide_banner", "-loglevel", "error", "-i", Path.Combine(frames, $"{posterFrame:D6}.jpg"), "-c:v", "png", Path.Combine(staging, poster)]).ConfigureAwait(false); - artifacts.Add(poster); - // No final output is touched until every encoder and the poster have succeeded. - foreach (string name in artifacts) { - string target = Path.Combine(output, name); - File.Move(Path.Combine(staging, name), target, overwrite: true); - Console.WriteLine($" {name,-38} {new FileInfo(target).Length / 1024.0 / 1024.0,7:0.00} MB"); - } - Directory.Delete(frames, recursive: true); - } finally { - Directory.Delete(staging, recursive: true); - } + }).ConfigureAwait(false); + Directory.Delete(frames, recursive: true); } /// File extension and explicit FFmpeg encoder arguments for each supported format. diff --git a/Build/FormatMapRecorder/Program.cs b/Build/FormatMapRecorder/Program.cs index 0d23694eb2..a82549ed22 100644 --- a/Build/FormatMapRecorder/Program.cs +++ b/Build/FormatMapRecorder/Program.cs @@ -164,8 +164,8 @@ private static void ValidateCut(Cut cut) { "16x9" => (16, 9), "1x1" => (1, 1), "4x5" => (4, 5), "9x16" => (9, 16), _ => throw new ArgumentException($"Invalid ratio for cut '{cut.Name}': use 16x9, 1x1, 4x5 or 9x16.") }; - if (cut.Width < 2 || cut.Height < 2 || (long)cut.Width * height != (long)cut.Height * width || cut.Scale < 1 || cut.Width % cut.Scale != 0 || cut.Height % cut.Scale != 0 || cut.Fps < 10 || cut.Fps > 120 || cut.GifWidth < 2 || cut.GifFps < 1 || cut.GifFps > 120 || !double.IsFinite(cut.Speed) || cut.Speed < .4 || cut.Speed > 3) - throw new ArgumentException($"Cut '{cut.Name}' has invalid dimensions, scale, frame rate or speed."); + if (cut.Width < 2 || cut.Height < 2 || (long)cut.Width * height != (long)cut.Height * width || cut.Scale < 1 || cut.Width % cut.Scale != 0 || cut.Height % cut.Scale != 0 || cut.Fps < 10 || cut.Fps > 120 || cut.GifWidth < 2 || cut.GifFps < 1 || cut.GifFps > 120 || cut.PosterMs < 0 || !double.IsFinite(cut.Speed) || cut.Speed < .4 || cut.Speed > 3) + throw new ArgumentException($"Cut '{cut.Name}' has invalid dimensions, scale, frame rate, speed or poster timestamp."); if (cut.Formats is not { Length: > 0 }) throw new ArgumentException($"Cut '{cut.Name}' needs an output format."); foreach (string format in cut.Formats) { if (string.IsNullOrWhiteSpace(format)) throw new ArgumentException($"Cut '{cut.Name}' has an empty output format."); @@ -201,16 +201,18 @@ private static async Task RecordAsync(string baseUrl, Cut cut, string output, st string[] skipped = await page.EvaluateAsync("() => window.imoMedia.skipped").ConfigureAwait(false); if (skipped.Length > 0) throw new InvalidOperationException($"The page has nothing to show for: {string.Join(", ", skipped)}. Update the scenes of cut '{cut.Name}' in Build/FormatMapMedia/cuts.json."); double duration = await page.EvaluateAsync("() => window.imoMedia.duration").ConfigureAwait(false); + if (stills.Length == 0 && cut.PosterMs > duration) throw new ArgumentException($"Cut '{cut.Name}' lasts {duration.ToString("R", CultureInfo.InvariantCulture)} ms; posterMs must not exceed its duration."); if (stills.Length > 0) { if (stills.Any(at => at > duration)) throw new ArgumentException($"Cut '{cut.Name}' lasts {duration.ToString("R", CultureInfo.InvariantCulture)} ms; --still timestamps must not exceed its duration."); - foreach (double at in stills) { - await page.EvaluateAsync("(t) => window.imoMedia.render(t)", at).ConfigureAwait(false); - string still = Path.Combine(output, $"{cut.Name}-t{at.ToString("R", CultureInfo.InvariantCulture)}.png"); - - await page.ScreenshotAsync(new() { Type = ScreenshotType.Png, Path = still }).ConfigureAwait(false); - Report(still); - } + string[] names = stills.Select(at => $"{cut.Name}-t{at.ToString("R", CultureInfo.InvariantCulture)}.png").ToArray(); + await MediaArtifacts.PublishAsync(output, names, async staging => { + for (int i = 0; i < stills.Length; i++) { + await page.EvaluateAsync("(t) => window.imoMedia.render(t)", stills[i]).ConfigureAwait(false); + await page.ScreenshotAsync(new() { Type = ScreenshotType.Png, Path = Path.Combine(staging, names[i]) }).ConfigureAwait(false); + } + if (problems.Count > 0) throw new InvalidOperationException("The media page raised an error while capturing stills: " + problems[0]); + }).ConfigureAwait(false); Console.WriteLine($" (duration {duration / 1000.0:0.0}s)"); Directory.Delete(frames, recursive: true); return; @@ -224,12 +226,10 @@ private static async Task RecordAsync(string baseUrl, Cut cut, string output, st } if (problems.Count > 0) throw new InvalidOperationException("The media page raised an error while rendering: " + problems[0]); } - await MediaEncoding.EncodeAsync(cut, output, frames, count, ffmpeg).ConfigureAwait(false); + await MediaEncoding.EncodeAsync(cut, output, frames, ffmpeg).ConfigureAwait(false); Console.WriteLine($" rendered in {timer.Elapsed.TotalSeconds:0}s"); } - private static void Report(string path) => Console.WriteLine($" {Path.GetFileName(path),-38} {new FileInfo(path).Length / 1024.0 / 1024.0,7:0.00} MB"); - private static string? Option(string[] args, string name) => args.FirstOrDefault(a => a.StartsWith(name + "=", StringComparison.OrdinalIgnoreCase))?[(name.Length + 1)..]; diff --git a/Build/Record-FormatMap.ps1 b/Build/Record-FormatMap.ps1 index 5b8944bd1d..82557af885 100644 --- a/Build/Record-FormatMap.ps1 +++ b/Build/Record-FormatMap.ps1 @@ -8,7 +8,8 @@ 2. Renders every cut in Build/FormatMapMedia/cuts.json frame by frame and encodes it with ffmpeg. The diagram (Build/FormatMapMedia/index.html) reads the generated data directly, so no website build is needed. Clips and poster frames land in Artefacts/FormatMapVideos (not tracked). Video encoding needs ffmpeg with the selected codecs; - PNG stills need only the shared browser. Encoder availability is checked before rendering. Failed encodes preserve existing clips and posters. + PNG stills need only the shared browser. Encoder availability is checked before rendering. Rendering or encoding failures preserve existing clips, posters and stills. + Custom cuts must set posterMs within their duration; poster timestamps are checked before frame capture. .PARAMETER Cut Only these cuts (names from cuts.json). Default: all. From c8a24932fe88b8288fa1fab3c903bbd0ff8fcd3a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Przemys=C5=82aw=20K=C5=82ys?= Date: Sun, 11 Oct 2026 05:59:36 +0200 Subject: [PATCH 08/11] fix: recover previous media when artifact replacement fails --- Build/FormatMapRecorder/MediaArtifacts.cs | 48 ++++++++++++++++++++++- Build/Record-FormatMap.ps1 | 1 + 2 files changed, 47 insertions(+), 2 deletions(-) diff --git a/Build/FormatMapRecorder/MediaArtifacts.cs b/Build/FormatMapRecorder/MediaArtifacts.cs index 0d1e5d134a..15eb1d204c 100644 --- a/Build/FormatMapRecorder/MediaArtifacts.cs +++ b/Build/FormatMapRecorder/MediaArtifacts.cs @@ -4,18 +4,62 @@ internal static async Task PublishAsync(string output, string[] names, Func(); + try { + foreach (string name in names) { + File.Move(Path.Combine(staging, name), Path.Combine(output, name), overwrite: true); + published.Add(name); + } + } catch (Exception publishError) { + var recoveryErrors = new List(); + foreach (string name in published.AsEnumerable().Reverse()) { + try { + string previous = Path.Combine(backup, name); + string target = Path.Combine(output, name); + if (File.Exists(previous)) File.Move(previous, target, overwrite: true); + else File.Delete(target); + } catch (Exception recoveryError) { recoveryErrors.Add(recoveryError); } + } + if (recoveryErrors.Count > 0) { + removeStaging = false; + throw new AggregateException($"Publishing and recovery failed; recovery files remain at '{staging}'.", [publishError, .. recoveryErrors]); + } + throw; + } foreach (string name in names) { string target = Path.Combine(output, name); - File.Move(Path.Combine(staging, name), target, overwrite: true); Console.WriteLine($" {name,-38} {new FileInfo(target).Length / 1024.0 / 1024.0,7:0.00} MB"); } + } catch (Exception error) { + failure = error; + throw; } finally { - Directory.Delete(staging, recursive: true); + if (removeStaging) { + try { + // Backups inherit attributes. Clear read-only only on disposable staging copies. + foreach (string file in Directory.EnumerateFiles(staging, "*", SearchOption.AllDirectories)) { + FileAttributes attributes = File.GetAttributes(file); + if ((attributes & FileAttributes.ReadOnly) != 0) File.SetAttributes(file, attributes & ~FileAttributes.ReadOnly); + } + Directory.Delete(staging, recursive: true); + } catch (Exception cleanupError) when (failure is not null) { + Console.Error.WriteLine($"Cleanup failed; staging files remain at '{staging}': {cleanupError.Message}"); + } + } } } } diff --git a/Build/Record-FormatMap.ps1 b/Build/Record-FormatMap.ps1 index 82557af885..e33aae75c0 100644 --- a/Build/Record-FormatMap.ps1 +++ b/Build/Record-FormatMap.ps1 @@ -9,6 +9,7 @@ The diagram (Build/FormatMapMedia/index.html) reads the generated data directly, so no website build is needed. Clips and poster frames land in Artefacts/FormatMapVideos (not tracked). Video encoding needs ffmpeg with the selected codecs; PNG stills need only the shared browser. Encoder availability is checked before rendering. Rendering or encoding failures preserve existing clips, posters and stills. + Failed file replacements restore previous outputs. If recovery is blocked, the error identifies the retained recovery directory. Custom cuts must set posterMs within their duration; poster timestamps are checked before frame capture. .PARAMETER Cut From 732c1ed50af04db4a53b021d89f75d18e77f1a17 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Przemys=C5=82aw=20K=C5=82ys?= Date: Sun, 11 Oct 2026 06:50:37 +0200 Subject: [PATCH 09/11] fix: validate cut schemas and preserve playback speed --- Build/FormatMapRecorder/Program.cs | 9 +++++++-- Build/Record-FormatMap.ps1 | 1 + 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/Build/FormatMapRecorder/Program.cs b/Build/FormatMapRecorder/Program.cs index a82549ed22..66aa2c219f 100644 --- a/Build/FormatMapRecorder/Program.cs +++ b/Build/FormatMapRecorder/Program.cs @@ -23,7 +23,11 @@ /// Frames are written under EVOTEC_SCRATCH_ROOT (or the output folder) and removed after a successful encode. /// internal static class Program { - private static readonly JsonSerializerOptions JsonOptions = new() { PropertyNameCaseInsensitive = true, ReadCommentHandling = JsonCommentHandling.Skip }; + private static readonly JsonSerializerOptions JsonOptions = new() { + PropertyNameCaseInsensitive = true, ReadCommentHandling = JsonCommentHandling.Skip, + UnmappedMemberHandling = System.Text.Json.Serialization.JsonUnmappedMemberHandling.Disallow, + AllowDuplicateProperties = false, RespectRequiredConstructorParameters = true + }; private static async Task Main(string[] args) { try { @@ -179,7 +183,8 @@ private static void ValidateCut(Cut cut) { private static async Task RecordAsync(string baseUrl, Cut cut, string output, string frames, string ffmpeg, double[] stills) { var query = new List { "ratio=" + cut.Ratio }; if (cut.Scenes is not null) query.Add("scenes=" + Uri.EscapeDataString(cut.Scenes)); - if (Math.Abs(cut.Speed - 1) > 0.001) query.Add("speed=" + cut.Speed.ToString("0.##", CultureInfo.InvariantCulture)); + if (cut.Speed != 1) query.Add("speed=" + cut.Speed.ToString("R", CultureInfo.InvariantCulture)); + string url = $"{baseUrl}/index.html?{string.Join('&', query)}"; Console.WriteLine($"[{cut.Name}] rendering media {cut.Width}x{cut.Height} at {cut.Fps} fps ({cut.Scale}x) {url}"); diff --git a/Build/Record-FormatMap.ps1 b/Build/Record-FormatMap.ps1 index e33aae75c0..219fceff12 100644 --- a/Build/Record-FormatMap.ps1 +++ b/Build/Record-FormatMap.ps1 @@ -11,6 +11,7 @@ PNG stills need only the shared browser. Encoder availability is checked before rendering. Rendering or encoding failures preserve existing clips, posters and stills. Failed file replacements restore previous outputs. If recovery is blocked, the error identifies the retained recovery directory. Custom cuts must set posterMs within their duration; poster timestamps are checked before frame capture. + Cut JSON rejects unknown or duplicate properties and missing required fields; configured playback speed is preserved without rounding. .PARAMETER Cut Only these cuts (names from cuts.json). Default: all. From 70d3cfb4632b93f582b61c6327d71a2c90bb0cbb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Przemys=C5=82aw=20K=C5=82ys?= Date: Sun, 11 Oct 2026 09:18:29 +0200 Subject: [PATCH 10/11] Add documented social video presets and export workflow --- AGENTS.md | 2 + Build/FormatMapMedia/social-cuts.json | 52 ++++++++++ Build/Record-FormatMap.ps1 | 18 +++- Docs/README.md | 1 + Docs/officeimo.format-map-media.md | 144 ++++++++++++++++++++++++++ 5 files changed, 212 insertions(+), 5 deletions(-) create mode 100644 Build/FormatMapMedia/social-cuts.json create mode 100644 Docs/officeimo.format-map-media.md diff --git a/AGENTS.md b/AGENTS.md index 6086412eff..78da246c94 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -69,6 +69,8 @@ ## Format map recordings +Contributor usage and platform export presets are documented in [Docs/officeimo.format-map-media.md](Docs/officeimo.format-map-media.md). Keep that guide aligned with the recorder and cut files. + `Build/FormatMapMedia/index.html` is a purpose-built diagram of the format map for video and social media; `Build/FormatMapRecorder` renders it into clips. Nothing in a clip is hand-made, so re-record from the data whenever the catalog, the PowerShell mapping or the design changes. The website's own format map (`Website/themes/officeimo/partials/sections/format-map.html`, `Website/static/js/format-map.js`) is separate and is not used for recordings. - Re-record with `./Build/Record-FormatMap.ps1`. It regenerates `Website/data/format_map.json` and renders every cut in `Build/FormatMapMedia/cuts.json` into `Artefacts/FormatMapVideos` (ignored). Useful switches: `-Cut `, `-Scale 2` (true 3840x2160), `-Formats mp4,h265,mov,gif,...`, `-SkipCatalog`, `-Still 0,2000,8000` (PNG stills of those moments, for design work), `-List`. Video encoding needs ffmpeg; stills need only the shared Playwright browsers (HtmlTinkerX). Review generated changes after regeneration; the wrapper never restores working-tree edits. No website build is involved. diff --git a/Build/FormatMapMedia/social-cuts.json b/Build/FormatMapMedia/social-cuts.json new file mode 100644 index 0000000000..437784155b --- /dev/null +++ b/Build/FormatMapMedia/social-cuts.json @@ -0,0 +1,52 @@ +{ + "cuts": [ + { + "name": "x-twitter-feed", + "ratio": "1x1", + "width": 1080, + "height": 1080, + "scenes": "intro,spot:DOCX,path:DOCX>Markdown,outro", + "formats": [ + "mp4" + ], + "posterMs": 6800, + "fps": 30 + }, + { + "name": "linkedin-feed", + "ratio": "4x5", + "width": 1080, + "height": 1350, + "scenes": "intro,spot:XLSX,path:XLSX>PDF,surface:browser,outro", + "formats": [ + "mp4" + ], + "posterMs": 7000, + "fps": 30 + }, + { + "name": "youtube-landscape", + "ratio": "16x9", + "width": 1920, + "height": 1080, + "scenes": "intro,spot:DOCX,spot:XLSX,path:DOCX>Markdown,path:XLSX>PDF,surface:browser,surface:powershell,outro", + "formats": [ + "mp4" + ], + "posterMs": 6800, + "fps": 30 + }, + { + "name": "youtube-shorts", + "ratio": "9x16", + "width": 1080, + "height": 1920, + "scenes": "intro,spot:XLSX,path:XLSX>PDF,surface:powershell,outro", + "formats": [ + "mp4" + ], + "posterMs": 7000, + "fps": 30 + } + ] +} diff --git a/Build/Record-FormatMap.ps1 b/Build/Record-FormatMap.ps1 index 219fceff12..ebbab6e958 100644 --- a/Build/Record-FormatMap.ps1 +++ b/Build/Record-FormatMap.ps1 @@ -5,7 +5,7 @@ .DESCRIPTION Runs two steps in order and stops at the first failure: 1. Regenerates the catalog data (Website/data/format_map.json) from the conversion catalog and powershell-routes.json. - 2. Renders every cut in Build/FormatMapMedia/cuts.json frame by frame and encodes it with ffmpeg. + 2. Renders the selected cuts frame by frame and encodes them with ffmpeg (default recipe: Build/FormatMapMedia/cuts.json). The diagram (Build/FormatMapMedia/index.html) reads the generated data directly, so no website build is needed. Clips and poster frames land in Artefacts/FormatMapVideos (not tracked). Video encoding needs ffmpeg with the selected codecs; PNG stills need only the shared browser. Encoder availability is checked before rendering. Rendering or encoding failures preserve existing clips, posters and stills. @@ -13,8 +13,11 @@ Custom cuts must set posterMs within their duration; poster timestamps are checked before frame capture. Cut JSON rejects unknown or duplicate properties and missing required fields; configured playback speed is preserved without rounding. +.PARAMETER CutsFile + Cut JSON file. Default: Build/FormatMapMedia/cuts.json. Use Build/FormatMapMedia/social-cuts.json for platform exports. + .PARAMETER Cut - Only these cuts (names from cuts.json). Default: all. + Only these cuts (names from the selected cut file). Default: all. .PARAMETER Scale 2 renders 1920x1080 as true 3840x2160 (the diagram is vector, so text and lines stay sharp; about three times slower). @@ -39,6 +42,7 @@ .EXAMPLE ./Build/Record-FormatMap.ps1 + ./Build/Record-FormatMap.ps1 -CutsFile Build/FormatMapMedia/social-cuts.json ./Build/Record-FormatMap.ps1 -Cut media-9x16 -SkipCatalog ./Build/Record-FormatMap.ps1 -Cut media-16x9 -Scale 2 -SkipCatalog ./Build/Record-FormatMap.ps1 -Cut media-9x16 -Still 2000,8000 -SkipCatalog @@ -56,7 +60,9 @@ param( [switch] $SkipCatalog, [ValidateRange(0, [int]::MaxValue)] [int[]] $Still, - [switch] $List + [switch] $List, + [ValidateNotNullOrEmpty()] + [string] $CutsFile ) $ErrorActionPreference = 'Stop' @@ -72,8 +78,10 @@ function Invoke-Step { Push-Location $repository try { + $cutArguments = @() + if ($PSBoundParameters.ContainsKey('CutsFile')) { $cutArguments += "--cuts=$CutsFile" } if ($List) { - dotnet run --project $recorder -c Release -- --list + dotnet run --project $recorder -c Release -- @cutArguments --list exit $LASTEXITCODE } @@ -83,7 +91,7 @@ try { } } - $arguments = @() + $arguments = @($cutArguments) if ($PSBoundParameters.ContainsKey('Still')) { if ($Still.Count -eq 0) { throw 'Still requires at least one timestamp.' } $arguments += "--still=$($Still -join ',')" diff --git a/Docs/README.md b/Docs/README.md index 45ac46611c..6adcda65c4 100644 --- a/Docs/README.md +++ b/Docs/README.md @@ -15,6 +15,7 @@ Use this index to find package guides, cross-package contracts, generated eviden - [Project document API](../OfficeIMO.Project/README.md) and [operation matrix](../OfficeIMO.Project/SUPPORT.md) — typed/fluent XML, native MPP/MPT, MPX, advanced calculation, portable reports, mapped data exchange, and producer-specific limits. - [GitHub Releases](https://github.com/EvotecIT/OfficeIMO/releases) — release notes, version history, and downloadable artifacts. - [Website guide](officeimo.website.md) — local build and publication workflow. +- [Format-map videos and social exports](officeimo.format-map-media.md) — recorder commands, output formats, platform presets and updating scenes as the catalog grows. - [Website search and answer-engine operations](officeimo.website-seo-geo-operations.md) — publication and discovery checks. - [CI and test strategy](officeimo.ci-test-strategy.md) — test ownership and validation lanes. - [Native OCR quality corpus](../OfficeIMO.TestAssets/OcrQuality/README.md) — independently labelled forms and scans, bounded retries, confidence false passes, source hashes, and platform scorecards. diff --git a/Docs/officeimo.format-map-media.md b/Docs/officeimo.format-map-media.md new file mode 100644 index 0000000000..736ee097e7 --- /dev/null +++ b/Docs/officeimo.format-map-media.md @@ -0,0 +1,144 @@ +# Format-map videos and social exports + +The format-map recorder turns OfficeIMO's generated conversion catalog into animated clips and PNG stills. It includes landscape, square, 4:5 and vertical compositions with format spotlights, conversion paths, surface badges and an OfficeIMO outro. Clips are silent: audio, narration and subtitle tracks are not generated. + +This is repository tooling for producing showcase media. It is separate from document conversion and from the interactive website map. Generated videos are local artifacts, not files installed with an OfficeIMO package. + +## Record a clip + +Run these commands from the repository root. Use the .NET SDK selected by `global.json`, PowerShell and the shared HtmlTinkerX browser. Video encoding also needs ffmpeg on `PATH`, including the selected encoders and the PNG encoder used for posters. PNG still capture does not need ffmpeg. + +```powershell +# Inspect the standard compositions without recording +./Build/Record-FormatMap.ps1 -List + +# Refresh the catalog and render the standard cuts +./Build/Record-FormatMap.ps1 + +# Reuse the catalog while changing presentation or timing +./Build/Record-FormatMap.ps1 -Cut media-9x16 -Formats mp4 -Fps 30 -SkipCatalog + +# Render the landscape composition at true 3840 x 2160 +./Build/Record-FormatMap.ps1 -Cut media-16x9 -Scale 2 -Formats mp4 -SkipCatalog + +# Capture exact moments, including the opening frame, as PNGs +./Build/Record-FormatMap.ps1 -Cut media-16x9 -Still 0,2000,6800 -SkipCatalog +``` + +The default output directory is `Artefacts/FormatMapVideos`. Set `-Output` to choose another directory. Each video cut produces a clip and a PNG poster with the same base name. Still filenames include their timestamp, for example `media-16x9-t2000.png`. + +The standard `cuts.json` includes MP4 for all four compositions and GIF for the square composition. `-Formats` replaces a cut's format selection; it does not add to it. + +## Export for X/Twitter, LinkedIn and YouTube + +[Social presets](../Build/FormatMapMedia/social-cuts.json) provide four named, silent H.264 MP4 exports at 30 fps. Each composition is rendered at its native dimensions; vertical output is not cropped from the landscape clip. + +| Preset | Intended placement | Resolution | Aspect ratio | +| --- | --- | --- | --- | +| `x-twitter-feed` | X/Twitter feed | 1080 x 1080 | 1:1 | +| `linkedin-feed` | LinkedIn organic feed | 1080 x 1350 | 4:5 | +| `youtube-landscape` | YouTube standard video | 1920 x 1080 | 16:9 | +| `youtube-shorts` | YouTube Shorts | 1080 x 1920 | 9:16 | + +These are editorial export choices within the documented upload formats, not a promise that one size performs best for every audience. Square keeps the X composition compact; 4:5 gives the LinkedIn composition more vertical room. YouTube's standard desktop ratio is 16:9; the vertical preset fills a Shorts frame. + +```powershell +# List the platform presets +./Build/Record-FormatMap.ps1 -CutsFile Build/FormatMapMedia/social-cuts.json -List + +# Refresh the catalog and render all four upload files and posters +./Build/Record-FormatMap.ps1 -CutsFile Build/FormatMapMedia/social-cuts.json -Output Artefacts/FormatMapSocial + +# Re-render one preset after a design change +./Build/Record-FormatMap.ps1 -CutsFile Build/FormatMapMedia/social-cuts.json -Cut linkedin-feed -Output Artefacts/FormatMapSocial -SkipCatalog +``` + +Upload the resulting `.mp4` file, rather than a ZIP or poster. The PNG is available for a thumbnail or a companion image; platform controls determine whether a custom thumbnail is accepted. The commands prepare local files and do not upload or publish them. + +Platform rules change and vary by placement or account. Check the linked sources before a campaign, especially when changing duration, resolution or frame rate: + +- [X video uploads](https://help.x.com/en/using-x/x-videos): non-Premium uploads are limited to 140 seconds and 512 MB. Keep the short feed preset below both limits. +- [LinkedIn Page video guidelines](https://www.linkedin.com/help/linkedin/answer/a1311816): MP4/H.264, 10-60 fps and aspect ratios from 1:2.4 to 2.4:1 are supported. [LinkedIn advertising specifications](https://business.linkedin.com/advertise/ads/sponsored-content/video-ads/specs) also describe 1080 x 1350 at 4:5, but ads have their own rules; the supplied preset targets organic posts. +- [YouTube upload encoding](https://support.google.com/youtube/answer/1722171): MP4, H.264, progressive video, 4:2:0 and fast-start metadata are supported upload settings. The recorder uses quality-based variable bitrate, not a fixed platform bitrate target. +- [YouTube Shorts classification](https://support.google.com/youtube/answer/15424877): square or vertical videos up to three minutes are classified as Shorts under the current rules. Use the landscape preset for a standard watch-page video. + +## Output formats and options + +| `-Formats` value | Encoding | File extension | +| --- | --- | --- | +| `mp4` | H.264, YUV 4:2:0, fast start | `.mp4` | +| `h265` | HEVC/H.265 in MP4 | `.h265.mp4` | +| `av1` | AV1 in MP4 | `.av1.mp4` | +| `webm` | VP9 | `.webm` | +| `mov` | ProRes 422 HQ | `.mov` | +| `gif` | Palette-based animated GIF | `.gif` | +| `webp` | Animated WebP | `.webp` | +| `apng` | Animated PNG | `.apng` | + +```powershell +./Build/Record-FormatMap.ps1 -Cut media-1x1 -Formats mp4,gif,webp -SkipCatalog +``` + +`-Cut` accepts one or more cut names. `-CutsFile` selects the recipe file. `-Fps` overrides video capture at 10-120 fps; the default is 30. `-Scale` accepts 1-4 and multiplies pixel dimensions while retaining the composition. Higher resolutions and frame rates take more time and storage. GIF, animated WebP and APNG have separate `gifWidth` and `gifFps` recipe settings, shared by those three animated image formats. + +Use the .NET entry point for custom media directories, an explicit ffmpeg path or fractional still timestamps: + +```powershell +dotnet run --project Build/FormatMapRecorder -c Release -- --cuts=Build/FormatMapMedia/social-cuts.json --cut=x-twitter-feed --still=0,100.1,100.9 +``` + +The CLI also accepts `--output`, `--scale`, `--fps`, `--formats`, `--media-dir`, `--ffmpeg` and `--list`. Value options use `--name=value`. Unknown, repeated or empty options fail rather than silently changing the requested render. + +## Change the story as capabilities grow + +[The standard cuts](../Build/FormatMapMedia/cuts.json) and [social cuts](../Build/FormatMapMedia/social-cuts.json) own scene selection and export settings. Edit a maintained preset or copy a cut into another JSON file and pass it with `-CutsFile`. Required fields are `name`, `ratio`, `width`, `height` and `formats`. + +```json +{ + "cuts": [ + { + "name": "spreadsheet-showcase", + "ratio": "4x5", + "width": 1080, + "height": 1350, + "formats": ["mp4"], + "fps": 30, + "speed": 1.1, + "scenes": "intro,spot:XLSX,path:XLSX>PDF,surface:powershell,outro", + "posterMs": 6000 + } + ] +} +``` + +`scenes` is a comma-separated sequence: + +| Scene | Shows | +| --- | --- | +| `intro` | The catalog's format/route network and introduction | +| `spot:DOCX` | A format and its connected routes | +| `path:DOCX>Markdown` | A conversion path | +| `surface:powershell` | Routes available on a surface | +| `outro` | The OfficeIMO closing composition | + +Append a surface qualifier, such as `spot:XLSX@powershell`, to filter a spotlight or path. Surface identifiers are `studio`, `browser`, `cli`, `dotnet` and `powershell`; use the catalog's exact format names. Omitting `scenes` plays the full generated sequence. An empty scene list, unknown name or scene without matching routes is an error. + +`speed` accepts 0.4-3; higher values shorten the clip. `posterMs` selects a point in the resulting playback timeline and must fit its duration. Optional defaults are `speed: 1`, `posterMs: 2500`, `fps: 30`, `gifWidth: 640`, `gifFps: 12` and `scale: 1`. Ratios are `16x9`, `1x1`, `4x5` and `9x16`; dimensions must match the ratio. JSON field names are case-insensitive and comments are allowed, but unknown or duplicate fields and missing required fields are rejected. + +For a capability update: + +1. Update the owning conversion catalog or the reviewed PowerShell route mapping. Do not hand-edit generated `Website/data/format_map.json`. +2. Run the wrapper without `-SkipCatalog` to regenerate the route data. Review its generated changes; the wrapper preserves working-tree edits. +3. Update the scene selections in each relevant cut. Counts and surface badges come from the regenerated data; they do not need handwritten numbers. +4. Capture opening, spotlight, path and outro stills with `-Still` before a full render. Check long format names and captions in each selected composition. +5. Render at the intended frame rate, inspect the clips and posters, and verify file size/duration against the destination's current upload rules. + +To change typography, camera movement, captions or composition, edit [the media page](../Build/FormatMapMedia/index.html). Its `ALIAS` table shortens presentation labels without renaming catalog formats. Changes to the interactive website map do not automatically change this media page. + +## Rendering and output boundaries + +The page exposes `window.imoMedia.render(milliseconds)`: drawing is a function of time, rather than a real-time screen recording. The recorder serves the page, generated catalog, surface data and site font on a private loopback address, then captures each timestamp through HtmlTinkerX. No website build is required. Timing is repeatable; identical bytes across different operating systems or browser versions are not guaranteed. + +Requested encoders and codec dimensions are checked before rendering. Stills and posters must fit the cut duration. Rendering and encoding finish in unique staging directories before replacing final artifacts. Failed replacements attempt to restore previous outputs; blocked recovery reports where the retained files live. This does not provide crash consistency or concurrent-writer isolation. Use separate output directories for concurrent runs. + +Windows capture and encoding are locally qualified. macOS/Linux recorder execution, Firefox/Safari rendering and the platforms' upload/transcoding results require separate validation. Inspect the actual upload preview before publishing. The supplied silent clips do not claim spoken captions or an audio track. From 8f1f19d28a519f3b652baf437d3c4625fde7741a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Przemys=C5=82aw=20K=C5=82ys?= Date: Sun, 11 Oct 2026 09:32:38 +0200 Subject: [PATCH 11/11] Add native 4K YouTube preset and platform export guidance --- Build/FormatMapMedia/social-cuts.json | 13 +++++++++++++ Docs/officeimo.format-map-media.md | 17 ++++++++++++----- 2 files changed, 25 insertions(+), 5 deletions(-) diff --git a/Build/FormatMapMedia/social-cuts.json b/Build/FormatMapMedia/social-cuts.json index 437784155b..f85f9a0d2b 100644 --- a/Build/FormatMapMedia/social-cuts.json +++ b/Build/FormatMapMedia/social-cuts.json @@ -36,6 +36,19 @@ "posterMs": 6800, "fps": 30 }, + { + "name": "youtube-landscape-4k", + "ratio": "16x9", + "width": 3840, + "height": 2160, + "scenes": "intro,spot:DOCX,spot:XLSX,path:DOCX>Markdown,path:XLSX>PDF,surface:browser,surface:powershell,outro", + "formats": [ + "mp4" + ], + "posterMs": 6800, + "fps": 30, + "scale": 2 + }, { "name": "youtube-shorts", "ratio": "9x16", diff --git a/Docs/officeimo.format-map-media.md b/Docs/officeimo.format-map-media.md index 736ee097e7..6a4cb04b66 100644 --- a/Docs/officeimo.format-map-media.md +++ b/Docs/officeimo.format-map-media.md @@ -31,24 +31,30 @@ The standard `cuts.json` includes MP4 for all four compositions and GIF for the ## Export for X/Twitter, LinkedIn and YouTube -[Social presets](../Build/FormatMapMedia/social-cuts.json) provide four named, silent H.264 MP4 exports at 30 fps. Each composition is rendered at its native dimensions; vertical output is not cropped from the landscape clip. +[Social presets](../Build/FormatMapMedia/social-cuts.json) provide five named, silent H.264 MP4 exports at 30 fps, including both 4K and 1080p YouTube landscape files. Each composition is rendered at its native dimensions; vertical output is not cropped from the landscape clip. | Preset | Intended placement | Resolution | Aspect ratio | | --- | --- | --- | --- | | `x-twitter-feed` | X/Twitter feed | 1080 x 1080 | 1:1 | | `linkedin-feed` | LinkedIn organic feed | 1080 x 1350 | 4:5 | -| `youtube-landscape` | YouTube standard video | 1920 x 1080 | 16:9 | +| `youtube-landscape-4k` | YouTube standard video: preferred upload | 3840 x 2160 | 16:9 | +| `youtube-landscape` | YouTube standard video: smaller 1080p alternative | 1920 x 1080 | 16:9 | | `youtube-shorts` | YouTube Shorts | 1080 x 1920 | 9:16 | -These are editorial export choices within the documented upload formats, not a promise that one size performs best for every audience. Square keeps the X composition compact; 4:5 gives the LinkedIn composition more vertical room. YouTube's standard desktop ratio is 16:9; the vertical preset fills a Shorts frame. +Use the 4K landscape file for a standard YouTube upload when preserving the diagram's fine lines and small labels matters. Its recipe sets `scale: 2`, so the page draws the same landscape composition at twice the pixel dimensions; it does not enlarge an existing 1080p video. Keep the 1080p landscape file as a smaller alternative. Both play the same scenes at the same speed. + +For X use the square export, for LinkedIn use the 4:5 export, and for Shorts use the 1080 x 1920 vertical export. These are editorial choices within the documented upload formats, not a promise that one size performs best for every audience. Do not apply `-Scale 2` to the entire social recipe: the 4K preset already includes its scale, and the other presets retain their platform dimensions. ```powershell # List the platform presets ./Build/Record-FormatMap.ps1 -CutsFile Build/FormatMapMedia/social-cuts.json -List -# Refresh the catalog and render all four upload files and posters +# Refresh the catalog and render all five files and their posters ./Build/Record-FormatMap.ps1 -CutsFile Build/FormatMapMedia/social-cuts.json -Output Artefacts/FormatMapSocial +# Render only the preferred YouTube upload (already configured for native 4K) +./Build/Record-FormatMap.ps1 -CutsFile Build/FormatMapMedia/social-cuts.json -Cut youtube-landscape-4k -Output Artefacts/FormatMapSocial -SkipCatalog + # Re-render one preset after a design change ./Build/Record-FormatMap.ps1 -CutsFile Build/FormatMapMedia/social-cuts.json -Cut linkedin-feed -Output Artefacts/FormatMapSocial -SkipCatalog ``` @@ -59,8 +65,9 @@ Platform rules change and vary by placement or account. Check the linked sources - [X video uploads](https://help.x.com/en/using-x/x-videos): non-Premium uploads are limited to 140 seconds and 512 MB. Keep the short feed preset below both limits. - [LinkedIn Page video guidelines](https://www.linkedin.com/help/linkedin/answer/a1311816): MP4/H.264, 10-60 fps and aspect ratios from 1:2.4 to 2.4:1 are supported. [LinkedIn advertising specifications](https://business.linkedin.com/advertise/ads/sponsored-content/video-ads/specs) also describe 1080 x 1350 at 4:5, but ads have their own rules; the supplied preset targets organic posts. -- [YouTube upload encoding](https://support.google.com/youtube/answer/1722171): MP4, H.264, progressive video, 4:2:0 and fast-start metadata are supported upload settings. The recorder uses quality-based variable bitrate, not a fixed platform bitrate target. +- [YouTube 4K guidance](https://support.google.com/youtube/answer/16597617?hl=en) supports recording and exporting at 3840 x 2160. [YouTube upload encoding](https://support.google.com/youtube/answer/1722171): MP4, H.264, progressive video, 4:2:0 and fast-start metadata are supported upload settings. The recorder uses quality-based variable bitrate, not a fixed platform bitrate target. Allow [high-resolution processing](https://support.google.com/youtube/answer/71674?hl=en) to finish before assessing the uploaded video's quality. - [YouTube Shorts classification](https://support.google.com/youtube/answer/15424877): square or vertical videos up to three minutes are classified as Shorts under the current rules. Use the landscape preset for a standard watch-page video. +- [YouTube Shorts creation guidance](https://support.google.com/youtube/answer/10059070?hl=en-GB) specifies a maximum upload resolution of 1080p for that workflow; the Shorts preset stays at 1080 x 1920. LinkedIn's Page guidelines list a maximum of 4096 x 2304, so doubling the 4:5 preset to 2160 x 2700 would exceed the listed height. ## Output formats and options