Audio and video are processed off the request

Every audio and video upload was probed and remuxed inside the request, whatever its length; ffmpeg would read any
protocol and probe any format; `-map 0` kept the data tracks iPhones add, which mp4 refuses; nothing was ever
transcoded, so HEVC or MPEG-4 Part 2 reached browsers that can't play them, and the advertised video_matrix_limit
and frame rate limit were never applied; the output was read whole into memory, the video was saved before its
poster could fail, and the poster's frame leaked in /tmp. FLAC uploads were served as 404.

Now an upload sent to /api/v2/media is stored as sent in media-incoming (beside the media root, never served) and
answered with 202 and no url, while a ProcessMedia job, one at a time, does the work; GET /api/v1/media/:id answers 206
until it is ready, or 422 with why, and media still processing can't be posted. v1 processes before answering.
ffmpeg reads only that file (protocol whitelist, format forced from the probe) and drops data and subtitle tracks. A
video browsers play as it is (H.264, VP8, VP9, AV1 within Media:MaxVideoPixels and MaxFrameRate) is remuxed, anything
else transcoded to H.264 that fits, as Mastodon does; longer than Media:MaxSeconds is refused. Outputs move into place
only once everything succeeded, every temporary file goes, durations are kept, FLAC is served as audio/flac, and the
unit gets PrivateTmp. The instance API advertises the limits that are now applied.

No pasture scenario uploads audio or video through PrivaPub, so the sweep could not see this. MastodonMediaTests: v2
answers 202 then the job makes it playable (and an unreadable file 422 once processed), media still processing can't
be posted, MPEG-4 Part 2 becomes H.264, a video over the limit is made smaller, FLAC is served.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
This commit is contained in:
thepraandClaude Opus 5.5 committed 2026-10-07 10:48:56 +02:00
1 parent e4f9d0b61e
commit 8e59145826
14 files changed
+281 -48

No files matched your search

@@ -56,10 +56,10 @@ namespace PrivaPub.Api.Mastodon.Controllers
{
supported_mime_types = PrivaPub.Domain.Media.MediaService.SupportedTypes,
image_size_limit = media.MaxImageBytes,
image_matrix_limit = media.MaxImageSide * media.MaxImageSide,
image_matrix_limit = media.MaxPixels,
video_size_limit = media.MaxVideoBytes,
video_frame_rate_limit = 60,
video_matrix_limit = 2_304_000
video_frame_rate_limit = (int)media.MaxFrameRate,
video_matrix_limit = media.MaxVideoPixels
};
}
}
@@ -9,6 +9,7 @@ using PrivaPub.Api.Mastodon.Infrastructure;
using PrivaPub.Domain.Media;
using PrivaPub.Models.Media;
using PrivaPub.Infrastructure;
using PrivaPub.Infrastructure.Jobs;
using System.Globalization;
@@ -20,13 +21,15 @@ namespace PrivaPub.Api.Mastodon.Controllers
readonly IMediaService _media;
readonly IMediaProxy _proxy;
readonly IJobQueue _jobs;
readonly IInteractionLedger _ledger;
public MediaController(IMediaService media, IMediaProxy proxy, IInteractionLedger ledger = default)
public MediaController(IMediaService media, IMediaProxy proxy, IJobQueue jobs, IInteractionLedger ledger = default)
{
_media = media;
_proxy = proxy;
_jobs = jobs;
_ledger = ledger;
}
@@ -37,15 +40,28 @@ namespace PrivaPub.Api.Mastodon.Controllers
if (!Request.HasFormContentType)
return Error(StatusCodes.Status422UnprocessableEntity, "Validation failed: File can't be blank");
var form = await Request.ReadFormAsync(token);
var outcome = await _media.Upload(Me, form.Files["file"], form["description"], form["focus"], token);
return outcome.Ok ? Json(View(outcome.Attachment)) : Error(outcome.Status, outcome.Error);
// v2 may answer before audio or video is processed (202, its url null until then), as Mastodon does; v1 waits
var later = Request.Path.StartsWithSegments("/api/v2/media");
var outcome = await _media.Upload(Me, form.Files["file"], form["description"], form["focus"], later, token);
if (!outcome.Ok)
return Error(outcome.Status, outcome.Error);
if (outcome.Attachment.ProcessingState != "pending")
return Json(View(outcome.Attachment));
await _jobs.EnqueueMany(new[] { ProcessMediaJob.JobFor(outcome.Attachment, new Uri(Me.BaseAddress).Host) }, token);
return new JsonResult(View(outcome.Attachment)) { StatusCode = StatusCodes.Status202Accepted };
}
[HttpGet("/api/v1/media/{id}"), Scope("write:media")]
public async Task<IActionResult> Get(string id, CancellationToken token)
{
var attachment = await DB.Default.Find<MediaAttachment>().Match(m => m.ID == id && m.OwnerAvatarId == MyId && m.TrashedAt == null && m.ProfileOfAvatarId == null).ExecuteFirstAsync(token);
return attachment == default ? NotFoundError() : Json(View(attachment));
return attachment?.ProcessingState switch
{
null when attachment == default => NotFoundError(),
"pending" => new JsonResult(View(attachment)) { StatusCode = StatusCodes.Status206PartialContent },
"failed" => Error(StatusCodes.Status422UnprocessableEntity, attachment.ProcessingError ?? "Validation failed: The file could not be processed"),
_ => Json(View(attachment))
};
}
[HttpPut("/api/v1/media/{id}"), Scope("write:media")]