Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,13 @@ because it turns other people's test suites red.
lower detail in Chrome and in libjxl with partial files allowed, because the
format sends a coarse picture first. A cut of 90% shows nothing. An AVIF
nothing cuts is unchanged. Every JPEG XL changes (see Breaking).
- **`truncate` cuts the pictures of an MP4 or a WebM film by the percentage
you set.** A cut used to take only padding, leaving every frame. Now
`--damage truncate:missing=50` removes half of the coded pictures, as an
interrupted download does, and ffmpeg gives back the frames in front of the
cut. Chrome and Firefox stop a cut MP4 with an error and end a cut WebM
without one. The manifest of a cut WebM now says how long its pictures are,
where it said nothing. Films nothing cuts are unchanged.

- **tfg is on npm, for tests in Node.**
`npm install --save-dev testing-files-generator` gives Playwright, Vitest
Expand Down
58 changes: 58 additions & 0 deletions internal/format/mp4/cut.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
package mp4

import (
"math"

"github.com/donislawdev/TestingFilesGenerator/internal/format"
)

// splitForACut settles how much of the padding goes between the movie box and
// the media data of a film a damage will cut after its first cutAt bytes, so
// that the cut keeps the share of the media data it keeps of the whole file
// (format.Request.CutAt), and gives back the padding left behind the media
// data. content is the media data's samples, as the first pass measured them.
//
// With all of the padding behind the media data, as an uncut film has it, a
// large film cut at any share kept every picture and lost only padding. A free
// box may stand anywhere among the top level boxes, and readers skip it in
// front of the media data as they do behind it - ffmpeg's faststart writes one
// there (docs/TRUNCATE-2026-11-11.md section 14.4). The one thing a box there
// changes is where every chunk starts, which stco says in bytes from the start
// of the file, so every place moves by the gap: front counts it, and the places
// are written from front.
//
// The places take four bytes unless the layout already asked for eight. One
// that the gap takes past 4 GiB widens the table to co64, as a film whose
// pictures take their whole reserve past 4 GiB has it, and the movie box grows
// by four bytes a chunk - only then. When the padding cannot give up those
// bytes and still hold a free box, the film is laid out as one nothing cuts,
// and the manifest's cut says where the cut fell. That takes a film whose
// pictures came within four bytes a chunk of their whole reserve, which the
// guard cannot reach (section 14.3).
//
// A film whose share wants nothing in front - and every film nothing cuts - is
// laid out as one nothing cuts. It sets l.gap, and l.wide when it widens.
func splitForACut(l *layout, content, total, cutAt int64) (back int64) {
before := int64(len(l.ftyp)) + l.moovLen()
mdat := l.front() - before + content
pad := total - before - mdat
l.gap = format.SplitFront(pad, before, mdat, cutAt, total, minFree)
if l.gap > 0 && !l.wide && l.front()+content > math.MaxUint32 {
grown := (wideChunk - narrowChunk) * l.chunks
l.gap = 0
if pad-grown >= minFree {
l.wide = true
l.gap = format.SplitFront(pad-grown, before+grown, mdat, cutAt, total, minFree)
l.wide = l.gap > 0
}
}
return total - l.front() - content
}

// mediaData is how long the samples of the media data come to, chunk by
// chunk.
func (z sizes) mediaData(l layout) int64 {
var n int64
l.eachChunk(func(first, samples int64) { n += z.chunk(first, samples) })
return n
}
10 changes: 7 additions & 3 deletions internal/format/mp4/layout.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,8 +43,12 @@ type layout struct {
// wide is whether the chunks' places take eight bytes each (co64), and
// bigData whether the media data's length does - each only when a film
// whose pictures take their whole reserve would need it, so a smaller film
// never has a field it could not fill.
// never has a field it could not fill. A film a damage will cut may widen
// its places for the padding in front of the media data (cut.go).
wide, bigData bool
// gap is the padding between the movie box and the media data, nothing
// but in a film a damage will cut (cut.go). Planning counts none.
gap int64

ftyp, mvhd, tkhd, mdhd, hdlr, vmhd, dinf, stsd, stts []byte
}
Expand Down Expand Up @@ -92,13 +96,13 @@ func (l layout) boundContent() int64 {
}

// front is where the media data's content starts: the file type, the movie
// box and the media data's header.
// box, the padding in front of the media data and the media data's header.
func (l layout) front() int64 {
head := int64(isobmff.BoxHeader)
if l.bigData {
head += 8
}
return int64(len(l.ftyp)) + l.moovLen() + head
return int64(len(l.ftyp)) + l.moovLen() + l.gap + head
}

// need is the file without its padding when every picture takes its whole
Expand Down
76 changes: 49 additions & 27 deletions internal/format/mp4/mp4.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,9 @@
// with the probe at 8, 9, 15, 16 and 1 048 576 bytes and with none. A free box
// is always written, at least its eight bytes, which leaves no size above the
// minimum that cannot be reached, and one larger than a box length can say is
// several (internal/format/isobmff).
// several (internal/format/isobmff). A film a damage will cut splits them
// around the media data, so the cut keeps the share of the pictures asked for
// (cut.go).
package mp4

import (
Expand Down Expand Up @@ -138,34 +140,54 @@ func (generator) Write(ctx context.Context, w io.Writer, p format.Plan) error {
}
defer pics.Close()

// The map: the file type and the movie box, then the media data box with
// the pictures, then the free boxes that make up the size. The movie box
// is the same length whatever the pictures come to, so it is marked before
// a byte of it goes out, and the rest once the first pass has the length.
mdatAt := int64(len(l.ftyp)) + l.moovLen()
format.Mark(w, format.RoleStructure, 0, mdatAt)
// The padding behind the media data is what the first pass's lengths
// leave, not what the second pass wrote, so a second pass that came out
// different leaves the file a different size from the plan - which the
// engine refuses - rather than a movie box that describes samples it does
// not carry. A film a damage will cut has part of its padding in front of
// the media data (cut.go).
content := z.mediaData(l)
back := splitForACut(&l, content, p.Bytes, f.CutAt)
if back < 0 || back > 0 && back < minFree || l.gap+back < minFree {
return core.Defect(fmt.Errorf("mp4: the pictures came to %d B of media data and the file is %d B, which leaves no room for the free box every one of these carries",
content, p.Bytes))
}

o := &out{w: w, buf: make([]byte, 0, 64<<10)}
content := l.writeMovie(o, z)
// The map: the file type and the movie box, the free boxes in front of the
// media data when a damage will cut the film, then the media data box with
// the pictures, then the free boxes that make up the size. The first pass
// has every length, so all of it is marked before a byte goes out.
movie := int64(len(l.ftyp)) + l.moovLen()
mdatAt := movie + l.gap
format.Mark(w, format.RoleStructure, 0, movie)
if l.gap > 0 {
format.Mark(w, format.RolePadding, movie, l.gap)
}
format.Mark(w, format.RoleContent, mdatAt, l.front()-mdatAt+content)
format.Mark(w, format.RolePadding, l.front()+content, p.Bytes-l.front()-content)
if back > 0 {
format.Mark(w, format.RolePadding, l.front()+content, back)
}

// Both sides of the padding draw their filler from the film's seed. Nobody
// reads padding, and the side behind the media data lies past any cut.
o := &out{w: w, buf: make([]byte, 0, 64<<10)}
l.writeMovie(o, z)
if l.gap > 0 {
if o.flush(); o.err != nil {
return o.err
}
if err := isobmff.WritePadding(ctx, w, f.Choice.Seed, l.gap); err != nil {
return err
}
}
o.mediaHeader(l.bigData, content)
if err := writeSamples(ctx, o, l, pics); err != nil {
return err
}
if o.flush(); o.err != nil {
return o.err
}
// The padding is what the first pass's lengths leave, not what the second
// pass wrote, so a second pass that came out different leaves the file a
// different size from the plan - which the engine refuses - rather than a
// movie box that describes samples it does not carry.
pad := p.Bytes - l.front() - content
if pad < minFree {
return core.Defect(fmt.Errorf("mp4: the pictures came to %d B of media data and the file is %d B, which leaves no room for the free box every one of these carries",
content, p.Bytes))
}
return isobmff.WritePadding(ctx, w, f.Choice.Seed, pad)
return isobmff.WritePadding(ctx, w, f.Choice.Seed, back)
}

// sizes is how long each picture's two samples came out in the first pass, by
Expand Down Expand Up @@ -232,8 +254,8 @@ func measure(ctx context.Context, f video.Film, st video.Stream) (sizes, *video.
}

// writeMovie writes the file type and the movie box, with the sample tables
// from the first pass's lengths, and gives back how long the media data is.
func (l layout) writeMovie(o *out, z sizes) int64 {
// from the first pass's lengths.
func (l layout) writeMovie(o *out, z sizes) {
o.write(l.ftyp)
o.header("moov", l.moovLen())
o.write(l.mvhd)
Expand All @@ -252,7 +274,7 @@ func (l layout) writeMovie(o *out, z sizes) int64 {
l.writeDependencies(o)
l.writeChunkRuns(o)
l.writeSizes(o, z)
return l.writeChunkPlaces(o, z)
l.writeChunkPlaces(o, z)
}

// writeSyncs is stss: every key frame, numbered from one. Each is a sample a
Expand Down Expand Up @@ -315,9 +337,10 @@ func (l layout) writeSizes(o *out, z sizes) {
}
}

// writeChunkPlaces is stco, or co64 when the layout asked for eight bytes, and
// gives back where the last chunk ends, counted from the media data's start.
func (l layout) writeChunkPlaces(o *out, z sizes) int64 {
// writeChunkPlaces is stco, or co64 when the layout asked for eight bytes:
// each chunk's place in bytes from the start of the file, which front - and so
// any padding in front of the media data - is counted in.
func (l layout) writeChunkPlaces(o *out, z sizes) {
kind := "stco"
if l.wide {
kind = "co64"
Expand All @@ -333,7 +356,6 @@ func (l layout) writeChunkPlaces(o *out, z sizes) int64 {
}
content += z.chunk(first, samples)
})
return content
}

// writeSamples is the second pass: every frame's sample, chunk by chunk, the
Expand Down
6 changes: 5 additions & 1 deletion internal/format/video/plan.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,10 @@ import (
type Film struct {
Choice Choice
Settings Settings
// CutAt is where a damage will cut the file, zero when nothing does
// (format.Request.CutAt). Each container splits its padding around the
// pictures for it.
CutAt int64
}

// Stream is the stream the film is written in, the one planning sized it by.
Expand Down Expand Up @@ -50,7 +54,7 @@ func Plan(formatID string, r format.Request, need func(Stream) int64, refuse fun
Exact: true,
Determinism: format.DeterminismByte,
Properties: Facts(c, s),
Memo: Film{Choice: c, Settings: s},
Memo: Film{Choice: c, Settings: s, CutAt: r.CutAt},
Work: st.Work(),
Holds: Holds(c.Width, c.Height),
}
Expand Down
62 changes: 62 additions & 0 deletions internal/format/webm/cut.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
package webm

import (
"context"

"github.com/donislawdev/TestingFilesGenerator/internal/format"
"github.com/donislawdev/TestingFilesGenerator/internal/format/video"
)

// A film a damage will cut (format.Request.CutAt) has its padding split around
// the clusters, so that the cut keeps the share of the clusters it keeps of the
// whole file: a Void between the Tracks and the first cluster, and the rest in
// the Void before the Cues - none there when the share wants all of it in
// front, and the Cues follow the last cluster.
//
// With all of the padding behind the clusters, as an uncut film has it, a
// large film cut at any share kept every picture and lost only padding. A Void
// is an EBML element allowed at every level, and readers skip it in front of
// the clusters as they do behind them - mkvmerge writes one there
// (docs/TRUNCATE-2026-11-11.md section 14.4). The one thing a Void there
// changes is where every cluster starts, which the Cues say from the start of
// the segment's content, and they are written last from where the clusters
// went. The SeekHead's places stay: Info and Tracks stand in front of the
// Void, and the Cues end the file.
//
// How much goes in front depends on how long the clusters come to, and a
// cluster's length is known only once its picture is coded. So a film a damage
// will cut is coded twice, as every MP4 is: once for the lengths (measure) and
// again to write it, the second pass taking every tile the first one kept
// (video.Pictures.Again). A film nothing cuts is coded once, as before.

// measure is the first pass of a film a damage will cut: every cluster's
// length, nothing written, so the padding can be split before the file has a
// byte. It gives back what the clusters come to and the coder of the second
// pass. The work coding is worth is reported here, and the second pass, which
// codes only what this one could not keep, reports none.
//
// first is closed on every way out, by Again or by the caller's deferred
// Close - closing it twice is harmless.
func measure(ctx context.Context, l layout, first *video.Pictures) (int64, *video.Pictures, error) {
var content uint64
err := eachCluster(ctx, l, first, true, func(from, to int64) error {
content += elementLen(idCluster, l.clusterContent(from, to, first.SampleLens()))
return nil
})
if err != nil {
return 0, nil, err
}
again, err := first.Again()
if err != nil {
return 0, nil, err
}
return int64(content), again, nil
}

// splitForACut is how much of the padding pad goes in front of the clusters,
// which come to content bytes after before bytes of the file, for a cut after
// the file's first cutAt bytes of whole. Each side is nothing or a Void of at
// least its two bytes (format.SplitFront).
func splitForACut(pad, before, content, cutAt, whole int64) int64 {
return format.SplitFront(pad, before, content, cutAt, whole, minVoid)
}
Loading
Loading