using T3.Core.Utils; namespace T3.VideoServices; /// /// Pure, deterministic mapping from a requested playback time (seconds) to a stream presentation /// timestamp (PTS). Holds no D3D or FFmpeg state so it can be unit-tested in isolation. This is the core /// that guarantees the same double time always resolves to the same frame, whether paused or playing. /// public static class TimeToFrameMapper { /// /// Resolve a requested time against the clip duration. Looping wraps modulo duration (negatives wrap /// forward); otherwise the time is clamped to [0, duration] so out-of-range requests resolve to /// the first/last frame. /// public static double ResolvePlaybackSeconds(double requestedSeconds, double durationSeconds, bool loop) { if (durationSeconds <= 0) return 0; return loop ? MathUtils.Fmod(requestedSeconds, durationSeconds) : Math.Clamp(requestedSeconds, 0, durationSeconds); } /// /// Floor-to-PTS: the presentation timestamp of the frame whose interval [pts, pts+1) contains /// . Flooring (never rounding) is what makes a paused seek and a playing /// pass-through land on the identical frame. is the stream's /// start_time (often 0); the time base is /. /// public static long SecondsToPts(double seconds, long streamStartPts, int timeBaseNum, int timeBaseDen) { if (timeBaseNum <= 0 || timeBaseDen <= 0) return streamStartPts; // seconds = (pts - start) * num/den => pts = start + floor(seconds * den / num) var ticks = (long)Math.Floor(seconds * timeBaseDen / timeBaseNum); return streamStartPts + ticks; } /// /// Like but snapped to the frame grid: the PTS of the frame whose display /// interval contains , assuming constant . Keeping the /// target on actual frame boundaries means a decoder that stops at the first pts >= target lands /// exactly on the displayed frame instead of overshooting to the next one — and repeated requests within /// one frame resolve to the same target. Without this, a display refresh that outruns the video frame rate /// (e.g. 24 fps clip on a 60 Hz editor) overshoots, then seeks backward every frame and re-decodes the GOP. /// Falls back to per-tick flooring when fps is unknown. /// public static long SecondsToFramePts(double seconds, long streamStartPts, int timeBaseNum, int timeBaseDen, double fps) { if (fps <= 0 || timeBaseNum <= 0 || timeBaseDen <= 0) return SecondsToPts(seconds, streamStartPts, timeBaseNum, timeBaseDen); // The frame index FLOORS the time to the displayed frame; its PTS must then land exactly on that frame's // boundary, so ROUND the conversion (not floor). Flooring here lets float error at the boundary push the // target a tick into the previous frame, so the decoder lands one frame early and disagrees with a cache // lookup that floored the same time to this index — a reproducible 1-frame split at certain times. var frameIndex = Math.Floor(seconds * fps); var ticks = (long)Math.Round(frameIndex / fps * timeBaseDen / timeBaseNum); return streamStartPts + ticks; } /// Inverse of , for diagnostics and clamping. public static double PtsToSeconds(long pts, long streamStartPts, int timeBaseNum, int timeBaseDen) { if (timeBaseDen <= 0) return 0; return (pts - streamStartPts) * (double)timeBaseNum / timeBaseDen; } }