MP4 Metadata Layout Safety - log5r/simple-media-player GitHub Wiki
MP4 metadata layout safety
Issue: #38. Verification date: 2026-10-07 (JST).
Failure and priority
MP4 metadata edits could move file content after moov while updating only stco and co64. Fragmented MP4 can also use offsets inside moof/tfhd, mfra, and sidx; those references were not updated. This was prioritized over the other open performance and display issues because editing could damage media playback. New udta/meta also lacked the metadata handler box.
The repair is in the shared writer used by embedded tag editing (including bulk editing), artwork updates, lyrics embedding, and transformed AAC exports. Library writes invoke it on a utility task; transformed exports invoke it through the exporter. The repair adds no MainActor I/O or OS-specific API.
Implementation and limits
New meta boxes contain the four-byte FullBox header, an iTunes hdlr, and ilst. The 33-byte handler contains zero version/flags and pre-defined fields, handler type mdir, reserved value appl, two zero reserved words, and a null name. This follows FFmpeg 8.0's iTunes handler implementation. Existing handlers and unknown metadata children are preserved.
The writer detects top-level moof, mfra, and sidx, and the moov/mvex declaration. W3C's ISO BMFF byte-stream description describes mvex and moof as fragment structures. After the existing padding step, a nonzero movie-size change with any such marker throws unsupportedMP4MetadataLayout. The existing English/Japanese localized error is used. No replacement is published; temporary files are cleaned up and the source remains byte-identical.
Equal-size changes remain supported. Shrinks of at least eight bytes use a free box to retain the original movie size. Shrinks of one through seven bytes are rejected for fragmented layouts, since they cannot hold a box header. Growth is rejected. Detection is deliberately conservative even if particular fragment offsets would remain valid. Fragment-offset rewriting and reuse of existing padding for growth are outside this change.
The box parser, container rewrite, and chunk-offset helpers moved to MP4MetadataWriter+Boxes.swift; all eleven extracted function bodies are unchanged. Supporting types are nested in the writer. The extraction keeps both files and type bodies within SwiftLint limits without changing its baseline.
Regression verification
Before the repair, the new layout tests failed with 26 assertions: missing handlers, accepted fragment/index growth, and accepted 1–7 byte shrink. In the accepted unsafe cases, source bytes changed. After the repair, the same tests pass. An independent probe compiled the original writer from origin/main and edited a copy of the native fragmented-video fixture: FFmpeg then reported invalid H.264 NAL sizes and missing pictures (294 diagnostic lines), while the unedited source decoded without errors. The repaired writer rejects that edit and preserves the original file.
The layout suite covers both absent udta and absent meta, every fragment/index marker, each small-shrink boundary, same-size edits, padded shrinks of 8 and 40 bytes, preserved handler/unknown children, source preservation, and temporary-file cleanup. Existing tests retain nonfragmented stco/co64 growth, extended and zero-sized boxes, interleaved media, overflow and invalid input, artwork, and lyrics.
Two real fixtures provide independent checks:
untagged-aac.m4a: a 0.35-second FFmpeg 8.0 sine-wave AAC with trailingmoov, whose emptyudtawas removed without moving media. After editing, AVFoundation and FFprobe read the new title,mdatstays identical, and decoded PCM is identical. FFmpeg decoded MD5 before and after:ab5e6858a581f309cada423e69dc696c.fragmented-video.mp4: a playable AVAssetWriter snapshot with severalmoofboxes, usingmovieFragmentInterval = 0.5sand keyframes every 15 frames. It is captured before finalization; Xcode 27.1 finalization flattened the generated file. The test asserts multiplemoofboxes, decodes at least 60 frames, reads from a one-second position, rejects tag growth, then repeats those reads and confirms byte-identical source and cleanup.
| Check | Result |
|---|---|
| macOS 27, Xcode 27.1 Beta / Swift 6: six related suites | 50 test methods passed |
| iPhone 18 Pro, iOS 27 simulator: three MP4 suites | 22 test methods passed |
| iPad Pro 11-inch (M5), iPadOS 27 simulator: three MP4 suites | 22 test methods passed |
| Application and test builds on macOS / iOS Simulator | Passed |
| Changed Swift files, strict SwiftLint 0.65.1 | Zero findings |
| Whole-repository lint | Passed; 43 existing warnings, same as origin/main |
git diff --check |
Passed |
The first iPad invocation completed all 22 test methods, then stalled during Xcode result finalization without producing an xcresult Info.plist. It was terminated after several minutes; the retry using already-built tests, an explicit result path, and -collect-test-diagnostics never completed successfully with all 22 methods passing and TEST EXECUTE SUCCEEDED. The retry produced a readable xcresult bundle.
No UI changes were made, so UI tests were not run. Physical devices, minimum supported OS versions, Music.app and Bento4 mp4dump were not tested. Video verification checks decoded buffers and timestamps through AVAssetReader, rather than a manual audiovisual playback session.
Reproducing the fixtures
For the native fragment snapshot, compile scripts/GenerateMP4MetadataFixture.swift with xcrun swiftc -parse-as-library, then run it with a temporary output path. The sibling fragment-snapshot.mp4 is the regression input. The finalized output is not a fragmented fixture on the tested SDK.
For AAC, generate the source:
ffmpeg -f lavfi -i 'sine=frequency=440:sample_rate=44100:duration=0.35' \
-c:a aac -fflags +bitexact -flags:a +bitexact -map_metadata -1 trailing.m4a
Remove only udta from the final moov and update its size. Do not use +faststart for this preparation: keeping moov last means removal does not shift sample offsets. The checked-in fixture is 4,122 bytes and contains ftyp, free, mdat, and moov.