From 35e787bbb29510ac9dabd1e84802a093702a5f74 Mon Sep 17 00:00:00 2001
From: SeoJCalive <180237232+SeoJCalive@users.noreply.github.com>
Date: Fri, 30 Oct 2026 00:43:32 +0900
Subject: [PATCH] fix(qta): follow-up to the #762 review
Waveform, silence detection and clip music take the first audio track
FFmpeg can decode. The codec and encoder list queries run off the
caller's thread, run once per executable even when callers overlap, and
are asked again after a failure. Audio without a codec name counts as
undecodable when the decodable set is known. ENGINES.md mentions the
skipped tracks.
---
docs/ENGINES.md | 8 +-
src/Filee.Engines/Media/ClipPreview.cs | 48 +++--
src/Filee.Engines/Media/FfmpegConverter.cs | 72 +++++---
src/Filee.Engines/Media/MediaEncoding.Clip.cs | 15 +-
src/Filee.Engines/Media/MediaInfo.cs | 36 +++-
tests/Filee.Engines.Tests/ClipTests.cs | 109 ++++++++++++
.../HardwareEncodingTests.cs | 165 +++++++++++++++++-
tests/Filee.Engines.Tests/MediaTests.cs | 37 +++-
8 files changed, 427 insertions(+), 63 deletions(-)
diff --git a/docs/ENGINES.md b/docs/ENGINES.md
index 61362f05..9e0cc5d5 100644
--- a/docs/ENGINES.md
+++ b/docs/ENGINES.md
@@ -797,7 +797,9 @@ in the preset library only). Video and audio need FFmpeg.
is encoded to AAC.
- **Merge videos** copies the clips with the concat demuxer only when all share the codec setup (ffprobe's extradata
hash), size, pixel format, frame rate, turn and kind of sound; otherwise every clip is fitted into the first clip's
- frame and encoded, a clip without sound silent for its length. **Merge audio** always decodes and joins the sound
+ frame and encoded, a clip without sound silent for its length. Sound tracks FFmpeg can't decode are skipped: each
+ clip gives its first track FFmpeg can decode, and the clips are copied only when that track has the same number in
+ every clip. **Merge audio** always decodes and joins the sound
into one M4A, so no encoder padding or gap is left between files. Both are in `Media/MediaJoining.cs`.
- **Video from images**: each picture is fitted into 1920 × 1080 as a JPEG, then shown for the preset's seconds with
crossfades (`xfade`) of 1 s, at most half a picture's time, or cuts. At most `MediaOptions.MaxSlides` (300)
@@ -1578,7 +1580,9 @@ format, and FFmpeg reads what the file holds, so a `.dat` with no video in it fa
subtitle engine (ASS and WebVTT extraction cost 11 so SRT is the hub).
- The first video stream (cover pictures excluded) is converted. Video to video keeps every audio track (languages,
commentary), each copied where the target takes its codec or encoded on its own, with its language tag; FLV and DV
- hold one and keep the first. Video to audio takes the first. Files without sound still convert to video. Title, artist
+ hold one and keep the first. Video to audio takes the first. Tracks FFmpeg can't decode (no decoder, like the spatial
+ APAC track of iPhone recordings, or a codec ffprobe can't name) are skipped, unless the file has no other sound; the
+ clip window's waveform, *Remove silence* and clip music take the same track. Files without sound still convert to video. Title, artist
and other tags and chapters are kept, album art too for MP3, M4A, M4B and FLAC. Portrait phone videos are turned
upright. Odd sizes are evened out, frame rates that MPEG-2/MXF/MPEG-4 part 2 cannot store are converted, and channels
WMA or AMR cannot hold are mixed down.
diff --git a/src/Filee.Engines/Media/ClipPreview.cs b/src/Filee.Engines/Media/ClipPreview.cs
index 0102116c..38993c5e 100644
--- a/src/Filee.Engines/Media/ClipPreview.cs
+++ b/src/Filee.Engines/Media/ClipPreview.cs
@@ -77,7 +77,7 @@ public static async Task PlayAsync(string path, ClipEdit edit, int height, Actio
{
if ((info.Audio is not null || clip.Music is not null) && Player() is { } ffplay)
{
- decoder = Start(ffmpeg, MediaEncoding.ClipPreviewAudio(input, clip, video, info.Audio is not null, info.Audio?.Ordinal ?? 0), stdin: false);
+ decoder = Start(ffmpeg, await PreviewSoundArgumentsAsync(ffprobe, input, clip, video, info.Audio, stop.Token).ConfigureAwait(false), stdin: false);
player = Start(ffplay, ["-hide_banner", "-loglevel", "error", "-nodisp", "-autoexit",
.. PlayerVolume is { } volume ? new[] { "-volume", volume.ToString(CultureInfo.InvariantCulture) } : [], "-i", "-"], stdin: true);
pump = Pump(decoder, player, stop.Token);
@@ -131,7 +131,18 @@ public static async Task PlayAsync(string path, ClipEdit edit, int height, Actio
}
///
- /// How loud the sound of the video at is over its seconds, in
+ /// The arguments of the preview's sound: the music's track probed like the video's.
+ ///
+ internal static async Task> PreviewSoundArgumentsAsync(string ffprobe, string input, ClipEdit clip,
+ VideoStreamInfo video, AudioStreamInfo? audio, CancellationToken cancellationToken)
+ {
+ var musicTrack = clip.Music is { } song
+ ? await MediaInfo.AudioTrackAsync(ffprobe, Path.GetFullPath(song.Path), cancellationToken).ConfigureAwait(false) : 0;
+ return MediaEncoding.ClipPreviewAudio(input, clip, video, audio is not null, audio?.Ordinal ?? 0, musicTrack);
+ }
+
+ ///
+ /// How loud the sound (the track conversions use) of the video at is over its seconds, in
/// steps from 0 (silent) to 1 (its loudest step); empty when it has no sound.
///
/// Where the samples are written for a moment.
@@ -139,14 +150,22 @@ public static Task ReadLoudnessAsync(string path, double duration, int
ReadLoudnessAsync(path, 0, duration, count, workFolder, cancellationToken);
///
- /// How loud the sound of the file at is from to
+ /// How loud the sound (the track conversions use) of the file at is from to
/// seconds, in steps from 0 to 1 (its loudest step); empty
/// without sound.
///
public static async Task ReadLoudnessAsync(string path, double start, double end, int count, string workFolder,
CancellationToken cancellationToken)
{
- var (ffmpeg, _) = FfmpegConverter.Locate() ?? throw new InvalidOperationException("FFmpeg was not found.");
+ return await ReadLoudnessAsync(FfmpegConverter.Locate() ?? throw new InvalidOperationException("FFmpeg was not found."),
+ path, start, end, count, workFolder, cancellationToken).ConfigureAwait(false);
+ }
+
+ internal static async Task ReadLoudnessAsync((string Ffmpeg, string Ffprobe) tools, string path, double start, double end,
+ int count, string workFolder, CancellationToken cancellationToken)
+ {
+ var input = Path.GetFullPath(path);
+ var track = await MediaInfo.AudioTrackAsync(tools.Ffprobe, input, cancellationToken).ConfigureAwait(false);
var samples = Path.Combine(workFolder, Guid.NewGuid().ToString("N") + ".f32");
try
{
@@ -155,9 +174,9 @@ public static async Task ReadLoudnessAsync(string path, double start, d
args.AddRange(["-ss", ClipRange.Ffmpeg(start)]);
if (end > start)
args.AddRange(["-to", ClipRange.Ffmpeg(end)]);
- args.AddRange(["-i", Path.GetFullPath(path), "-map", "0:a:0", "-ac", "1", "-ar", PeakRate.ToString(CultureInfo.InvariantCulture),
+ args.AddRange(["-i", input, "-map", $"0:a:{track}", "-ac", "1", "-ar", PeakRate.ToString(CultureInfo.InvariantCulture),
"-c:a", "pcm_f32le", "-f", "f32le", samples]);
- var result = await FfmpegRunner.RunAsync(ffmpeg, args, null, cancellationToken);
+ var result = await FfmpegRunner.RunAsync(tools.Ffmpeg, args, null, cancellationToken);
if (result.ExitCode != 0 || !File.Exists(samples))
return []; // no sound
var bytes = await File.ReadAllBytesAsync(samples, cancellationToken);
@@ -186,18 +205,27 @@ public static async Task ReadSoundLengthAsync(string path, CancellationT
/// The silent stretches of the video at between and .
public static async Task> FindSilencesAsync(string path, double start, double end, CancellationToken cancellationToken)
{
- var (ffmpeg, _) = FfmpegConverter.Locate() ?? throw new InvalidOperationException("FFmpeg was not found.");
- var result = await ProcessRunner.RunAsync(ffmpeg, SilenceArguments(Path.GetFullPath(path), start, end), TimeSpan.FromMinutes(10), cancellationToken);
+ return await FindSilencesAsync(FfmpegConverter.Locate() ?? throw new InvalidOperationException("FFmpeg was not found."),
+ path, start, end, cancellationToken).ConfigureAwait(false);
+ }
+
+ internal static async Task> FindSilencesAsync((string Ffmpeg, string Ffprobe) tools, string path,
+ double start, double end, CancellationToken cancellationToken)
+ {
+ var input = Path.GetFullPath(path);
+ var track = await MediaInfo.AudioTrackAsync(tools.Ffprobe, input, cancellationToken).ConfigureAwait(false);
+ var result = await ProcessRunner.RunAsync(tools.Ffmpeg, SilenceArguments(input, start, end, track), TimeSpan.FromMinutes(10), cancellationToken);
if (result.ExitCode != 0)
throw new InvalidOperationException("FFmpeg failed: " + FfmpegRunner.Summarize(result.StandardError));
return ParseSilences(result.StandardError, start, end);
}
/// FFmpeg's silencedetect over to of the video, reported on stderr.
- internal static List SilenceArguments(string input, double start, double end) =>
+ /// The selected audio track's ordinal in the input file.
+ internal static List SilenceArguments(string input, double start, double end, int audioTrack = 0) =>
[
"-hide_banner", "-nostdin", "-nostats", "-v", "info", "-ss", ClipRange.Ffmpeg(start), "-to", ClipRange.Ffmpeg(end), "-i", input,
- "-map", "0:a:0", "-af", $"silencedetect=noise={SilenceThreshold.ToString(CultureInfo.InvariantCulture)}dB:d={ClipCutter.MinSilence.ToString(CultureInfo.InvariantCulture)}",
+ "-map", $"0:a:{audioTrack}", "-af", $"silencedetect=noise={SilenceThreshold.ToString(CultureInfo.InvariantCulture)}dB:d={ClipCutter.MinSilence.ToString(CultureInfo.InvariantCulture)}",
"-f", "null", "-",
];
diff --git a/src/Filee.Engines/Media/FfmpegConverter.cs b/src/Filee.Engines/Media/FfmpegConverter.cs
index db42f4e5..ab192d74 100644
--- a/src/Filee.Engines/Media/FfmpegConverter.cs
+++ b/src/Filee.Engines/Media/FfmpegConverter.cs
@@ -124,7 +124,7 @@ public async Task> ConvertAsync(ConversionStep step, IProg
if (step.MotionPhoto)
return await ExtractMotionAsync(step, progress, cancellationToken);
if (step.LivePhoto)
- return await LivePhotoWriter.ConvertAsync(ffmpeg, ffprobe, step, EncodersOf(ffmpeg), filter => HasFilter(ffmpeg, filter),
+ return await LivePhotoWriter.ConvertAsync(ffmpeg, ffprobe, step, await EncodersOfAsync(ffmpeg), filter => HasFilter(ffmpeg, filter),
progress, cancellationToken);
if (step.To == "heic")
return await HeicWriter.ConvertAsync(ffmpeg, step, progress, cancellationToken);
@@ -135,7 +135,7 @@ public async Task> ConvertAsync(ConversionStep step, IProg
if (step.FrameSequence)
return await FrameSequenceWriter.ConvertAsync(ffmpeg, step, input, info, progress, cancellationToken);
if (step.Preset.CutsClipOf(step.From) && MediaEncoding.VideoTargets.ContainsKey(step.To))
- return await CutClipAsync(ffmpeg, step, input, info, progress, cancellationToken);
+ return await CutClipAsync(ffmpeg, ffprobe, step, input, info, progress, cancellationToken);
var output = step.Output.Allocate(FormatRegistry.Get(step.To).PrimaryExtension);
if (output is null)
@@ -179,7 +179,7 @@ await RunPassesAsync(ffmpeg, [new FfmpegPass(MediaEncoding.WithoutMetadata(input
throw new ConversionRefusedException("error.over_size_limit", $"{info.Duration.Value.TotalSeconds:0} s in {media.MaxSizeMb:0.##} MB");
}
- var encoders = still ? null : EncodersOf(ffmpeg);
+ var encoders = still ? null : await EncodersOfAsync(ffmpeg);
// Hardware encoding (preset option): the first GPU encoder that passes its test encode, if any.
HardwareEncoder? hardware = null;
if (!still && MediaEncoding.HardwareCodec(step.To, media, info) is { } codec)
@@ -260,7 +260,7 @@ public async Task JoinAsync(IReadOnlyList inputPaths, string outputPath,
if (media.Slideshow)
{
var slides = await PrepareSlidesAsync(ffmpeg, inputPaths, scratch, progress, cancellationToken);
- var encoder = MediaEncoding.VideoEncoder("mp4", "libx264", media.VideoCodec, EncodersOf(ffmpeg));
+ var encoder = MediaEncoding.VideoEncoder("mp4", "libx264", media.VideoCodec, await EncodersOfAsync(ffmpeg));
args = MediaJoining.Slideshow(slides, outputPath, media, encoder, scratch);
duration = MediaJoining.SlideshowDuration(slides.Count, media);
start = 0.2;
@@ -291,7 +291,7 @@ public async Task JoinAsync(IReadOnlyList inputPaths, string outputPath,
}
else
{
- var encoder = MediaEncoding.VideoEncoder("mp4", "libx264", media.VideoCodec, EncodersOf(ffmpeg));
+ var encoder = MediaEncoding.VideoEncoder("mp4", "libx264", media.VideoCodec, await EncodersOfAsync(ffmpeg));
args = MediaJoining.EncodeVideos(clips, outputPath, media, encoder, scratch);
}
}
@@ -343,7 +343,7 @@ private static async Task RunJoinPassAsync(string ffmpeg, IReadOnlyList
/// the target can't take (a codec its container doesn't hold) is encoded exactly instead, like the preset's other
/// videos. Several parts, speeds, volumes, pauses or fades are encoded through one filter graph.
///
- private async Task> CutClipAsync(string ffmpeg, ConversionStep step, string input, MediaInfo info,
+ private async Task> CutClipAsync(string ffmpeg, string ffprobe, ConversionStep step, string input, MediaInfo info,
IProgress? progress, CancellationToken cancellationToken)
{
if (info.Video is null)
@@ -359,7 +359,10 @@ private async Task> CutClipAsync(string ffmpeg, Conversion
// Parts, speeds, volumes, pauses or fades: one filter graph, always encoded.
try
{
- var pass = new FfmpegPass(MediaEncoding.ClipEditArguments(input, output, step.To, edit, media, info, EncodersOf(ffmpeg)), 1);
+ var musicTrack = edit.Music is { } song && !media.RemoveAudio
+ ? await MediaInfo.AudioTrackAsync(ffprobe, Path.GetFullPath(song.Path), cancellationToken) : 0;
+ var pass = new FfmpegPass(MediaEncoding.ClipEditArguments(input, output, step.To, edit, media, info,
+ await EncodersOfAsync(ffmpeg), musicTrack), 1);
await RunPassesAsync(ffmpeg, [pass], output, duration, progress, cancellationToken);
}
catch
@@ -390,7 +393,7 @@ private async Task> CutClipAsync(string ffmpeg, Conversion
}
}
- var encoders = EncodersOf(ffmpeg);
+ var encoders = await EncodersOfAsync(ffmpeg);
HardwareEncoder? hardware = null;
if (MediaEncoding.HardwareCodec(step.To, media, info, encode: true) is { } codec)
{
@@ -557,45 +560,58 @@ internal static string VersionOf(string ffmpeg, TimeSpan? timeout = null) =>
[GeneratedRegex(@"version\s+n?(\d+(?:\.\d+)+|\S+)")]
private static partial Regex VersionPattern();
- private static readonly ConcurrentDictionary?> Encoders = new();
+ private static readonly ConcurrentDictionary?>>> Encoders = new();
///
/// The encoders of (ffmpeg -encoders), cached per executable: a copy the user
/// picked may lack x265 or SVT-AV1. Null (assume Filee's build) when the list can't be read.
+ /// Queried once at a time and kept when read; asked again after a failure.
///
- internal static IReadOnlySet? EncodersOf(string ffmpeg, TimeSpan? timeout = null) =>
- Encoders.GetOrAdd($"{ffmpeg}|{File.GetLastWriteTimeUtc(ffmpeg).Ticks}", _ =>
- {
- try
- {
- var result = FfmpegRunner.RunBriefly(ffmpeg, ["-hide_banner", "-encoders"], timeout ?? QueryTimeout);
- return result.ExitCode == 0 ? ParseEncoders(result.StandardOutput) : null;
- }
- catch (Exception ex) when (ex is TimeoutException or InvalidOperationException or System.ComponentModel.Win32Exception or IOException)
- {
- return null;
- }
- });
+ internal static Task?> EncodersOfAsync(string ffmpeg, TimeSpan? timeout = null) =>
+ QueryOnceAsync(Encoders, ffmpeg, "-encoders", timeout, ParseEncoders);
- private static readonly ConcurrentDictionary?> Decoders = new();
+ private static readonly ConcurrentDictionary?>>> Decoders = new();
///
/// The decodable codec names of this ffmpeg, cached per executable. Null assumes every codec can be decoded
/// when the table cannot be read.
+ /// Queried once at a time and kept when read; asked again after a failure.
///
- internal static IReadOnlySet? DecodableCodecsOf(string ffmpeg, TimeSpan? timeout = null) =>
- Decoders.GetOrAdd($"{ffmpeg}|{File.GetLastWriteTimeUtc(ffmpeg).Ticks}", _ =>
+ internal static Task?> DecodableCodecsOfAsync(string ffmpeg, TimeSpan? timeout = null) =>
+ QueryOnceAsync(Decoders, ffmpeg, "-codecs", timeout, ParseDecodableCodecs);
+
+ private static async Task?> QueryOnceAsync(
+ ConcurrentDictionary?>>> cache, string ffmpeg, string option,
+ TimeSpan? timeout, Func> parse)
+ {
+ var key = $"{ffmpeg}|{File.GetLastWriteTimeUtc(ffmpeg).Ticks}";
+ var entry = cache.GetOrAdd(key, _ => new Lazy?>>(() => Task.Run(Query)));
+ try
+ {
+ var result = await entry.Value.ConfigureAwait(false);
+ if (result is null)
+ cache.TryRemove(KeyValuePair.Create(key, entry));
+ return result;
+ }
+ catch
+ {
+ cache.TryRemove(KeyValuePair.Create(key, entry));
+ throw;
+ }
+
+ IReadOnlySet? Query()
{
try
{
- var result = FfmpegRunner.RunBriefly(ffmpeg, ["-hide_banner", "-codecs"], timeout ?? QueryTimeout);
- return result.ExitCode == 0 ? ParseDecodableCodecs(result.StandardOutput) : null;
+ var result = FfmpegRunner.RunBriefly(ffmpeg, ["-hide_banner", option], timeout ?? QueryTimeout);
+ return result.ExitCode == 0 ? parse(result.StandardOutput) : null;
}
catch (Exception ex) when (ex is TimeoutException or InvalidOperationException or System.ComponentModel.Win32Exception or IOException)
{
return null;
}
- });
+ }
+ }
private static readonly ConcurrentDictionary?> Filters = new();
diff --git a/src/Filee.Engines/Media/MediaEncoding.Clip.cs b/src/Filee.Engines/Media/MediaEncoding.Clip.cs
index d36d152f..a5bf46a9 100644
--- a/src/Filee.Engines/Media/MediaEncoding.Clip.cs
+++ b/src/Filee.Engines/Media/MediaEncoding.Clip.cs
@@ -39,8 +39,9 @@ internal static partial class MediaEncoding
/// The video's own sound is used (it has sound, and the preset keeps it).
/// The clip's music is mixed in (the preset keeps sound).
/// The selected audio track's ordinal in the input file.
+ /// The music file's audio track ordinal.
internal static ClipGraph ClipGraphOf(string input, ClipEdit edit, bool audio, int width, int height, string fps, string pixelFormat = "yuv420p",
- bool video = true, bool music = true, int audioTrack = 0)
+ bool video = true, bool music = true, int audioTrack = 0, int musicTrack = 0)
{
var song = music ? edit.Music : null;
var sound = audio || song is not null;
@@ -122,7 +123,7 @@ internal static ClipGraph ClipGraphOf(string input, ClipEdit edit, bool audio, i
var length = song.PlayLength(total);
var fade = song.Fade ? Math.Min(ClipMusic.FadeSeconds, length / 2) : 0;
var delay = (long)Math.Round(song.Offset * 1000);
- var chain = $"[1:a:0]atrim=duration={Seconds(length)},asetpts=PTS-STARTPTS";
+ var chain = $"[1:a:{musicTrack}]atrim=duration={Seconds(length)},asetpts=PTS-STARTPTS";
if (song.PlayVolume != 1)
chain += $",volume={Seconds(song.PlayVolume)}";
if (fade > 0)
@@ -146,7 +147,7 @@ internal static ClipGraph ClipGraphOf(string input, ClipEdit edit, bool audio, i
/// The file has no video stream.
/// FFmpeg cannot write .
internal static List ClipEditArguments(string input, string output, string target, ClipEdit edit, MediaOptions options,
- MediaInfo info, IReadOnlySet? encoders = null)
+ MediaInfo info, IReadOnlySet? encoders = null, int musicTrack = 0)
{
var video = info.Video ?? throw new InvalidOperationException("The file has no video stream.");
if (!VideoTargets.TryGetValue(target, out var spec))
@@ -157,7 +158,7 @@ internal static List ClipEditArguments(string input, string output, stri
var tenBit = video.IsHighBitDepth && encoder is "libx265" or "libsvtav1" or "libaom-av1";
// "Remove audio" leaves the clip silent, music and all.
var graph = ClipGraphOf(input, edit, !options.RemoveAudio && info.Audio is not null, width, height, fps, tenBit ? "yuv420p10le" : "yuv420p",
- music: !options.RemoveAudio, audioTrack: info.Audio?.Ordinal ?? 0);
+ music: !options.RemoveAudio, audioTrack: info.Audio?.Ordinal ?? 0, musicTrack: musicTrack);
var audio = graph.HasAudio;
var pal = IsPal(video.FrameRate);
var filter = target is "vob" or "dv"
@@ -194,10 +195,12 @@ internal static List ClipPreviewVideo(string input, ClipEdit edit, Video
/// The arguments that play the sound of : a WAV stream on stdout (for ffplay).
/// The video has sound of its own (else only the music plays).
/// The selected audio track's ordinal in the input file.
- internal static List ClipPreviewAudio(string input, ClipEdit edit, VideoStreamInfo video, bool hasAudio = true, int audioTrack = 0)
+ /// The music file's audio track ordinal.
+ internal static List ClipPreviewAudio(string input, ClipEdit edit, VideoStreamInfo video, bool hasAudio = true, int audioTrack = 0,
+ int musicTrack = 0)
{
var (width, height) = ClipSize(video, 90);
- var graph = ClipGraphOf(input, edit, hasAudio, width, height, "10", video: false, audioTrack: audioTrack);
+ var graph = ClipGraphOf(input, edit, hasAudio, width, height, "10", video: false, audioTrack: audioTrack, musicTrack: musicTrack);
return [.. Common, .. graph.Inputs, "-filter_complex", graph.Filter, "-map", "[a]", "-c:a", "pcm_s16le", "-f", "wav", "pipe:1"];
}
diff --git a/src/Filee.Engines/Media/MediaInfo.cs b/src/Filee.Engines/Media/MediaInfo.cs
index 697a5851..562bb7a5 100644
--- a/src/Filee.Engines/Media/MediaInfo.cs
+++ b/src/Filee.Engines/Media/MediaInfo.cs
@@ -3,6 +3,7 @@
using System.Globalization;
using System.Text.Json;
+using Filee.Core.Conversion;
using Filee.Engines.Infrastructure;
namespace Filee.Engines.Media;
@@ -73,7 +74,8 @@ internal sealed record AudioStreamInfo(int Channels, int SampleRate, string? Cod
/// Summary of a media file.
/// Playing time, null when the container does not say (raw streams).
/// First video stream that is not a cover picture, if any.
-/// First audio stream FFmpeg can decode, or the first audio stream when none can be decoded.
+/// First audio stream FFmpeg can decode, or the first audio stream when none can be decoded;
+/// a stream ffprobe names no codec for counts as undecodable when the codec table is known.
/// Index of an embedded cover picture (JPEG or PNG) such as album art, if any.
/// Every subtitle stream in file order, null when there is none.
///
@@ -81,12 +83,14 @@ internal sealed record AudioStreamInfo(int Channels, int SampleRate, string? Cod
/// always encoded, never copied into a container.
///
/// The file carries attachments (an MKV's subtitle fonts).
-/// Decodable audio streams in file order (all when none can be decoded), null for none or only one.
+/// Decodable audio streams in file order (all when none can be decoded), null for none or only one;
+/// a stream ffprobe names no codec for counts as undecodable when the codec table is known.
internal sealed record MediaInfo(TimeSpan? Duration, VideoStreamInfo? Video, AudioStreamInfo? Audio, int? CoverStreamIndex,
IReadOnlyList? SubtitleStreams = null, bool RawStream = false, bool HasAttachments = false,
IReadOnlyList? AudioStreams = null)
{
- /// Decodable audio streams in file order (all when none can be decoded): first.
+ /// Decodable audio streams in file order (all when none can be decoded): first;
+ /// a stream ffprobe names no codec for counts as undecodable when the codec table is known.
public IReadOnlyList AudioTracks => AudioStreams ?? (Audio is null ? [] : [Audio]);
/// Every subtitle stream, in file order.
@@ -97,7 +101,7 @@ internal sealed record MediaInfo(TimeSpan? Duration, VideoStreamInfo? Video, Aud
///
/// Runs ffprobe on ; throws with FFmpeg's message when the file cannot be read, or refuses an
/// .m4s media segment that needs its init segment (). Audio selection follows the codecs
- /// that the ffmpeg beside this ffprobe can decode.
+ /// that the ffmpeg beside this ffprobe can decode; the codec table is read on a worker thread, never on the caller's.
///
public static async Task ProbeAsync(string ffprobe, string input, CancellationToken cancellationToken)
{
@@ -111,10 +115,28 @@ public static async Task ProbeAsync(string ffprobe, string input, Can
throw new InvalidOperationException($"FFmpeg cannot read this file{(reason.Length > 0 ? ": " + reason : ".")}");
}
var ffmpeg = Path.Combine(Path.GetDirectoryName(ffprobe) ?? "", EngineEnvironment.ProgramName("ffmpeg.exe"));
- return Parse(result.StandardOutput, FfmpegConverter.DecodableCodecsOf(ffmpeg));
+ return Parse(result.StandardOutput, await FfmpegConverter.DecodableCodecsOfAsync(ffmpeg).ConfigureAwait(false));
}
- /// Parses ffprobe's JSON; null assumes every codec can be decoded.
+ ///
+ /// The audio track Filee maps for the file (the first one FFmpeg can decode); 0 when it can't be probed,
+ /// so FFmpeg reports the problem as before.
+ ///
+ internal static async Task AudioTrackAsync(string ffprobe, string input, CancellationToken cancellationToken)
+ {
+ try
+ {
+ return (await ProbeAsync(ffprobe, input, cancellationToken).ConfigureAwait(false)).Audio?.Ordinal ?? 0;
+ }
+ catch (Exception ex) when (ex is InvalidOperationException or TimeoutException or System.ComponentModel.Win32Exception
+ or IOException or ConversionRefusedException)
+ {
+ return 0;
+ }
+ }
+
+ /// Parses ffprobe's JSON; null assumes every codec can be decoded;
+ /// a stream ffprobe names no codec for counts as undecodable when the codec table is known.
public static MediaInfo Parse(string json, IReadOnlySet? decodable = null)
{
using var document = JsonDocument.Parse(json);
@@ -155,7 +177,7 @@ public static MediaInfo Parse(string json, IReadOnlySet? decodable = nul
case "audio":
var sound = new AudioStreamInfo((int)Number(stream, "channels"), (int)Number(stream, "sample_rate"),
Text(stream, "codec_name"), audioOrdinal++);
- if (decodable is null || sound.Codec is not { } name || decodable.Contains(name))
+ if (decodable is null || sound.Codec is { } name && decodable.Contains(name))
audios.Add(sound);
else
undecodable.Add(sound);
diff --git a/tests/Filee.Engines.Tests/ClipTests.cs b/tests/Filee.Engines.Tests/ClipTests.cs
index 21844748..541d07dd 100644
--- a/tests/Filee.Engines.Tests/ClipTests.cs
+++ b/tests/Filee.Engines.Tests/ClipTests.cs
@@ -440,6 +440,8 @@ public void Silences_are_read_from_silencedetect()
Assert.Equal([new ClipRange(60, 61.25), new ClipRange(63.5, 65), new ClipRange(69.75, 70)], ClipPreview.ParseSilences(log, 60, 70));
Assert.Empty(ClipPreview.ParseSilences("nothing here", 0, 10));
var args = ClipPreview.SilenceArguments("in.mp4", 60, 70);
+ Assert.Equal("0:a:0", After(args, "-map"));
+ Assert.Equal("0:a:1", After(ClipPreview.SilenceArguments("in.mp4", 60, 70, audioTrack: 1), "-map"));
Assert.Equal("60.000", After(args, "-ss"));
Assert.StartsWith("silencedetect=noise=-35dB:d=0.4", After(args, "-af"));
}
@@ -510,6 +512,71 @@ public async Task Silences_and_loudness_are_found_in_the_sound()
Assert.Single(Directory.GetFiles(folder)); // the samples are gone
}
+ [Fact]
+ public async Task The_waveform_and_silences_come_from_the_first_sound_ffmpeg_can_decode()
+ {
+ SkipUnlessInstalled();
+ var folder = fx.NewFolder();
+ var source = Path.Combine(folder, "talk.mov");
+ var result = await ProcessRunner.RunAsync(Tools!.Value.Ffmpeg,
+ [
+ "-hide_banner", "-nostdin", "-v", "error", "-y",
+ "-f", "lavfi", "-i", "testsrc2=size=160x120:rate=25:duration=8",
+ "-f", "lavfi", "-i", "sine=frequency=440:sample_rate=48000:duration=8",
+ "-f", "lavfi", "-i", "aevalsrc='if(between(t,3,5),0,0.5*sin(2*PI*440*t))':s=48000:d=8",
+ "-map", "0:v", "-map", "1:a", "-map", "2:a", "-ac:a:0", "4",
+ "-c:v", "mjpeg", "-c:a", "aac", "-f", "mov", source,
+ ], TimeSpan.FromMinutes(2), CancellationToken.None);
+ Assert.True(result.ExitCode == 0, result.StandardError);
+ MediaTests.WithoutDecoder(source);
+ var probed = await ProcessRunner.RunAsync(Tools.Value.Ffprobe,
+ ["-v", "error", "-select_streams", "a", "-show_entries", "stream=codec_name", "-of", "json", source],
+ TimeSpan.FromMinutes(1), CancellationToken.None);
+ Assert.True(probed.ExitCode == 0, probed.StandardError);
+ using var json = JsonDocument.Parse(probed.StandardOutput);
+ Assert.Equal(["apple_apac", "aac"], json.RootElement.GetProperty("streams").EnumerateArray().Select(s => s.GetProperty("codec_name").GetString()));
+
+ var silence = Assert.Single(await ClipPreview.FindSilencesAsync(source, 1, 7, CancellationToken.None));
+ Assert.InRange(silence.Start, 2.9, 3.2);
+ Assert.InRange(silence.End, 4.9, 5.2);
+ var loudness = await ClipPreview.ReadLoudnessAsync(source, 8, 8, folder, CancellationToken.None);
+ Assert.Equal(8, loudness.Length);
+ Assert.True(loudness[3] < 0.1 && loudness[4] < 0.1, string.Join(", ", loudness));
+ Assert.True(loudness[1] > 0.8 && loudness[6] > 0.8, string.Join(", ", loudness));
+ Assert.Single(Directory.GetFiles(folder)); // the samples are gone
+ }
+
+ [Fact]
+ public async Task The_waveform_and_silences_go_on_with_track_0_when_the_file_cant_be_probed()
+ {
+ SkipUnlessInstalled();
+ var folder = fx.NewFolder();
+ var notMedia = Path.Combine(folder, "not-media.mp4");
+ await File.WriteAllTextAsync(notMedia, "not media", TestContext.Current.CancellationToken);
+ Assert.Empty(await ClipPreview.ReadLoudnessAsync(notMedia, 8, 8, folder, CancellationToken.None));
+ var error = await Assert.ThrowsAsync(() =>
+ ClipPreview.FindSilencesAsync(notMedia, 1, 7, CancellationToken.None));
+ Assert.StartsWith("FFmpeg failed", error.Message);
+
+ var source = Path.Combine(folder, "talk.mp4");
+ var result = await ProcessRunner.RunAsync(Tools!.Value.Ffmpeg,
+ [
+ "-hide_banner", "-nostdin", "-v", "error", "-y",
+ "-f", "lavfi", "-i", "testsrc2=size=160x120:rate=25:duration=8",
+ "-f", "lavfi", "-i", "aevalsrc='if(between(t,3,5),0,0.5*sin(2*PI*440*t))':s=48000:d=8",
+ "-c:v", "libx264", "-pix_fmt", "yuv420p", "-c:a", "aac", source,
+ ], TimeSpan.FromMinutes(2), CancellationToken.None);
+ Assert.True(result.ExitCode == 0, result.StandardError);
+ var tools = (Tools.Value.Ffmpeg, Path.Combine(folder, "no-ffprobe", "ffprobe"));
+ var silence = Assert.Single(await ClipPreview.FindSilencesAsync(tools, source, 1, 7, CancellationToken.None));
+ Assert.InRange(silence.Start, 2.9, 3.2);
+ Assert.InRange(silence.End, 4.9, 5.2);
+ var loudness = await ClipPreview.ReadLoudnessAsync(tools, source, 0, 8, 8, folder, CancellationToken.None);
+ Assert.Equal(8, loudness.Length);
+ Assert.True(loudness[3] < 0.1 && loudness[4] < 0.1, string.Join(", ", loudness));
+ Assert.True(loudness[1] > 0.8 && loudness[6] > 0.8, string.Join(", ", loudness));
+ }
+
[Fact]
public void The_previews_sound_is_a_graph_of_sound_alone()
{
@@ -611,6 +678,9 @@ public void Music_is_a_second_input_mixed_under_the_clip()
Assert.Contains("[as0]atrim=start=0:end=10,asetpts=PTS-STARTPTS,volume=0,", filter); // muted: silent, still there
// 17 s of clip, from 2.5 s: 14.5 s of music, faded a second at its ends, 2.5 s late.
Assert.Contains("[1:a:0]atrim=duration=14.5,asetpts=PTS-STARTPTS,volume=0.5,afade=t=in:st=0:d=1,afade=t=out:st=13.5:d=1,adelay=2500:all=1,", filter);
+ var nextTrack = MediaEncoding.ClipGraphOf("in.mp4", edit, audio: true, 1920, 1080, "30", musicTrack: 1).Filter;
+ Assert.Contains("[1:a:1]atrim=duration=14.5", nextTrack);
+ Assert.DoesNotContain("[1:a:0]", nextTrack);
Assert.Contains("[ac][m]amix=inputs=2:duration=first:dropout_transition=0:normalize=0[am]", filter);
Assert.EndsWith("[am]afade=t=in:st=0:d=1,afade=t=out:st=15:d=2[a]", filter);
@@ -680,4 +750,43 @@ public async Task A_video_without_sound_gets_the_musics()
Assert.Equal(("h264", "aac"), (probed.VideoCodec, probed.AudioCodec));
Assert.InRange(probed.Duration, 2.9, 3.15);
}
+
+ [Fact]
+ public async Task Music_whose_first_sound_ffmpeg_cannot_decode_plays_its_next_one()
+ {
+ SkipUnlessInstalled();
+ var folder = fx.NewFolder();
+ var song = Path.Combine(folder, "song.mov");
+ var made = await ProcessRunner.RunAsync(Tools!.Value.Ffmpeg,
+ [
+ "-hide_banner", "-nostdin", "-v", "error", "-y",
+ "-f", "lavfi", "-i", "sine=frequency=440:sample_rate=48000:duration=8",
+ "-f", "lavfi", "-i", "sine=frequency=880:sample_rate=44100:duration=8",
+ "-ac:a:0", "4", "-map", "0:a", "-map", "1:a", "-c:a", "aac", "-f", "mov", song,
+ ], TimeSpan.FromMinutes(1), CancellationToken.None);
+ Assert.True(made.ExitCode == 0, made.StandardError);
+ MediaTests.WithoutDecoder(song);
+ var source = await MakeVideoAsync(folder, "silent.mp4", "-c:v", "libx264", "-g", "25", "-pix_fmt", "yuv420p", "-an");
+ var edit = ClipEdit.Of(new ClipRange(2, 5));
+ edit.Music = new ClipMusic { Path = song, Length = 8 };
+
+ var job = await fx.ConvertAsync([source],
+ ClipTool.ConversionOf(BuiltInData.CreatePresets().Single(p => p.Id == "tool-clip"), "mp4", edit));
+ Assert.True(job.State == JobState.Completed, string.Join("; ", job.Files.Select(f => $"{f.ErrorKey} {f.ErrorDetail}")));
+ var probed = await ProbeAsync(Assert.Single(job.Outputs));
+ Assert.Equal(("h264", "aac"), (probed.VideoCodec, probed.AudioCodec));
+ Assert.InRange(probed.Duration, 2.85, 3.15);
+
+ var info = await MediaInfo.ProbeAsync(Tools.Value.Ffprobe, source, CancellationToken.None);
+ var clip = edit.Clamped(info.Duration?.TotalSeconds);
+ var args = await ClipPreview.PreviewSoundArgumentsAsync(Tools.Value.Ffprobe, source, clip, info.Video!, info.Audio, CancellationToken.None);
+ var filter = After(args, "-filter_complex");
+ Assert.Contains("[1:a:1]atrim=", filter);
+ Assert.DoesNotContain("[1:a:0]", filter);
+ var wav = Path.Combine(folder, "sound.wav");
+ args[^1] = wav;
+ var result = await ProcessRunner.RunAsync(Tools.Value.Ffmpeg, args, TimeSpan.FromMinutes(1), CancellationToken.None);
+ Assert.True(result.ExitCode == 0, result.StandardError);
+ Assert.InRange(new FileInfo(wav).Length, 3 * 48000 * 4 * 0.95, 3 * 48000 * 4 * 1.05);
+ }
}
diff --git a/tests/Filee.Engines.Tests/HardwareEncodingTests.cs b/tests/Filee.Engines.Tests/HardwareEncodingTests.cs
index 24004612..bc24940f 100644
--- a/tests/Filee.Engines.Tests/HardwareEncodingTests.cs
+++ b/tests/Filee.Engines.Tests/HardwareEncodingTests.cs
@@ -2,6 +2,7 @@
// when a hardware encode fails, and the time limit of FFmpeg's quick queries. Real hardware encodes run only on a
// computer with a working GPU encoder and Filee's FFmpeg; CI has neither, so those tests skip there.
+using System.Collections.Concurrent;
using System.Diagnostics;
using Filee.Core.Conversion;
using Filee.Core.Presets;
@@ -414,7 +415,7 @@ public async Task A_working_hardware_encoder_writes_the_video(VideoCodec videoCo
Assert.SkipUnless(Tools is not null, "FFmpeg is not installed (pwsh build/fetch-engines.ps1 -Only ffmpeg)");
var ffmpeg = Tools!.Value.Ffmpeg;
var logger = new ListLogger();
- var hardware = await HardwareProbe.SelectAsync(ffmpeg, codec, false, FfmpegConverter.EncodersOf(ffmpeg), logger, CancellationToken.None);
+ var hardware = await HardwareProbe.SelectAsync(ffmpeg, codec, false, await FfmpegConverter.EncodersOfAsync(ffmpeg), logger, CancellationToken.None);
Assert.SkipUnless(hardware is not null, $"No {codec} hardware encoder works on this computer");
var folder = fx.NewFolder();
@@ -452,26 +453,97 @@ public async Task An_av1_preset_with_hardware_encoding_writes_av1_with_or_withou
// ───────────────────────── Quick queries ─────────────────────────
[Fact]
- public void Version_and_encoder_queries_give_up_on_an_ffmpeg_that_hangs()
+ public async Task Version_and_encoder_queries_give_up_on_an_ffmpeg_that_hangs()
{
var hanging = HangingProgram(fx.NewFolder());
var watch = Stopwatch.StartNew();
Assert.Throws(() => FfmpegConverter.VersionOf(hanging, TimeSpan.FromSeconds(1)));
- Assert.Null(FfmpegConverter.EncodersOf(hanging, TimeSpan.FromSeconds(1)));
+ Assert.Null(await FfmpegConverter.EncodersOfAsync(hanging, TimeSpan.FromSeconds(1)));
// Before, both read the output to its end first and waited as long as the program hung (here a minute).
Assert.True(watch.Elapsed < TimeSpan.FromSeconds(20), $"took {watch.Elapsed}");
}
[Fact]
- public void Decoder_queries_give_up_on_a_missing_or_hanging_ffmpeg()
+ public async Task Decoder_queries_give_up_on_a_missing_or_hanging_ffmpeg()
{
- Assert.Null(FfmpegConverter.DecodableCodecsOf(Path.Combine(fx.NewFolder(), "missing-ffmpeg"), TimeSpan.FromSeconds(1)));
+ Assert.Null(await FfmpegConverter.DecodableCodecsOfAsync(Path.Combine(fx.NewFolder(), "missing-ffmpeg"), TimeSpan.FromSeconds(1)));
var hanging = HangingProgram(fx.NewFolder());
var watch = Stopwatch.StartNew();
- Assert.Null(FfmpegConverter.DecodableCodecsOf(hanging, TimeSpan.FromSeconds(1)));
+ Assert.Null(await FfmpegConverter.DecodableCodecsOfAsync(hanging, TimeSpan.FromSeconds(1)));
Assert.True(watch.Elapsed < TimeSpan.FromSeconds(20), $"took {watch.Elapsed}");
}
+ [Fact]
+ public async Task A_probe_leaves_its_callers_thread_free_while_ffmpeg_lists_its_codecs()
+ {
+ Assert.SkipWhen(OperatingSystem.IsWindows(), "The gated FFmpeg uses Unix scripts and FIFOs.");
+ var folder = fx.NewFolder();
+ var (_, ffprobe) = await GatedFfmpeg(folder);
+ using var pump = new SingleThreadContext();
+ var probe = pump.Run(() => MediaInfo.ProbeAsync(ffprobe, Path.Combine(folder, "in.mov"), TestContext.Current.CancellationToken));
+ await Task.Run(() => File.ReadAllText(Path.Combine(folder, "started")), TestContext.Current.CancellationToken)
+ .WaitAsync(TimeSpan.FromSeconds(30), TestContext.Current.CancellationToken);
+ var free = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ var stillWaiting = false;
+ pump.Post(_ =>
+ {
+ stillWaiting = !probe.IsCompleted;
+ free.SetResult();
+ }, null);
+ await free.Task.WaitAsync(TimeSpan.FromSeconds(30), TestContext.Current.CancellationToken);
+ Assert.True(stillWaiting);
+ await Release(folder);
+ var info = await probe.WaitAsync(TimeSpan.FromSeconds(30), TestContext.Current.CancellationToken);
+ Assert.Equal(("aac", 1), (info.Audio!.Codec, info.Audio.Ordinal));
+ }
+
+ [Fact]
+ public async Task Overlapping_codec_and_encoder_queries_run_once()
+ {
+ Assert.SkipWhen(OperatingSystem.IsWindows(), "The gated FFmpeg uses Unix scripts and FIFOs.");
+ foreach (var codecs in new[] { true, false })
+ {
+ var folder = fx.NewFolder();
+ var (ffmpeg, _) = await GatedFfmpeg(folder);
+ var first = codecs ? FfmpegConverter.DecodableCodecsOfAsync(ffmpeg) : FfmpegConverter.EncodersOfAsync(ffmpeg);
+ await Task.Run(() => File.ReadAllText(Path.Combine(folder, "started")), TestContext.Current.CancellationToken)
+ .WaitAsync(TimeSpan.FromSeconds(30), TestContext.Current.CancellationToken);
+ var second = codecs ? FfmpegConverter.DecodableCodecsOfAsync(ffmpeg) : FfmpegConverter.EncodersOfAsync(ffmpeg);
+ await Release(folder);
+ var results = await Task.WhenAll(first, second).WaitAsync(TimeSpan.FromSeconds(30), TestContext.Current.CancellationToken);
+ Assert.Single(File.ReadAllLines(Path.Combine(folder, "runs")));
+ foreach (var result in results)
+ {
+ Assert.NotNull(result);
+ Assert.Contains(codecs ? "aac" : "libx264", result);
+ }
+ }
+ }
+
+ [Fact]
+ public async Task A_failed_codec_or_encoder_query_is_asked_again()
+ {
+ Assert.SkipWhen(OperatingSystem.IsWindows(), "The gated FFmpeg uses Unix scripts and FIFOs.");
+ var folder = fx.NewFolder();
+ var (ffmpeg, _) = await GatedFfmpeg(folder);
+ File.Delete(Path.Combine(folder, "started"));
+ File.Delete(Path.Combine(folder, "release"));
+ var fail = Path.Combine(folder, "fail");
+ File.WriteAllText(fail, "");
+ Assert.Null(await FfmpegConverter.DecodableCodecsOfAsync(ffmpeg));
+ Assert.Null(await FfmpegConverter.EncodersOfAsync(ffmpeg));
+ File.Delete(fail);
+ var codecs = await FfmpegConverter.DecodableCodecsOfAsync(ffmpeg);
+ var encoders = await FfmpegConverter.EncodersOfAsync(ffmpeg);
+ Assert.NotNull(codecs);
+ Assert.NotNull(encoders);
+ Assert.Contains("aac", codecs);
+ Assert.Contains("libx264", encoders);
+ Assert.Contains("aac", (await FfmpegConverter.DecodableCodecsOfAsync(ffmpeg))!);
+ Assert.Contains("libx264", (await FfmpegConverter.EncodersOfAsync(ffmpeg))!);
+ Assert.Equal(["-hide_banner -codecs", "-hide_banner -encoders"], File.ReadAllLines(Path.Combine(folder, "runs")));
+ }
+
[Fact]
public void Encoder_lists_are_read()
{
@@ -517,6 +589,87 @@ private static string HangingProgram(string folder)
return shell;
}
+ private static async Task<(string Ffmpeg, string Ffprobe)> GatedFfmpeg(string folder)
+ {
+ Assert.SkipWhen(OperatingSystem.IsWindows(), "The gated FFmpeg uses Unix scripts and FIFOs.");
+ var ffmpeg = Path.Combine(folder, "ffmpeg");
+ var ffprobe = Path.Combine(folder, "ffprobe");
+ File.WriteAllText(ffmpeg, """
+ #!/bin/sh
+ d=$(dirname "$0")
+ [ -e "$d/fail" ] && exit 1
+ echo "$*" >> "$d/runs"
+ [ -p "$d/started" ] && { echo started > "$d/started"; read go < "$d/release"; }
+ printf ' DEA.L. aac AAC (Advanced Audio Coding)\n ..A.L. apple_apac Apple Positional Audio Codec\n V....D libx264 libx264 H.264\n'
+
+ """);
+ File.WriteAllText(ffprobe, """
+ #!/bin/sh
+ cat <<'JSON'
+ {"streams":[{"codec_type":"audio","codec_name":"apple_apac","channels":4,"sample_rate":"48000"},{"codec_type":"audio","codec_name":"aac","channels":2,"sample_rate":"48000"}],"format":{"duration":"8"}}
+ JSON
+
+ """);
+ if (!OperatingSystem.IsWindows())
+ {
+ File.SetUnixFileMode(ffmpeg, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute);
+ File.SetUnixFileMode(ffprobe, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute);
+ }
+ var result = await ProcessRunner.RunAsync("/usr/bin/mkfifo", [Path.Combine(folder, "started"), Path.Combine(folder, "release")],
+ TimeSpan.FromSeconds(30), TestContext.Current.CancellationToken);
+ Assert.True(result.ExitCode == 0, result.StandardError);
+ return (ffmpeg, ffprobe);
+ }
+
+ private static Task Release(string folder) =>
+ Task.Run(() => File.WriteAllText(Path.Combine(folder, "release"), "go\n"), TestContext.Current.CancellationToken)
+ .WaitAsync(TimeSpan.FromSeconds(30), TestContext.Current.CancellationToken);
+
+ private sealed class SingleThreadContext : SynchronizationContext, IDisposable
+ {
+ private readonly BlockingCollection<(SendOrPostCallback Callback, object? State)> _queue = new();
+ private readonly Thread _thread;
+
+ public SingleThreadContext()
+ {
+ _thread = new Thread(() =>
+ {
+ SetSynchronizationContext(this);
+ foreach (var (callback, state) in _queue.GetConsumingEnumerable())
+ callback(state);
+ })
+ { IsBackground = true };
+ _thread.Start();
+ }
+
+ public override void Post(SendOrPostCallback d, object? state) => _queue.Add((d, state));
+
+ public Task Run(Func> action)
+ {
+ var result = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ Post(async _ =>
+ {
+ try
+ {
+ result.SetResult(await action());
+ }
+ catch (Exception ex)
+ {
+ result.SetException(ex);
+ }
+ }, null);
+ return result.Task;
+ }
+
+ public void Dispose()
+ {
+ _queue.CompleteAdding();
+ if (!_thread.Join(TimeSpan.FromSeconds(30)))
+ throw new TimeoutException("The single-thread context did not stop.");
+ _queue.Dispose();
+ }
+ }
+
// ───────────────────────── Helpers ─────────────────────────
private sealed class FolderAllocator(string folder) : IOutputAllocator
diff --git a/tests/Filee.Engines.Tests/MediaTests.cs b/tests/Filee.Engines.Tests/MediaTests.cs
index dba6c819..9fbc757b 100644
--- a/tests/Filee.Engines.Tests/MediaTests.cs
+++ b/tests/Filee.Engines.Tests/MediaTests.cs
@@ -5,6 +5,7 @@
using System.Buffers.Binary;
using System.Diagnostics;
using System.Globalization;
+using System.Text;
using System.Text.Json;
using Filee.Core.Conversion;
using Filee.Core.Formats;
@@ -305,6 +306,30 @@ await RunAsync(Tools!.Value.Ffmpeg, ["-hide_banner", "-nostdin", "-v", "error",
Assert.Single(probed.Streams, s => s.Type == "video");
}
+ [Fact]
+ public async Task A_video_whose_first_sound_ffprobe_cannot_name_keeps_the_next_one()
+ {
+ SkipUnlessInstalled();
+ var video = Path.Combine(fx.NewFolder(), "clip.mov");
+ await RunAsync(Tools!.Value.Ffmpeg, ["-hide_banner", "-nostdin", "-v", "error", "-y",
+ "-f", "lavfi", "-i", "testsrc2=size=64x64:rate=10:duration=2",
+ "-f", "lavfi", "-i", "sine=frequency=440:sample_rate=48000:duration=2",
+ "-f", "lavfi", "-i", "sine=frequency=880:sample_rate=44100:duration=2",
+ "-map", "0:v", "-map", "1:a", "-map", "2:a", "-ac:a:0", "4",
+ "-c:v", "mjpeg", "-c:a", "aac", "-f", "mov", video]);
+ WithoutDecoder(video, tag: "zzzz");
+ var result = await ProcessRunner.RunAsync(Tools.Value.Ffprobe,
+ ["-v", "error", "-select_streams", "a", "-show_entries", "stream=codec_name", "-of", "json", video],
+ TimeSpan.FromMinutes(1), CancellationToken.None);
+ Assert.True(result.ExitCode == 0, result.StandardError);
+ using var json = JsonDocument.Parse(result.StandardOutput);
+ Assert.Equal([null, "aac"], json.RootElement.GetProperty("streams").EnumerateArray()
+ .Select(s => s.TryGetProperty("codec_name", out var codec) ? codec.GetString() : null));
+ var probed = await ProbeAsync(await ConvertAsync(video, "mkv"));
+ Assert.Equal(1, Assert.Single(probed.Streams, s => s.Type == "audio").Channels);
+ Assert.Single(probed.Streams, s => s.Type == "video");
+ }
+
[Fact]
public async Task A_video_whose_later_sound_ffmpeg_cannot_decode_keeps_only_the_first()
{
@@ -343,8 +368,9 @@ await RunAsync(Tools!.Value.Ffmpeg, ["-hide_banner", "-nostdin", "-v", "error",
Assert.InRange(probed.Duration, 3.5, 4.6);
}
- /// Marks one AAC QuickTime track as APAC, which the bundled FFmpeg identifies but cannot decode.
- internal static void WithoutDecoder(string path, int track = 0)
+ /// Marks one AAC QuickTime track as APAC, which the bundled FFmpeg identifies but cannot decode,
+ /// or a tag ffprobe knows no codec for ("zzzz").
+ internal static void WithoutDecoder(string path, int track = 0, string tag = "apac")
{
var bytes = File.ReadAllBytes(path);
var at = 0;
@@ -354,7 +380,7 @@ internal static void WithoutDecoder(string path, int track = 0)
Assert.True(index >= 0, "AAC sample entry not found.");
at += index;
if (i >= track * 3)
- "apac"u8.CopyTo(bytes.AsSpan(at));
+ Encoding.ASCII.GetBytes(tag).CopyTo(bytes.AsSpan(at));
at += 4;
}
at = 0;
@@ -910,7 +936,7 @@ public void Probe_skips_audio_ffmpeg_cannot_decode()
var decodable = new HashSet { "h264", "aac" };
var info = MediaInfo.Parse(json, decodable);
Assert.Equal(new AudioStreamInfo(0, 0, "aac", 1), info.Audio);
- Assert.Equal([1, 2, 3], info.AudioTracks.Select(t => t.Ordinal));
+ Assert.Equal([1, 2], info.AudioTracks.Select(t => t.Ordinal));
var unknown = MediaInfo.Parse(json);
Assert.Equal(new AudioStreamInfo(4, 48000, "apple_apac"), unknown.Audio);
Assert.Equal([0, 1, 2, 3], unknown.AudioTracks.Select(t => t.Ordinal));
@@ -919,6 +945,9 @@ public void Probe_skips_audio_ffmpeg_cannot_decode()
""", decodable);
Assert.Equal(new AudioStreamInfo(4, 48000, "apple_apac"), only.Audio);
Assert.Equal([0], only.AudioTracks.Select(t => t.Ordinal));
+ var unnamed = MediaInfo.Parse("""{ "streams": [{ "codec_type": "audio", "channels": 2 }] }""", decodable);
+ Assert.Equal(0, unnamed.Audio!.Ordinal);
+ Assert.Equal([0], unnamed.AudioTracks.Select(t => t.Ordinal));
}
[Fact]