diff mbox series

[FFmpeg-devel] movenc: Add an option for hiding fragments at the end

Message ID 20240531085358.45478-1-martin@martin.st
State Superseded
Headers show
Series [FFmpeg-devel] movenc: Add an option for hiding fragments at the end | expand

Checks

Context Check Description
andriy/configure_x86 warning Failed to apply patch
yinshiyou/configure_loongarch64 warning Failed to apply patch

Commit Message

Martin Storsjö May 31, 2024, 8:53 a.m. UTC
This allows ending up with a normal, non-fragmented file when
the file is finished, while keeping the file readable if writing
is aborted abruptly at any point. (Normally when writing a
mov/mp4 file, the unfinished file is completely useless unless it
is finished properly.)

This results in a file where the mdat atom contains (and hides)
all the moof atoms that were part of the fragmented file structure
initially.
---
This is, incidentally, how Apple devices do (or at least did, at some
point) their writing of files when recording, at least with some
of their userspace APIs.
---
 doc/muxers.texi               |  7 +++++
 libavformat/movenc.c          | 59 ++++++++++++++++++++++++++++++++---
 libavformat/movenc.h          |  4 ++-
 libavformat/version.h         |  4 +--
 tests/fate/lavf-container.mak |  3 +-
 tests/ref/lavf/mov_hide_frag  |  3 ++
 6 files changed, 71 insertions(+), 9 deletions(-)
 create mode 100644 tests/ref/lavf/mov_hide_frag

Comments

Timo Rothenpieler May 31, 2024, 12:18 p.m. UTC | #1
On 31/05/2024 10:53, Martin Storsjö wrote:
> This allows ending up with a normal, non-fragmented file when
> the file is finished, while keeping the file readable if writing
> is aborted abruptly at any point. (Normally when writing a
> mov/mp4 file, the unfinished file is completely useless unless it
> is finished properly.)
> 
> This results in a file where the mdat atom contains (and hides)
> all the moof atoms that were part of the fragmented file structure
> initially.
> ---
> This is, incidentally, how Apple devices do (or at least did, at some
> point) their writing of files when recording, at least with some
> of their userspace APIs.
> ---
>   doc/muxers.texi               |  7 +++++
>   libavformat/movenc.c          | 59 ++++++++++++++++++++++++++++++++---
>   libavformat/movenc.h          |  4 ++-
>   libavformat/version.h         |  4 +--
>   tests/fate/lavf-container.mak |  3 +-
>   tests/ref/lavf/mov_hide_frag  |  3 ++
>   6 files changed, 71 insertions(+), 9 deletions(-)
>   create mode 100644 tests/ref/lavf/mov_hide_frag
> 
> diff --git a/doc/muxers.texi b/doc/muxers.texi
> index 6340c8e54d..e313b5e631 100644
> --- a/doc/muxers.texi
> +++ b/doc/muxers.texi
> @@ -569,6 +569,13 @@ experimental, may be renamed or changed, do not use from scripts.
>   
>   @item write_gama
>   write deprecated gama atom
> +
> +@item hide_fragments
> +After writing a fragmented file, convert it to a regular, non-fragmented
> +file at the end. This keeps the file readable while it is being
> +written, and makes it recoverable if the process of writing the file
> +gets aborted uncleanly, while still producing an easily seekable
> +and widely compatible non-fragmented file in the end.
>   @end table

I somehow feel like calling the option like this would not help an 
"unsuspecting user" to find it, cause it's not immediately obvious that 
it solves the issue it does.
Though I don't immediately have a better idea either. Something like 
"safe_recording"? "crash_resilience"?
Can't say I'm a fan of those either.
Martin Storsjö May 31, 2024, 12:38 p.m. UTC | #2
On Fri, 31 May 2024, Timo Rothenpieler wrote:

>
>
> On 31/05/2024 10:53, Martin Storsjö wrote:
>> This allows ending up with a normal, non-fragmented file when
>> the file is finished, while keeping the file readable if writing
>> is aborted abruptly at any point. (Normally when writing a
>> mov/mp4 file, the unfinished file is completely useless unless it
>> is finished properly.)
>> 
>> This results in a file where the mdat atom contains (and hides)
>> all the moof atoms that were part of the fragmented file structure
>> initially.
>> ---
>> This is, incidentally, how Apple devices do (or at least did, at some
>> point) their writing of files when recording, at least with some
>> of their userspace APIs.
>> ---
>>   doc/muxers.texi               |  7 +++++
>>   libavformat/movenc.c          | 59 ++++++++++++++++++++++++++++++++---
>>   libavformat/movenc.h          |  4 ++-
>>   libavformat/version.h         |  4 +--
>>   tests/fate/lavf-container.mak |  3 +-
>>   tests/ref/lavf/mov_hide_frag  |  3 ++
>>   6 files changed, 71 insertions(+), 9 deletions(-)
>>   create mode 100644 tests/ref/lavf/mov_hide_frag
>> 
>> diff --git a/doc/muxers.texi b/doc/muxers.texi
>> index 6340c8e54d..e313b5e631 100644
>> --- a/doc/muxers.texi
>> +++ b/doc/muxers.texi
>> @@ -569,6 +569,13 @@ experimental, may be renamed or changed, do not use 
> from scripts.
>>
>>   @item write_gama
>>   write deprecated gama atom
>> +
>> +@item hide_fragments
>> +After writing a fragmented file, convert it to a regular, non-fragmented
>> +file at the end. This keeps the file readable while it is being
>> +written, and makes it recoverable if the process of writing the file
>> +gets aborted uncleanly, while still producing an easily seekable
>> +and widely compatible non-fragmented file in the end.
>>   @end table
>
> I somehow feel like calling the option like this would not help an 
> "unsuspecting user" to find it, cause it's not immediately obvious that 
> it solves the issue it does.
> Though I don't immediately have a better idea either. Something like 
> "safe_recording"? "crash_resilience"?
> Can't say I'm a fan of those either.


Yeah, those don't seem better either - and they stray from a somewhat 
precise technical definition of what it does, into a vauge guess at what 
the user wants.

On that note, from the point of view of setting the option that way, the 
option should probably imply fragmentation (and if no fragmentation method 
is enabled, e.g. no specific time interval etc, it could default to 
fragmenting on keyframes or similar), instead of as right now, when it 
requires you to specify fragmentation separately (which requires the user 
even more to know what they're doing).

// Martin
Dennis Sädtler June 1, 2024, 12:38 p.m. UTC | #3
On 2024-05-31 10:53, Martin Storsjö wrote:
> This allows ending up with a normal, non-fragmented file when
> the file is finished, while keeping the file readable if writing
> is aborted abruptly at any point. (Normally when writing a
> mov/mp4 file, the unfinished file is completely useless unless it
> is finished properly.)
>
> This results in a file where the mdat atom contains (and hides)
> all the moof atoms that were part of the fragmented file structure
> initially.
> ---
> This is, incidentally, how Apple devices do (or at least did, at some
> point) their writing of files when recording, at least with some
> of their userspace APIs.
> ---
Should the ftyp atom also be updated to remove brands no longer required 
for non-fragmented files?
I'm not sure how important that is in real-world scenarios, so it might 
not be worth it to deal with some of the additional changes required 
e.g. to deal with the new ftyp possibly being a different size.

Since coincidentally I've implemented the exact same feature in a 
different application a couple weeks ago I'll also throw in the fun fact 
that files produced this way can be smaller than regular MP4s for long 
and/or large files.
This is due to the lack of interleaving of A/V samples resulting in the 
file having much fewer but larger chunks, which means the moov atom - 
mainly the stco/co64 and stsc boxes - can be much smaller.

Also good to know that Apple thought of this as well. I had no idea 
about that, but that further justifies adopting this method for 
achieving resilient but compatible recordings in my mind.

~ Dennis
Martin Storsjö June 2, 2024, 7:36 p.m. UTC | #4
On Sat, 1 Jun 2024, Dennis Sädtler via ffmpeg-devel wrote:

> Should the ftyp atom also be updated to remove brands no longer required 
> for non-fragmented files?
> I'm not sure how important that is in real-world scenarios, so it might 
> not be worth it to deal with some of the additional changes required 
> e.g. to deal with the new ftyp possibly being a different size.

Hmm, good point, I hadn't thought about that. I'd prefer not to do that, 
as it becomes a bit more of a mess to change the size of the ftyp.

If we mux a plain default mp4 with h264/aac, we produce this ftyp:

         major_brand = isom : ISO Base Media file format version 1
         minor_version = 512
         compatible_brands
             brand[0] = isom : ISO Base Media file format version 1
             brand[1] = iso2 : ISO Base Media file format version 2
             brand[2] = avc1 : Advanced Video Coding extensions
             brand[3] = mp41 : MP4 version 1

If we add -movflags frag_keyframe, we produce this:

         major_brand = isom : ISO Base Media file format version 1
         minor_version = 512
         compatible_brands
             brand[0] = isom : ISO Base Media file format version 1
             brand[1] = iso6 : ISO Base Media file format version 6
             brand[2] = iso2 : ISO Base Media file format version 2
             brand[3] = avc1 : Advanced Video Coding extensions
             brand[4] = mp41 : MP4 version 1

This has one extra entry in compatible_brands, but it shouldn't affect the 
baseline for what demuxers accept reading the file or not. However if we 
add e.g. "-movflags frag_keyframe+cmaf" (or negative_cts_offsets, or 
default_base_moof), we end up with something like this:

         major_brand = iso6 : ISO Base Media file format version 6
         minor_version = 512
         compatible_brands
             brand[0] = iso6 : ISO Base Media file format version 6
             brand[1] = cmfc
             brand[2] = mp41 : MP4 version 1

So if using this hybrid fragmented/non-fragmented mode, it'd be wise to 
not enable any of those options.

> Since coincidentally I've implemented the exact same feature in a 
> different application a couple weeks ago I'll also throw in the fun fact 
> that files produced this way can be smaller than regular MP4s for long 
> and/or large files.
> This is due to the lack of interleaving of A/V samples resulting in the 
> file having much fewer but larger chunks, which means the moov atom - 
> mainly the stco/co64 and stsc boxes - can be much smaller.

Oh, indeed, that's a good point. But on the other hand, the file ends up 
containing all the leftover moof boxes in the mdat. But are you saying 
that a compact moov + leftover moof, still is smaller than one large moov, 
in your practical test cases?

> Also good to know that Apple thought of this as well. I had no idea 
> about that, but that further justifies adopting this method for 
> achieving resilient but compatible recordings in my mind.

Indeed!

Btw, the patch in this form has one minimal time gap for when the file can 
end up unrecoverable; we patch the mdat size (hiding the moof boxes) 
before we write the moov - if we die at that specific moment, we'd have an 
unreadable file. I guess it should be possible to reorder these two calls 
as well - but it makes for a slightly bigger patch.

// Martin
Dennis Sädtler June 2, 2024, 8:45 p.m. UTC | #5
On 2024-06-02 21:36, Martin Storsjö wrote:
> On Sat, 1 Jun 2024, Dennis Sädtler via ffmpeg-devel wrote:
>
>> Should the ftyp atom also be updated to remove brands no longer 
>> required for non-fragmented files?
>> I'm not sure how important that is in real-world scenarios, so it 
>> might not be worth it to deal with some of the additional changes 
>> required e.g. to deal with the new ftyp possibly being a different size.
>
> Hmm, good point, I hadn't thought about that. I'd prefer not to do 
> that, as it becomes a bit more of a mess to change the size of the ftyp.

Indeed, though as far as I can tell only the offset and size of the mdat 
would need to be changed, since everything past the ftyp is "disposable" 
anyway.
But as I initially mentioned, I have no idea if there are real-word 
cases of players refusing a file purely based on those brands.

>> Since coincidentally I've implemented the exact same feature in a 
>> different application a couple weeks ago I'll also throw in the fun 
>> fact that files produced this way can be smaller than regular MP4s 
>> for long and/or large files.
>> This is due to the lack of interleaving of A/V samples resulting in 
>> the file having much fewer but larger chunks, which means the moov 
>> atom - mainly the stco/co64 and stsc boxes - can be much smaller.
>
> Oh, indeed, that's a good point. But on the other hand, the file ends 
> up containing all the leftover moof boxes in the mdat. But are you 
> saying that a compact moov + leftover moof, still is smaller than one 
> large moov, in your practical test cases?

Yep, that's what I meant! The initial ("empty") moov and following moof 
boxes aren't all that big, so once you go over a certain threshold 
(which I haven't calculated) this method ends up having a negative overhead.

I have a regular MP4 of a Twitch live stream (1 video + 1 audio track) 
where the moov alone ends up being ~40 MiB, so they can get quite large. 
That may be part of why the newer ISO-BMFF revisions actually have a 
feature for compressing moov/moof/sidx boxes (that nobody has 
implemented as far as I can tell).

> Btw, the patch in this form has one minimal time gap for when the file 
> can end up unrecoverable; we patch the mdat size (hiding the moof 
> boxes) before we write the moov - if we die at that specific moment, 
> we'd have an unreadable file. I guess it should be possible to reorder 
> these two calls as well - but it makes for a slightly bigger patch.

First writing the moov and then hiding the fragmentation is what I ended 
up doing in my implementation. Might be overkill, but would certainly 
make it the "safest" it can be.

In my implementation I also ended up changing the placeholder "free" 
atom at the start to be 16 bytes so that I could write the mdat header 
non-destructively and allow the fragmented structure to be preserved 
entirely. This was mostly done for easier manual recovery in case 
something goes wrong with the full moov, though again, probably overkill.

Finally, I've also had a somewhat cursed thought about having a second 
always-hidden ftyp before the initial moov, which would then allow you 
to use the same file for progressive download and DASH/HLS streaming by 
using range-requests (e.g. via BYTERANGE) to skip the first ftyp + mdat 
header for the init segment and then using the fragments as normal. 
Though that goes beyond the scope of this patch, I just had to get it 
out there in case anybody thinks that might actually be fun to try :P

~Dennis
Martin Storsjö June 3, 2024, 7:51 a.m. UTC | #6
On Sun, 2 Jun 2024, Dennis Sädtler wrote:

> On 2024-06-02 21:36, Martin Storsjö wrote:
>> On Sat, 1 Jun 2024, Dennis Sädtler via ffmpeg-devel wrote:
>> 
>>> Should the ftyp atom also be updated to remove brands no longer required 
>>> for non-fragmented files?
>>> I'm not sure how important that is in real-world scenarios, so it might 
>>> not be worth it to deal with some of the additional changes required e.g. 
>>> to deal with the new ftyp possibly being a different size.
>> 
>> Hmm, good point, I hadn't thought about that. I'd prefer not to do that, as 
>> it becomes a bit more of a mess to change the size of the ftyp.
>
> Indeed, though as far as I can tell only the offset and size of the mdat 
> would need to be changed, since everything past the ftyp is "disposable" 
> anyway.

Oh, right - yes, that's right, it wouldn't be all that hard to do it.

> But as I initially mentioned, I have no idea if there are real-word cases of 
> players refusing a file purely based on those brands.

Yeah, not sure. And anyway, it shouldn't be too hard to avoid using 
fragmentation modes that requires a higher major brand.

>>> Since coincidentally I've implemented the exact same feature in a 
>>> different application a couple weeks ago I'll also throw in the fun fact 
>>> that files produced this way can be smaller than regular MP4s for long 
>>> and/or large files.
>>> This is due to the lack of interleaving of A/V samples resulting in the 
>>> file having much fewer but larger chunks, which means the moov atom - 
>>> mainly the stco/co64 and stsc boxes - can be much smaller.
>> 
>> Oh, indeed, that's a good point. But on the other hand, the file ends up 
>> containing all the leftover moof boxes in the mdat. But are you saying that 
>> a compact moov + leftover moof, still is smaller than one large moov, in 
>> your practical test cases?
>
> Yep, that's what I meant! The initial ("empty") moov and following moof boxes 
> aren't all that big, so once you go over a certain threshold (which I haven't 
> calculated) this method ends up having a negative overhead.

Oh, that's interesting. (I guess it could be useful to do some sort of 
chunking when writing regular mp4s as well, to achieve the same sort of 
efficiency right away.)

> I have a regular MP4 of a Twitch live stream (1 video + 1 audio track) where 
> the moov alone ends up being ~40 MiB, so they can get quite large. That may 
> be part of why the newer ISO-BMFF revisions actually have a feature for 
> compressing moov/moof/sidx boxes (that nobody has implemented as far as I can 
> tell).

>> Btw, the patch in this form has one minimal time gap for when the file can 
>> end up unrecoverable; we patch the mdat size (hiding the moof boxes) before 
>> we write the moov - if we die at that specific moment, we'd have an 
>> unreadable file. I guess it should be possible to reorder these two calls 
>> as well - but it makes for a slightly bigger patch.
>
> First writing the moov and then hiding the fragmentation is what I ended up 
> doing in my implementation. Might be overkill, but would certainly make it 
> the "safest" it can be.

Yep, that'd be my idea as well. Not really overkill IMO, it's just that 
the existing code does it in this order, and changing it makes for a 
larger patch. Possibly as a later separate step maybe.

> In my implementation I also ended up changing the placeholder "free" atom at 
> the start to be 16 bytes so that I could write the mdat header 
> non-destructively and allow the fragmented structure to be preserved 
> entirely. This was mostly done for easier manual recovery in case something 
> goes wrong with the full moov, though again, probably overkill.

Oh, that sounds quite useful. Sounds like a good idea overall to have, 
maybe as a follow-up improvement here as well?

> Finally, I've also had a somewhat cursed thought about having a second 
> always-hidden ftyp before the initial moov, which would then allow you to use 
> the same file for progressive download and DASH/HLS streaming by using 
> range-requests (e.g. via BYTERANGE) to skip the first ftyp + mdat header for 
> the init segment and then using the fragments as normal. Though that goes 
> beyond the scope of this patch, I just had to get it out there in case 
> anybody thinks that might actually be fun to try :P

Oh, that sounds quite cursed indeed. I definitely can see the appeal of it 
:-) I guess it'd require some custom tooling to parse out the relevant 
byte ranges from it though (or maybe just listening to the avio marker 
callbacks?).

Anyway, Timo had thoughts about the name for this option/flag - do you 
have any suggestions to follow up with on that thread?

// Martin
Dennis Sädtler June 3, 2024, 9:38 a.m. UTC | #7
On 2024-06-03 09:51, Martin Storsjö wrote:
>> Finally, I've also had a somewhat cursed thought about having a 
>> second always-hidden ftyp before the initial moov, which would then 
>> allow you to use the same file for progressive download and DASH/HLS 
>> streaming by using range-requests (e.g. via BYTERANGE) to skip the 
>> first ftyp + mdat header for the init segment and then using the 
>> fragments as normal. Though that goes beyond the scope of this patch, 
>> I just had to get it out there in case anybody thinks that might 
>> actually be fun to try :P
>
> Oh, that sounds quite cursed indeed. I definitely can see the appeal 
> of it :-) I guess it'd require some custom tooling to parse out the 
> relevant byte ranges from it though (or maybe just listening to the 
> avio marker callbacks?).

It would at least require the DASH/HLS manifest creation tooling to 
understand this hack and skip the first N bytes. As I said this is kind 
of a fun idea but maybe not entirely practical.

> Anyway, Timo had thoughts about the name for this option/flag - do you 
> have any suggestions to follow up with on that thread?

Not really, I think "hide_fragments" is fine. Though perhaps the flag's 
description could explain what it does in a bit more detail.

For OBS I settled on "Hybrid MP4" and calling the process of hiding the 
fragments a "soft remux", but the name of the flag should probably be a 
bit more technical.

~Dennis
diff mbox series

Patch

diff --git a/doc/muxers.texi b/doc/muxers.texi
index 6340c8e54d..e313b5e631 100644
--- a/doc/muxers.texi
+++ b/doc/muxers.texi
@@ -569,6 +569,13 @@  experimental, may be renamed or changed, do not use from scripts.
 
 @item write_gama
 write deprecated gama atom
+
+@item hide_fragments
+After writing a fragmented file, convert it to a regular, non-fragmented
+file at the end. This keeps the file readable while it is being
+written, and makes it recoverable if the process of writing the file
+gets aborted uncleanly, while still producing an easily seekable
+and widely compatible non-fragmented file in the end.
 @end table
 
 @item movie_timescale @var{scale}
diff --git a/libavformat/movenc.c b/libavformat/movenc.c
index d1517870fc..6f044a62a7 100644
--- a/libavformat/movenc.c
+++ b/libavformat/movenc.c
@@ -110,6 +110,7 @@  static const AVOption options[] = {
       { "use_metadata_tags", "Use mdta atom for metadata.", 0, AV_OPT_TYPE_CONST, {.i64 = FF_MOV_FLAG_USE_MDTA}, INT_MIN, INT_MAX, AV_OPT_FLAG_ENCODING_PARAM, .unit = "movflags" },
       { "write_colr", "Write colr atom even if the color info is unspecified (Experimental, may be renamed or changed, do not use from scripts)", 0, AV_OPT_TYPE_CONST, {.i64 = FF_MOV_FLAG_WRITE_COLR}, INT_MIN, INT_MAX, AV_OPT_FLAG_ENCODING_PARAM, .unit = "movflags" },
       { "write_gama", "Write deprecated gama atom", 0, AV_OPT_TYPE_CONST, {.i64 = FF_MOV_FLAG_WRITE_GAMA}, INT_MIN, INT_MAX, AV_OPT_FLAG_ENCODING_PARAM, .unit = "movflags" },
+      { "hide_fragments", "Hide fragments at the end", 0, AV_OPT_TYPE_CONST, {.i64 = FF_MOV_FLAG_HIDE_FRAGMENTS}, INT_MIN, INT_MAX, AV_OPT_FLAG_ENCODING_PARAM, .unit = "movflags" },
     { "min_frag_duration", "Minimum fragment duration", offsetof(MOVMuxContext, min_fragment_duration), AV_OPT_TYPE_INT, {.i64 = 0}, 0, INT_MAX, AV_OPT_FLAG_ENCODING_PARAM},
     { "mov_gamma", "gamma value for gama atom", offsetof(MOVMuxContext, gamma), AV_OPT_TYPE_FLOAT, {.dbl = 0.0 }, 0.0, 10, AV_OPT_FLAG_ENCODING_PARAM},
     { "movie_timescale", "set movie timescale", offsetof(MOVMuxContext, movie_timescale), AV_OPT_TYPE_INT, {.i64 = MOV_TIMESCALE}, 1, INT_MAX, AV_OPT_FLAG_ENCODING_PARAM},
@@ -5993,10 +5994,30 @@  static int mov_write_squashed_packets(AVFormatContext *s)
     return 0;
 }
 
-static int mov_finish_fragment(MOVTrack *track)
+static int mov_finish_fragment(MOVMuxContext *mov, MOVTrack *track,
+                               int64_t ref_pos)
 {
+    int i;
     if (!track->entry)
         return 0;
+    if (mov->flags & FF_MOV_FLAG_HIDE_FRAGMENTS) {
+        for (i = 0; i < track->entry; i++)
+            track->cluster[i].pos += ref_pos + track->data_offset;
+        if (track->cluster_written == 0 && !(mov->flags & FF_MOV_FLAG_EMPTY_MOOV)) {
+            // First flush. If this was a case of not using empty moov, reset chunking.
+            for (i = 0; i < track->entry; i++) {
+                track->cluster[i].chunkNum = 0;
+                track->cluster[i].samples_in_chunk = track->cluster[i].entries;
+            }
+        }
+        if (av_reallocp_array(&track->cluster_written,
+                              track->entry_written + track->entry,
+                              sizeof(*track->cluster)))
+            return AVERROR(ENOMEM);
+        memcpy(&track->cluster_written[track->entry_written],
+               track->cluster, track->entry * sizeof(*track->cluster));
+        track->entry_written += track->entry;
+    }
     track->entry = 0;
     track->entries_flushed = 0;
     track->end_reliable = 0;
@@ -6007,7 +6028,7 @@  static int mov_flush_fragment(AVFormatContext *s, int force)
 {
     MOVMuxContext *mov = s->priv_data;
     int i, first_track = -1;
-    int64_t mdat_size = 0;
+    int64_t mdat_size = 0, mdat_start = 0;
     int ret;
     int has_video = 0, starts_with_key = 0, first_video_track = 1;
 
@@ -6113,7 +6134,7 @@  static int mov_flush_fragment(AVFormatContext *s, int force)
         mov->moov_written = 1;
         mov->mdat_size = 0;
         for (i = 0; i < mov->nb_tracks; i++)
-            mov_finish_fragment(&mov->tracks[i]);
+            mov_finish_fragment(mov, &mov->tracks[i], 0);
         avio_write_marker(s->pb, AV_NOPTS_VALUE, AVIO_DATA_MARKER_FLUSH_POINT);
         return 0;
     }
@@ -6182,9 +6203,10 @@  static int mov_flush_fragment(AVFormatContext *s, int force)
 
             avio_wb32(s->pb, mdat_size + 8);
             ffio_wfourcc(s->pb, "mdat");
+            mdat_start = avio_tell(s->pb);
         }
 
-        mov_finish_fragment(&mov->tracks[i]);
+        mov_finish_fragment(mov, &mov->tracks[i], mdat_start);
         if (!mov->frag_interleave) {
             if (!track->mdat_buf)
                 continue;
@@ -7169,6 +7191,7 @@  static void mov_free(AVFormatContext *s)
         else if (track->tag == MKTAG('t','m','c','d') && mov->nb_meta_tmcd)
             av_freep(&track->par);
         av_freep(&track->cluster);
+        av_freep(&track->cluster_written);
         av_freep(&track->frag_info);
         av_packet_free(&track->cover_image);
 
@@ -7887,6 +7910,11 @@  static int mov_write_header(AVFormatContext *s)
                             FF_MOV_FLAG_FRAG_EVERY_FRAME)) &&
             !mov->max_fragment_duration && !mov->max_fragment_size)
             mov->flags |= FF_MOV_FLAG_FRAG_KEYFRAME;
+        if (mov->flags & FF_MOV_FLAG_HIDE_FRAGMENTS) {
+            avio_wb32(pb, 8); // placeholder for extended size field (64 bit)
+            ffio_wfourcc(pb, mov->mode == MODE_MOV ? "wide" : "free");
+            mov->mdat_pos = avio_tell(pb);
+        }
     } else if (mov->mode != MODE_AVIF) {
         if (mov->flags & FF_MOV_FLAG_FASTSTART)
             mov->reserved_header_pos = avio_tell(pb);
@@ -8090,13 +8118,34 @@  static int mov_write_trailer(AVFormatContext *s)
         }
     }
 
-    if (!(mov->flags & FF_MOV_FLAG_FRAGMENT)) {
+    if (!(mov->flags & FF_MOV_FLAG_FRAGMENT) ||
+        mov->flags & FF_MOV_FLAG_HIDE_FRAGMENTS) {
+        if (mov->flags & FF_MOV_FLAG_HIDE_FRAGMENTS) {
+            mov_flush_fragment(s, 1);
+            mov->mdat_size = avio_tell(pb) - mov->mdat_pos - 8;
+            for (i = 0; i < mov->nb_tracks; i++) {
+                MOVTrack *track = &mov->tracks[i];
+                track->data_offset = 0;
+                av_free(track->cluster);
+                track->cluster = track->cluster_written;
+                track->entry   = track->entry_written;
+                track->cluster_written = NULL;
+                track->entry_written   = 0;
+                track->chunkCount = 0; // Force build_chunks to rebuild the list of chunks
+            }
+            // Clear the empty_moov flag, as we do want the moov to include
+            // all the samples at this point.
+            mov->flags &= ~FF_MOV_FLAG_EMPTY_MOOV;
+        }
+
         moov_pos = avio_tell(pb);
 
         /* Write size of mdat tag */
         if (mov->mdat_size + 8 <= UINT32_MAX) {
             avio_seek(pb, mov->mdat_pos, SEEK_SET);
             avio_wb32(pb, mov->mdat_size + 8);
+            if (mov->flags & FF_MOV_FLAG_HIDE_FRAGMENTS)
+                ffio_wfourcc(pb, "mdat"); // overwrite the original moov into a mdat
         } else {
             /* overwrite 'wide' placeholder atom */
             avio_seek(pb, mov->mdat_pos - 8, SEEK_SET);
diff --git a/libavformat/movenc.h b/libavformat/movenc.h
index 08d580594d..59daf8946b 100644
--- a/libavformat/movenc.h
+++ b/libavformat/movenc.h
@@ -85,7 +85,7 @@  typedef struct MOVFragmentInfo {
 
 typedef struct MOVTrack {
     int         mode;
-    int         entry;
+    int         entry, entry_written;
     unsigned    timescale;
     uint64_t    time;
     int64_t     track_duration;
@@ -114,6 +114,7 @@  typedef struct MOVTrack {
     int         vos_len;
     uint8_t     *vos_data;
     MOVIentry   *cluster;
+    MOVIentry   *cluster_written;
     unsigned    cluster_capacity;
     int         audio_vbr;
     int         height; ///< active picture (w/o VBI) height for D-10/IMX
@@ -282,6 +283,7 @@  typedef struct MOVMuxContext {
 #define FF_MOV_FLAG_SKIP_SIDX             (1 << 21)
 #define FF_MOV_FLAG_CMAF                  (1 << 22)
 #define FF_MOV_FLAG_PREFER_ICC            (1 << 23)
+#define FF_MOV_FLAG_HIDE_FRAGMENTS        (1 << 24)
 
 int ff_mov_write_packet(AVFormatContext *s, AVPacket *pkt);
 
diff --git a/libavformat/version.h b/libavformat/version.h
index 4687cd857c..af7d0a1024 100644
--- a/libavformat/version.h
+++ b/libavformat/version.h
@@ -31,8 +31,8 @@ 
 
 #include "version_major.h"
 
-#define LIBAVFORMAT_VERSION_MINOR   3
-#define LIBAVFORMAT_VERSION_MICRO 104
+#define LIBAVFORMAT_VERSION_MINOR   4
+#define LIBAVFORMAT_VERSION_MICRO 100
 
 #define LIBAVFORMAT_VERSION_INT AV_VERSION_INT(LIBAVFORMAT_VERSION_MAJOR, \
                                                LIBAVFORMAT_VERSION_MINOR, \
diff --git a/tests/fate/lavf-container.mak b/tests/fate/lavf-container.mak
index d84117c50f..035c394c09 100644
--- a/tests/fate/lavf-container.mak
+++ b/tests/fate/lavf-container.mak
@@ -5,7 +5,7 @@  FATE_LAVF_CONTAINER-$(call ENCDEC,  FLV,                   FLV)                +
 FATE_LAVF_CONTAINER-$(call ENCDEC,  RAWVIDEO,              FILMSTRIP)          += flm
 FATE_LAVF_CONTAINER-$(call ENCDEC2, MPEG2VIDEO, PCM_S16LE, GXF)                += gxf gxf_pal gxf_ntsc
 FATE_LAVF_CONTAINER-$(call ENCDEC2, MPEG4,      MP2,       MATROSKA)           += mkv mkv_attachment
-FATE_LAVF_CONTAINER-$(call ENCDEC2, MPEG4,      PCM_ALAW,  MOV)                += mov mov_rtphint ismv
+FATE_LAVF_CONTAINER-$(call ENCDEC2, MPEG4,      PCM_ALAW,  MOV)                += mov mov_rtphint mov_hide_frag ismv
 FATE_LAVF_CONTAINER-$(call ENCDEC,  MPEG4,                 MOV)                += mp4
 FATE_LAVF_CONTAINER-$(call ENCDEC2, MPEG1VIDEO, MP2,       MPEG1SYSTEM MPEGPS) += mpg
 FATE_LAVF_CONTAINER-$(call ENCDEC , FFV1,                  MXF)                += mxf_ffv1
@@ -51,6 +51,7 @@  fate-lavf-mkv: CMD = lavf_container "" "-c:a mp2 -c:v mpeg4 -ar 44100 -threads 1
 fate-lavf-mkv_attachment: CMD = lavf_container_attach "-c:a mp2 -c:v mpeg4 -threads 1 -f matroska"
 fate-lavf-mov: CMD = lavf_container_timecode "-movflags +faststart -c:a pcm_alaw -c:v mpeg4 -threads 1"
 fate-lavf-mov_rtphint: CMD = lavf_container "" "-movflags +rtphint -c:a pcm_alaw -c:v mpeg4 -threads 1 -f mov"
+fate-lavf-mov_hide_frag: CMD = lavf_container "" "-movflags +frag_keyframe+hide_fragments -c:a pcm_alaw -c:v mpeg4 -threads 1 -f mov"
 fate-lavf-mp4: CMD = lavf_container_timecode "-c:v mpeg4 -an -threads 1"
 fate-lavf-mpg: CMD = lavf_container_timecode "-ar 44100 -threads 1"
 fate-lavf-mxf: CMD = lavf_container_timecode "-af aresample=48000:tsf=s16p -bf 2 -threads 1"
diff --git a/tests/ref/lavf/mov_hide_frag b/tests/ref/lavf/mov_hide_frag
new file mode 100644
index 0000000000..af3af5b171
--- /dev/null
+++ b/tests/ref/lavf/mov_hide_frag
@@ -0,0 +1,3 @@ 
+4871796f41234350f1b050317d0288a3 *tests/data/lavf/lavf.mov_hide_frag
+358508 tests/data/lavf/lavf.mov_hide_frag
+tests/data/lavf/lavf.mov_hide_frag CRC=0xbb2b949b