#nullable enable using System; using System.Collections.Concurrent; using System.Collections.Generic; using System.IO; using System.Threading; using System.Threading.Tasks; using T3.Core.Logging; using T3.Core.Model; using T3.Core.Settings; using T3.Core.Video; using T3.Editor.Gui.UiHelpers; namespace T3.Editor.Gui.Windows.RenderExport; /// /// Editor-side background queue that transcodes video sources into seek-friendly proxies, one at a time. The /// actual decode→encode runs in the video assembly ('s /// GenerateProxy); this owns the queue, the sibling-file path, and progress/status. Format and resolution /// come from the per-project . Editor-only — the player never /// generates proxies. /// internal static class ProxyGenerationService { internal enum JobState { Queued, Generating, Done, Failed } internal readonly record struct JobStatus(JobState State, float Progress, string? Error); /// Per-source status, for UI feedback. public static IReadOnlyDictionary Jobs => _jobs; /// The sibling proxy path for a source (e.g. clip.mp4clip.proxy.mov). Shared with the /// playback engine via so generation and lookup never diverge. public static string ProxyPathFor(string sourcePath) => VideoPlayback.GetProxyPath(sourcePath); /// True when the proxy's target drive has at least the per-machine minimum free space /// (). Returns true (don't block) when the drive can't /// be queried. / are for the UI message. public static bool HasEnoughFreeDisk(string sourcePath, out long freeBytes, out long requiredBytes) { requiredBytes = (long)(Math.Max(0f, UserSettings.Config.ProxyMinFreeDiskGb) * 1_000_000_000L); freeBytes = long.MaxValue; try { var root = Path.GetPathRoot(Path.GetFullPath(ProxyPathFor(sourcePath))); if (string.IsNullOrEmpty(root)) return true; freeBytes = new DriveInfo(root).AvailableFreeSpace; return freeBytes >= requiredBytes; } catch { return true; // a probe failure shouldn't block generation } } /// Queues a proxy generation for using the current settings. No-op if /// one is already queued or running for that source. public static void Enqueue(string sourcePath) { if (string.IsNullOrWhiteSpace(sourcePath) || !File.Exists(sourcePath)) { Log.Warning($"Can't generate proxy — source not found: '{sourcePath}'"); return; } if (!HasEnoughFreeDisk(sourcePath, out var freeBytes, out var requiredBytes)) { var msg = $"Not enough free disk to generate a proxy ({freeBytes / 1e9:0.#} GB free, " + $"{requiredBytes / 1e9:0.#} GB required)."; _jobs[sourcePath] = new JobStatus(JobState.Failed, 0, msg); Log.Warning(msg + $" Source: '{sourcePath}'"); return; } if (_jobs.TryGetValue(sourcePath, out var existing) && existing.State is JobState.Queued or JobState.Generating) return; _jobs[sourcePath] = new JobStatus(JobState.Queued, 0, null); _queue.Enqueue(sourcePath); if (Interlocked.CompareExchange(ref _workerRunning, 1, 0) == 0) Task.Run(ProcessQueue); } private static void ProcessQueue() { try { while (_queue.TryDequeue(out var source)) Process(source); } finally { Interlocked.Exchange(ref _workerRunning, 0); // Something may have been enqueued between the empty-check and the flag reset — pick it up. if (!_queue.IsEmpty && Interlocked.CompareExchange(ref _workerRunning, 1, 0) == 0) Task.Run(ProcessQueue); } } private static void Process(string source) { var factory = ResolveFactory(); if (factory == null) { _jobs[source] = new JobStatus(JobState.Failed, 0, "Video package not loaded."); Log.Warning($"Can't generate proxy for '{source}': the video package isn't loaded."); return; } var proxySettings = CompositionSettings.Current.Proxy; var codec = proxySettings.Format; var scale = Math.Clamp(proxySettings.Resolution, 0.1f, 1f); var proxyPath = ProxyPathFor(source); _jobs[source] = new JobStatus(JobState.Generating, 0, null); var lastLoggedQuarter = -1; var progress = new SynchronousProgress(p => { _jobs[source] = new JobStatus(JobState.Generating, (float)p, null); var quarter = (int)(p * 4); // log at 25/50/75 % (gated) if (quarter > lastLoggedQuarter && quarter is > 0 and < 4) { lastLoggedQuarter = quarter; Log.Gated.VideoRender($"Proxy {Path.GetFileName(source)}: {p * 100:0}%"); } }); var error = factory.GenerateProxy(source, proxyPath, codec, scale, progress, CancellationToken.None); if (error == null) { _jobs[source] = new JobStatus(JobState.Done, 1, null); Log.Gated.VideoRender($"Proxy ready: {proxyPath}"); } else { _jobs[source] = new JobStatus(JobState.Failed, 0, error); Log.Warning($"Proxy generation failed for '{source}': {error}"); } } // The factory registers via the Video package's [ModuleInitializer]; force it if a project hasn't touched a // video op yet (same nudge the render-export path uses). private static IVideoEncoderFactory? ResolveFactory() { if (VideoExport.Factory != null) return VideoExport.Factory; foreach (var package in SymbolPackage.AllPackages) package.AssemblyInformation.RunModuleInitializers(); return VideoExport.Factory; } // Reports synchronously on the calling (worker) thread, so progress can't race past the final status. private sealed class SynchronousProgress(Action report) : IProgress { public void Report(double value) => report(value); } private static readonly ConcurrentDictionary _jobs = new(); private static readonly ConcurrentQueue _queue = new(); private static int _workerRunning; }