#nullable enable using System; using Sdcb.FFmpeg.Codecs; using Sdcb.FFmpeg.Formats; using Sdcb.FFmpeg.Raw; namespace T3.VideoServices; /// /// Decides whether a video source actually benefits from a seek-proxy. A proxy only helps long-GOP /// inter-frame video (H.264/HEVC/VP9/…), where a random seek must decode from the previous keyframe; all-intra /// sources (ProRes / DNxHR / MJPEG / HAP) and short-GOP files already seek cheaply and are skipped. /// /// The test is codec-agnostic: it measures the actual keyframe spacing from the bitstream, so an /// I-frame-only H.264 is correctly reported "not needed" while a long-GOP HEVC is "recommended". Demux-only — /// it reads packet headers, never decodes a frame — so it's cheap and runs off the editor's enqueue path. /// public static class ProxyEligibility { public enum Recommendation { /// Already seek-cheap (all-intra or short-GOP) — generating a proxy would waste disk and time. NotNeeded, /// Long-GOP inter-frame video — a proxy makes scrubbing/seeking cheap. Recommended, /// Couldn't tell (file unreadable, no video stream, no usable timestamps). Unknown, } /// Keyframes more than this far apart make a clip worth proxying: a random seek has to decode up to /// this many seconds of frames, which a proxy eliminates. The line sits well above all-intra (one keyframe per /// frame ≈ 1/fps ≈ 0.03 s) so ProRes/HAP/MJPEG are skipped, yet below the ~1 s GOP common to camera and /// screen-recording H.264/HEVC so those are caught. Tunable — could later scale with resolution. public const double LongGopSeconds = 0.75; public readonly record struct Result(Recommendation Recommendation, double KeyframeIntervalSeconds, string Reason); /// Probes and reports whether a seek-proxy is worth generating. /// Never throws — failures map to . public static Result Evaluate(string sourcePath) { if (!FfmpegLibrary.EnsureInitialized()) return new Result(Recommendation.Unknown, 0, FfmpegLibrary.StatusError ?? "FFmpeg is not available"); FormatContext? formatContext = null; try { formatContext = FormatContext.OpenInputUrl(sourcePath, null, null); formatContext.LoadStreamInfo(); var stream = formatContext.FindBestStreamOrNull(AVMediaType.Video); if (stream == null) return new Result(Recommendation.Unknown, 0, "No video stream found"); var videoStream = stream.Value; var timeBase = videoStream.TimeBase; var secondsPerTick = timeBase.Den != 0 ? timeBase.Num / (double)timeBase.Den : 0; if (secondsPerTick <= 0) return new Result(Recommendation.Unknown, 0, "Video stream has no usable time base"); var codecName = CodecName(videoStream); var (interval, capped, found) = MeasureKeyframeInterval(formatContext, videoStream.Index, secondsPerTick); if (!found) return new Result(Recommendation.Unknown, 0, $"Could not measure keyframe spacing of {codecName}"); // Capped before EOF with a single keyframe ⇒ the GOP is longer than the whole sampled span: clearly long. if (capped || interval > LongGopSeconds) return new Result(Recommendation.Recommended, interval, $"Long-GOP {codecName} (~{interval:0.0}s between keyframes) — a proxy speeds up seeking"); return new Result(Recommendation.NotNeeded, interval, $"{codecName} already seeks cheaply (~{interval:0.0}s between keyframes) — no proxy needed"); } catch (Exception e) { return new Result(Recommendation.Unknown, 0, "Could not read video: " + e.Message); } finally { formatContext?.Dispose(); } } // Reads packet headers until it finds two keyframes (the gap = GOP length) or hits the sample cap. Returns the // interval in seconds, whether it stopped at the cap (GOP longer than the sample), and whether anything usable // was found. A single keyframe at EOF means a short single-GOP file — its sampled span is the interval. private static (double interval, bool capped, bool found) MeasureKeyframeInterval( FormatContext formatContext, int streamIndex, double secondsPerTick) { var packet = new Packet(); var firstKeyTs = NoPts; var lastTs = NoPts; var secondKeyTs = NoPts; var videoPacketsSeen = 0; var reachedEof = false; try { while (videoPacketsSeen < MaxSampledPackets) { if (formatContext.ReadFrame(packet) == CodecResult.EOF) { reachedEof = true; break; } try { if (packet.StreamIndex != streamIndex) continue; videoPacketsSeen++; var ts = packet.Dts != NoPts ? packet.Dts : packet.Pts; if (ts != NoPts) lastTs = ts; if (((int)packet.Flags & ffmpeg.AV_PKT_FLAG_KEY) == 0 || ts == NoPts) continue; if (firstKeyTs == NoPts) { firstKeyTs = ts; } else { secondKeyTs = ts; break; } } finally { packet.Unref(); } } } finally { packet.Dispose(); } if (firstKeyTs == NoPts) return (0, false, false); if (secondKeyTs != NoPts) return (Math.Abs(secondKeyTs - firstKeyTs) * secondsPerTick, false, true); // Only one keyframe in the sample: long-GOP if we stopped at the cap, otherwise a short single-GOP file // whose whole span is one GOP. var span = lastTs != NoPts ? Math.Abs(lastTs - firstKeyTs) * secondsPerTick : 0; return (span, capped: !reachedEof, found: true); } private static string CodecName(MediaStream videoStream) { var id = videoStream.Codecpar?.CodecId ?? AVCodecID.None; try { var name = Codec.FindDecoderById(id).Name; return string.IsNullOrEmpty(name) ? id.ToString() : name; } catch { return id.ToString(); } } // ~40 s of 30 fps content: far beyond any sane GOP, so failing to find a second keyframe within it means the // stream really is one very long GOP. private const int MaxSampledPackets = 1200; private static readonly long NoPts = ffmpeg.AV_NOPTS_VALUE; }