Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <name>`, `-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


Expand Down
41 changes: 41 additions & 0 deletions Build/FormatMapMedia/cuts.json
Original file line number Diff line number Diff line change
@@ -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
}
]
}
484 changes: 484 additions & 0 deletions Build/FormatMapMedia/index.html

Large diffs are not rendered by default.

65 changes: 65 additions & 0 deletions Build/FormatMapMedia/social-cuts.json
Original file line number Diff line number Diff line change
@@ -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
}
]
}
65 changes: 65 additions & 0 deletions Build/FormatMapRecorder/MediaArtifacts.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
/// <summary>Stages a cut's artifacts beside their final paths and publishes them after production succeeds.</summary>
internal static class MediaArtifacts {
internal static async Task PublishAsync(string output, string[] names, Func<string, Task> 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<string>();
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<Exception>();
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");
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
} 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}");
}
}
}
}
}
64 changes: 64 additions & 0 deletions Build/FormatMapRecorder/MediaEncoding.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
using System.Diagnostics;
using System.Globalization;

/// <summary>Validates the selected FFmpeg encoders and stages complete cut artifacts before replacing existing output.</summary>
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);
}

/// <summary>File extension and explicit FFmpeg encoder arguments for each supported format.</summary>
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<string> 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<string> error = process.StandardError.ReadToEndAsync();
Task<string> 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;
}
}
12 changes: 12 additions & 0 deletions Build/FormatMapRecorder/OfficeIMO.FormatMapRecorder.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="HtmlTinkerX" Version="3.0.3" />
</ItemGroup>
</Project>
Loading
Loading