diff --git a/AGENTS.md b/AGENTS.md index 020b816153..78da246c94 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -67,6 +67,18 @@ - 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 + +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. +- 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`, `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/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..889d915ad7 --- /dev/null +++ b/Build/FormatMapMedia/index.html @@ -0,0 +1,484 @@ + + + + + +OfficeIMO format map: media + + + + +
+ + OfficeIMOofficeimo.com +
+

+ + + diff --git a/Build/FormatMapMedia/social-cuts.json b/Build/FormatMapMedia/social-cuts.json new file mode 100644 index 0000000000..f85f9a0d2b --- /dev/null +++ b/Build/FormatMapMedia/social-cuts.json @@ -0,0 +1,65 @@ +{ + "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-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", + "width": 1080, + "height": 1920, + "scenes": "intro,spot:XLSX,path:XLSX>PDF,surface:powershell,outro", + "formats": [ + "mp4" + ], + "posterMs": 7000, + "fps": 30 + } + ] +} diff --git a/Build/FormatMapRecorder/MediaArtifacts.cs b/Build/FormatMapRecorder/MediaArtifacts.cs new file mode 100644 index 0000000000..15eb1d204c --- /dev/null +++ b/Build/FormatMapRecorder/MediaArtifacts.cs @@ -0,0 +1,65 @@ +/// 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); + bool removeStaging = true; + Exception? failure = null; + 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); + } + // Copy all previous outputs before promotion; a locked target may reject a later move. + string backup = Path.Combine(staging, "previous"); + Directory.CreateDirectory(backup); + foreach (string name in names) { + string target = Path.Combine(output, name); + if (File.Exists(target)) File.Copy(target, Path.Combine(backup, name)); + } + var published = new List(); + 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); + Console.WriteLine($" {name,-38} {new FileInfo(target).Length / 1024.0 / 1024.0,7:0.00} MB"); + } + } catch (Exception error) { + failure = error; + throw; + } finally { + 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/FormatMapRecorder/MediaEncoding.cs b/Build/FormatMapRecorder/MediaEncoding.cs new file mode 100644 index 0000000000..19652dee01 --- /dev/null +++ b/Build/FormatMapRecorder/MediaEncoding.cs @@ -0,0 +1,64 @@ +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, 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 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); + } + 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); + }).ConfigureAwait(false); + Directory.Delete(frames, 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/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..66aa2c219f --- /dev/null +++ b/Build/FormatMapRecorder/Program.cs @@ -0,0 +1,336 @@ +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, + UnmappedMemberHandling = System.Text.Json.Serialization.JsonUnmappedMemberHandling.Disallow, + AllowDuplicateProperties = false, RespectRequiredConstructorParameters = true + }; + + 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) { + 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")); + 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 (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)"}"); + return 0; + } + + 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)); + 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 = ParseIntegerOption(args, "--scale", 1, 4) ?? 1; + int? fps = ParseIntegerOption(args, "--fps", 10, 120); + 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), + Fps = fps ?? cut.Fps, + Formats = formats is { Length: > 0 } ? formats : cut.Formats + }).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) { + 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) { + 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. + 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); + } 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; + } + + 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; + 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 || 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."); + _ = MediaEncoding.Settings(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. + /// + 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 (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}"); + + 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 + }; + 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 && 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."); + + 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; + } + 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 MediaEncoding.EncodeAsync(cut, output, frames, ffmpeg).ConfigureAwait(false); + Console.WriteLine($" rendered in {timer.Elapsed.TotalSeconds:0}s"); + } + + 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; + } + + +} + +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..ebbab6e958 --- /dev/null +++ b/Build/Record-FormatMap.ps1 @@ -0,0 +1,110 @@ +<# +.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 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. + 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 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 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). + +.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. Timestamps must fit the selected cuts' durations. + +.PARAMETER List + Print the cuts and exit. + +.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 + ./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, + [ValidateRange(0, [int]::MaxValue)] + [int[]] $Still, + [switch] $List, + [ValidateNotNullOrEmpty()] + [string] $CutsFile +) + +$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)." } +} + +Push-Location $repository +try { + $cutArguments = @() + if ($PSBoundParameters.ContainsKey('CutsFile')) { $cutArguments += "--cuts=$CutsFile" } + if ($List) { + dotnet run --project $recorder -c Release -- @cutArguments --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 + } + } + + $arguments = @($cutArguments) + if ($PSBoundParameters.ContainsKey('Still')) { + if ($Still.Count -eq 0) { throw 'Still requires at least one timestamp.' } + $arguments += "--still=$($Still -join ',')" + } + if ($PSBoundParameters.ContainsKey('Cut')) { $arguments += "--cut=$($Cut -join ',')" } + if ($Scale -gt 1) { $arguments += "--scale=$Scale" } + if ($PSBoundParameters.ContainsKey('Fps')) { $arguments += "--fps=$Fps" } + if ($PSBoundParameters.ContainsKey('Formats')) { $arguments += "--formats=$($Formats -join ',')" } + if ($Output) { $arguments += "--output=$Output" } + Invoke-Step 'Render clips' { + dotnet run --project $recorder -c Release -- @arguments + } +} +finally { + Pop-Location +} 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..6a4cb04b66 --- /dev/null +++ b/Docs/officeimo.format-map-media.md @@ -0,0 +1,151 @@ +# 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 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-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 | + +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 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 +``` + +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 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 + +| `-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.