Repository navigation
Add format-map video recorder, social presets and export guide #3034
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
16 commits
Select commit
Hold shift + click to select a range
ae7e2d5
Add a purpose-built media diagram for format map recordings, and its …
PrzemyslawKlys f7c2984
Validate media recorder inputs and fix compact compositions
PrzemyslawKlys 7ae1d3e
Merge commit 'd5de2bbfb9' into HEAD
PrzemyslawKlys 0f6b984
Validate media scene grammar and preserve capture identities
PrzemyslawKlys 35e96fa
Merge format-map interaction qualification
PrzemyslawKlys 33e007d
Validate scene lists and show incoming route surfaces
PrzemyslawKlys f766771
Merge format map gesture and panel corrections into media
PrzemyslawKlys efde2db
Validate recorder options and smooth media scene transitions
PrzemyslawKlys 73d3186
Merge format map hover takeover into media
PrzemyslawKlys 0f5f82a
Validate media encoders and preserve completed output on failure
PrzemyslawKlys 581ea35
Merge master into format map media
PrzemyslawKlys 195fd43
fix: preserve recorded stills and blend outro transitions
PrzemyslawKlys c8a2493
fix: recover previous media when artifact replacement fails
PrzemyslawKlys 732c1ed
fix: validate cut schemas and preserve playback speed
PrzemyslawKlys 70d3cfb
Add documented social video presets and export workflow
PrzemyslawKlys 8f1f19d
Add native 4K YouTube preset and platform export guidance
PrzemyslawKlys File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 | ||
| } | ||
| ] | ||
| } |
Large diffs are not rendered by default.
Oops, something went wrong.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 | ||
| } | ||
| ] | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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"); | ||
| } | ||
| } 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}"); | ||
| } | ||
| } | ||
| } | ||
| } | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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
12
Build/FormatMapRecorder/OfficeIMO.FormatMapRecorder.csproj
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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> |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.