From 57dd1b1d53ed276fc196b8ba08b2e403aec7be22 Mon Sep 17 00:00:00 2001 From: DonislawDev Date: Wed, 7 Oct 2026 00:15:19 +0200 Subject: [PATCH 1/4] format: webm pictures cut into AV1 tiles - a tile nothing changes is coded once a film A film's pictures differ only in the clock and the square, so from 256x144 up the picture is cut into AV1 tiles on superblock lines: a row under the clock's band, rows around the square's, a column for each superblock the clock's characters reach and one for the rest. Each tile is coded by gav1d as a picture of its own, read where it lies in the picture's planes, and joined under a frame header this package writes - tile_info with the tiles' sizes, the tile group with their lengths. A tile is looked up by what its pixels depend on (the clock's characters in it, the square's columns in it), so the tiles nothing changes are coded once a film, the square's ten places once each, and a change codes only the clock's tile. A thirty-minute 1920x1080 film of 2 GB: 67.9 s before, 2.8 and 3.1 s now, every one of its 54 000 frames decoded by libaom. Films of one tile keep their bytes - the three golden films are unchanged. - A new guard holds every tile in a film to gav1d's own coding of the pixels there, and the decoded frame to its tiles decoded alone, read back with a tile_info reader of its own. Four film guards now assert that they asked about a film of tiles. - The ladder's ceilings re-measured for the rungs that have tiles. - The work a change reports is the pixels of the tiles it codes. - change_interval no longer says a change costs a whole picture in time. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 12 +- internal/format/imagelabel/imagelabel.go | 18 + internal/format/video/ahead.go | 125 +++---- internal/format/video/av1.go | 118 +++++-- internal/format/video/bits.go | 24 +- internal/format/video/choose.go | 108 ++++-- internal/format/video/grid.go | 228 +++++++++++++ internal/format/video/picture.go | 138 +++++--- internal/format/video/pictures.go | 244 ++++++++++++++ internal/format/video/settings.go | 15 +- internal/format/video/still.go | 168 +++++---- internal/format/video/stream.go | 231 ++++--------- internal/format/video/tilekey.go | 97 ++++++ internal/format/webm/webm.go | 9 +- internal/guard/film_test.go | 30 +- internal/guard/filmahead_test.go | 45 ++- internal/guard/filmtiles_test.go | 394 ++++++++++++++++++++++ internal/gui/text/locale/registry/en.json | 4 +- internal/gui/text/locale/registry/pl.json | 2 +- 19 files changed, 1589 insertions(+), 421 deletions(-) create mode 100644 internal/format/video/grid.go create mode 100644 internal/format/video/pictures.go create mode 100644 internal/format/video/tilekey.go create mode 100644 internal/guard/filmtiles_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 9c95574e..c416b90b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,12 +24,12 @@ because it turns other people's test suites red. shows a running clock and one that freezes shows a stopped one. The clock reads the time the picture starts, the way a player's position does - `00:00:07`, with milliseconds when the changes do not fall on whole - seconds. Each change is a whole new picture in bytes and in coding time, - and when a film has more than one picture they are coded on several cores - at once - a thirty-minute 1920x1080 film took 68 s on sixteen threads, and - the - progress bar counts the pictures as well as the bytes, so the time it says - is left holds while they are coded. The frames + seconds. Each change is a whole new picture in bytes, but only the part of + it that moved is coded again: from 256x144 up the picture is cut into AV1 + tiles, and a tile nothing changes is coded once a film. What is left to + code is coded on several cores at once - a thirty-minute 1920x1080 film of + 2 GB takes about 3 s on sixteen threads - and the progress bar counts that + coding as well as the bytes, so the time it says is left holds. The frames between changes cost a few bytes each, so an hour at thirty frames a second with a change every second fits in about a megabyte (1 075 831 B at the smallest), and a `change_interval` as long as the film diff --git a/internal/format/imagelabel/imagelabel.go b/internal/format/imagelabel/imagelabel.go index 41e6ae08..159aa6c8 100644 --- a/internal/format/imagelabel/imagelabel.go +++ b/internal/format/imagelabel/imagelabel.go @@ -92,6 +92,24 @@ func Fits(width, chars int) bool { return scaleFor(width, chars) > 0 } +// Cells is where Draw puts the characters of a text: character k covers the +// columns from First + k*Step, Width of them, and every row of the band. A +// film asks it to know which characters of its clock fall in which part of +// the picture, so it is Draw's own arithmetic rather than a copy of it. +type Cells struct { + First, Step, Width int +} + +// CellsOf is where Draw puts a text of chars characters across width - the +// zero Cells when Draw would draw nothing. +func CellsOf(width, chars int) Cells { + s := scaleFor(width, chars) + if s == 0 { + return Cells{} + } + return Cells{First: pad, Step: (glyphWidth + 1) * s, Width: glyphWidth * s} +} + // BandHeight is how tall the band Draw paints for a label of chars characters // across width is, before it is cut to the picture - 0 when the label does not // fit. A film draws a second line under the first and asks this before it diff --git a/internal/format/video/ahead.go b/internal/format/video/ahead.go index 9f99f24b..bbc477c8 100644 --- a/internal/format/video/ahead.go +++ b/internal/format/video/ahead.go @@ -30,16 +30,21 @@ import ( // from one count for the whole process, GOMAXPROCS less one. A film that // finds none free codes alone, as every film did before, and asks again at its // next picture. +// +// Since a picture is cut into tiles (grid.go), what a helper codes is a tile: +// a new tile of the clock at every change, the square's ten places once each, +// and the rest of the picture once. The pictures still come out in order and +// the bytes are still those of one goroutine. // helping is how many helpers are coding beside the writers right now. var helping atomic.Int64 -// codedBeside counts the pictures a helper coded rather than the goroutine -// that asked for them, since the process started. +// codedBeside counts the tiles a helper coded rather than the goroutine that +// asked for them, since the process started. var codedBeside atomic.Int64 -// Helpers is how many helpers are coding right now and how many pictures -// helpers have coded since the process started. Guards read it: a film held to +// Helpers is how many helpers are coding right now and how many tiles helpers +// have coded since the process started. Guards read it: a film held to // the bytes of one goroutine says nothing unless several really coded it, and // a film abandoned half way has to leave none of them running. func Helpers() (running, coded int64) { return helping.Load(), codedBeside.Load() } @@ -58,44 +63,50 @@ func takeHelper() bool { } } -// job is one picture to code. Whoever takes it first - a helper, or the -// writer that needs it - codes it, and done is released when it is coded, so -// the writer waits only for a picture somebody else is coding. A WaitGroup -// inside the job rather than a channel beside it, because a channel is one -// more allocation for every picture of every film. +// job is one tile to code, of the picture with this look. Whoever takes it +// first - a helper, or the writer that needs it - codes it, and done is +// released when it is coded, so the writer waits only for a tile somebody else +// is coding. A WaitGroup inside the job rather than a channel beside it, +// because a channel is one more allocation for every tile of every film. type job struct { look look change int64 + tile int taken atomic.Bool done sync.WaitGroup - coded Coded + coded tileCoded err error // panicked is what coding it panicked with. A panic on a helper would end // the process, where the same panic on the writer becomes the run's error // (internal/engine, writeWithoutCrashing), so a helper keeps it here and - // the writer panics with it when it reaches the picture. + // the writer panics with it when it reaches the tile. panicked any + // counted says the tile's bytes are to be added to what the film keeps of + // the clock's tiles once it is coded. Read and written by the owning + // goroutine only. + counted bool } -func newJob(l look, change int64) *job { - j := &job{look: l, change: change} +func newJob(l look, change int64, tile int) *job { + j := &job{look: l, change: change, tile: tile} j.done.Add(1) return j } -// codedJob is a picture already coded, which nobody codes again. -func codedJob(c Coded) *job { - j := &job{coded: c} +// codedJob is a tile already coded, which nobody codes again. +func codedJob(t tileCoded) *job { + j := &job{coded: t} j.taken.Store(true) return j } -// crew codes the pictures of one film: the goroutine that owns it, and the +// crew codes the tiles of one film: the goroutine that owns it, and the // helpers it has been able to take. Jobs are offered in the order the film // shows them and waited for in the same order, so the first error a film // meets is the first in its order, whoever coded what. type crew struct { film *film + keys keyer qindex int own *painter queue chan *job @@ -104,19 +115,19 @@ type crew struct { wg sync.WaitGroup stopped atomic.Bool closed bool - // encode stands in for painting a picture and coding it through gav1d, + // encode stands in for painting a tile and coding it through gav1d, // told whether a helper is the one asking. Nil in every film - only // CodeBeside sets it. - encode func(change int64, beside bool) (Coded, error) + encode func(change int64, beside bool) (tileCoded, error) } -// newCrew is the crew for a film of this many pictures. A film of one picture -// takes no helper, and neither does a process of one thread. -func newCrew(f *film, qindex int, pictures int64) *crew { - c := &crew{film: f, qindex: qindex, own: f.painter()} - if most := min(int64(runtime.GOMAXPROCS(0)-1), pictures-1); most > 0 { +// newCrew is the crew for a film of at most this many tiles to code. A film of +// one tile takes no helper, and neither does a process of one thread. +func newCrew(f *film, ks keyer, qindex int, jobs int64) *crew { + c := &crew{film: f, keys: ks, qindex: qindex, own: f.painter()} + if most := min(int64(runtime.GOMAXPROCS(0)-1), jobs-1); most > 0 { c.most = int(most) - c.queue = make(chan *job, c.ahead()) + c.queue = make(chan *job, c.ahead()*len(ks.tiles)) } return c } @@ -132,8 +143,8 @@ func (c *crew) ahead() int { return 2 * (c.most + 1) } -// offer hands a picture to the helpers, taking one more first when the film -// may have more and the process has a place for it. With no helper the +// offer hands a tile to the helpers, taking one more first when the film may +// have more and the process has a place for it. With no helper the // writer codes it when it gets there. Called by the owning goroutine only. func (c *crew) offer(j *job) { if c.queue == nil || c.closed { @@ -150,7 +161,7 @@ func (c *crew) offer(j *job) { select { case c.queue <- j: default: - // The queue is as long as the window, so this is a picture the + // The queue holds every tile of the window, so this is a tile the // writer will reach before any helper would have. } } @@ -181,11 +192,12 @@ func (c *crew) code(p *painter, j *job, beside bool) { j.coded, j.err = c.encode(j.change, beside) return } - j.coded, j.err = Encode(p.draw(j.look), c.qindex) + r := c.keys.tiles[j.tile].rect + j.coded, j.err = encodeTile(p.drawIn(j.look, r), r, c.qindex) } -// wait is the picture of j, coded here when no helper has taken it. -func (c *crew) wait(j *job) (Coded, error) { +// wait is the tile of j, coded here when no helper has taken it. +func (c *crew) wait(j *job) (tileCoded, error) { if j.taken.CompareAndSwap(false, true) { c.code(c.own, j, false) } @@ -196,7 +208,7 @@ func (c *crew) wait(j *job) (Coded, error) { return j.coded, j.err } -// finish says no picture is coming after the ones offered, so the helpers end +// finish says no tile is coming after the ones offered, so the helpers end // once the queue is empty and give their places back to the process. func (c *crew) finish() { if c.queue != nil && !c.closed { @@ -206,8 +218,8 @@ func (c *crew) finish() { } // stop ends the crew and waits for its helpers. A helper in the middle of a -// picture finishes that picture - gav1d cannot be interrupted, and that was -// so before there were helpers - and codes nothing after it. Nothing a crew +// tile finishes that tile - gav1d cannot be interrupted, and that was so +// before there were helpers - and codes nothing after it. Nothing a crew // started is running once this returns. func (c *crew) stop() { c.stopped.Store(true) @@ -215,55 +227,52 @@ func (c *crew) stop() { c.wg.Wait() } -// all offers every job at once and gives back their pictures in order - a -// set known in advance, like the sample planning codes, rather than a film -// coded as it is written. -func (c *crew) all(jobs []*job) ([]Coded, error) { +// all offers every job at once and waits for each in order - a set known in +// advance, like the sample planning codes, rather than a film coded as it is +// written. Each job's tile is in the job once all returns. +func (c *crew) all(jobs []*job) error { for _, j := range jobs { c.offer(j) } c.finish() - out := make([]Coded, len(jobs)) - for i, j := range jobs { - coded, err := c.wait(j) - if err != nil { - return nil, err + for _, j := range jobs { + if _, err := c.wait(j); err != nil { + return err } - out[i] = coded } - return out, nil + return nil } -// CodeBeside codes pictures 0 to n-1 the way a film's are coded - this -// goroutine and the helpers it can take, offered ahead and taken in order - -// with code standing in for painting and gav1d, told whether a helper is the -// one calling, and gives back the size code returned for each. +// CodeBeside codes tiles 0 to n-1 the way a film's are coded - this goroutine +// and the helpers it can take, offered ahead and taken in order - with code +// standing in for painting and gav1d, told whether a helper is the one +// calling, and gives back the size code returned for each. // // It is for the guards, and only because what it lets them hold cannot be // reached another way: a panic on a helper has to come out on the goroutine -// that asked for the picture, where the engine turns it into the run's error, +// that asked for the tile, where the engine turns it into the run's error, // rather than end the process - and nothing outside makes gav1d panic. // Measured on 2026-10-06: a quantizer out of range is refused with an error, // and only planes shorter than the picture panic, which no painter makes. func CodeBeside(n int64, code func(change int64, beside bool) (int, error)) ([]int, error) { t := Timeline{FPS: 1, Frames: n, DurationMs: n * 1000, KeyEvery: n, ChangeEvery: 1} - c := newCrew(newFilm(1, 1, 0, "", t), 0, n) + f := newFilm(1, 1, 0, "", t) + c := newCrew(f, newKeyer(f.geometry, oneTile(1, 1)), 0, n) defer c.stop() - c.encode = func(change int64, beside bool) (Coded, error) { + c.encode = func(change int64, beside bool) (tileCoded, error) { size, err := code(change, beside) - return Coded{tile: make([]byte, size)}, err + return tileCoded{data: make([]byte, size)}, err } jobs := make([]*job, n) for i := range jobs { - jobs[i] = newJob(look{}, int64(i)) + jobs[i] = newJob(look{}, int64(i), 0) } - coded, err := c.all(jobs) - if err != nil { + if err := c.all(jobs); err != nil { return nil, err } sizes := make([]int, n) - for i, p := range coded { - sizes[i] = p.Size() + for i, j := range jobs { + sizes[i] = len(j.coded.data) } return sizes, nil } diff --git a/internal/format/video/av1.go b/internal/format/video/av1.go index dab164f8..bb19b3f9 100644 --- a/internal/format/video/av1.go +++ b/internal/format/video/av1.go @@ -1,5 +1,11 @@ package video +import ( + "fmt" + + "github.com/donislawdev/TestingFilesGenerator/internal/core" +) + // OBU types this package writes, AV1 specification section 6.2.2. const ( obuSequenceHeader = 1 @@ -116,29 +122,30 @@ func codecConfig(level int, sequence []byte) []byte { return append([]byte{0x81, byte(level & 0x1f), 0x0C, 0x00}, sequence...) } -// keyFrame is a shown KEY_FRAME around a coded picture. Shown, it is a place -// to start playing, and the specification makes it impossible to show again. -func keyFrame(c Coded) []byte { - w := bitWriter{buf: make([]byte, 0, 8)} - w.bit(0) // show_existing_frame - w.bits(0, 2) // frame_type: KEY_FRAME - w.bit(1) // show_frame - // error_resilient_mode is 1 by rule for a shown key frame, not written - w.bit(0) // disable_cdf_update - w.bit(0) // frame_size_override_flag - // refresh_frame_flags is every slot by rule, not written - w.bit(0) // render_and_frame_size_different - w.bit(1) // disable_frame_end_update_cdf - c.writeSuffix(&w) - w.align() - return obuOf(obuFrame, w.bytes(), c.tile) -} +// The two frames a picture is carried in. +const ( + // keyKind is a shown KEY_FRAME. Shown, it is a place to start playing, + // and the specification makes it impossible to show again. + keyKind = iota + // copyKind is the same picture as an INTRA_ONLY frame that is not shown + // now and is showable later, kept in copySlot. + copyKind +) -// hiddenCopy is the same picture as an INTRA_ONLY frame that is not shown now -// and is showable later, kept in copySlot. -func hiddenCopy(c Coded) []byte { - w := bitWriter{buf: make([]byte, 0, 9)} - w.bit(0) // show_existing_frame +// headerStart writes a frame's uncompressed header up to tile_info. +func headerStart(w *bitWriter, kind int) { + w.bit(0) // show_existing_frame + if kind == keyKind { + w.bits(0, 2) // frame_type: KEY_FRAME + w.bit(1) // show_frame + // error_resilient_mode is 1 by rule for a shown key frame, not written + w.bit(0) // disable_cdf_update + w.bit(0) // frame_size_override_flag + // refresh_frame_flags is every slot by rule, not written + w.bit(0) // render_and_frame_size_different + w.bit(1) // disable_frame_end_update_cdf + return + } w.bits(2, 2) // frame_type: INTRA_ONLY_FRAME w.bit(0) // show_frame w.bit(1) // showable_frame @@ -148,9 +155,72 @@ func hiddenCopy(c Coded) []byte { w.bits(1< 1 { + group = 1 + (len(tiles)-1)*s.tileSizeBytes + } + payload := len(w.buf) + group + for i, t := range tiles { + if !t.sameHeader(tiles[0]) { + return nil, core.Defect(fmt.Errorf("video: tile %d of a picture was coded under other header bits than tile 0, and one frame carries one header", i)) + } + payload += len(t.data) + } + var sizeRoom [8]byte + size := appendLeb128(sizeRoom[:0], payload) + out := dst[:0] + if total := len(before) + 1 + len(size) + payload + len(after); cap(out) < total { + out = make([]byte, 0, total) + } + out = append(append(append(append(out, before...), byte(obuFrame<<3)|0x02), size...), w.buf...) + if group > 0 { + out = append(out, 0) + } + for i, t := range tiles { + if i < len(tiles)-1 { + out = appendLittleEndian(out, len(t.data)-1, s.tileSizeBytes) + } + out = append(out, t.data...) + } + return append(out, after...), nil +} + +// appendLittleEndian is le(n), 04.conventions.md: v in n bytes, the lowest +// first. +func appendLittleEndian(out []byte, v, n int) []byte { + for b := range n { + out = append(out, byte(v>>(8*b))) + } + return out } // showCopy shows what copySlot holds, and is three bytes. diff --git a/internal/format/video/bits.go b/internal/format/video/bits.go index 84f7c1f6..4826667c 100644 --- a/internal/format/video/bits.go +++ b/internal/format/video/bits.go @@ -30,6 +30,24 @@ func (w *bitWriter) align() { } } +// ns is a value below n in the specification's non-symmetric code, +// 04.conventions.md lines 456-486, with w = FloorLog2(n) + 1 and +// m = (1 << w) - n. A value under m takes w - 1 bits, one at m or over takes +// one more, so that v = (x + m) >> 1 and the extra bit together read back as x. +func (w *bitWriter) ns(x, n int) { + floor := 0 + for 2<>1), floor) + w.bit(uint32((x + m) & 1)) +} + // trailing is trailing_bits: a one, then zeros to the next byte. func (w *bitWriter) trailing() { w.bit(1) @@ -65,8 +83,10 @@ func (r *bitReader) bits(n int) uint32 { } // leb128 is the length an OBU header carries. -func leb128(v int) []byte { - var out []byte +func leb128(v int) []byte { return appendLeb128(nil, v) } + +// appendLeb128 appends it to out. +func appendLeb128(out []byte, v int) []byte { for { b := byte(v & 0x7f) v >>= 7 diff --git a/internal/format/video/choose.go b/internal/format/video/choose.go index 5b37c90f..583579af 100644 --- a/internal/format/video/choose.go +++ b/internal/format/video/choose.go @@ -22,10 +22,16 @@ type Rung struct { // 640x360 for the reason AVIF stops at 640x480: a larger picture costs every // file of a run its encoding time, and somebody who wants Full HD says so. // -// Ceiling is a tenth above the largest tile any picture of a film on the rung -// coded to, and it is the reserve of every one of them. Measured by -// tools/probes/videoladder at the default quality on 2026-10-06 -// (docs/WIDEO-2026-10-06.md section 15), in three passes. First every seed +// Ceiling is a tenth above the largest any picture of a film on the rung +// coded to - all its tiles together - and it is the reserve of every one of +// them. Measured by tools/probes/videoladder at the default quality on +// 2026-10-06 (docs/WIDEO-2026-10-06.md section 15), in three passes, and +// again on 2026-10-07 once pictures were cut into tiles +// (docs/WEBM-WYDAJNOSC-2026-10-06.md section 10): the four rungs from 640x360 +// to 256x144 have tiles, and the first three came out 1.5 to 2.3 percent +// above their pictures whole, every seam costing a little prediction. 256x144 +// and the rungs under it did not move - their heaviest picture is one tile, +// on 256x144 the clock with milliseconds, which takes a third of it. First every seed // offset there is with every length the label can have, either format's name // in it, and the label absent, in both shapes of the clock. Then the eight // heaviest of those at a hundred clocks, every step of the square and the @@ -41,7 +47,7 @@ type Rung struct { // writing cannot keep, so writing checks every picture and says so rather // than trusting this table. var Ladder = []Rung{ - {640, 360, 17026}, {426, 240, 10905}, {320, 180, 8191}, {256, 144, 6754}, + {640, 360, 17277}, {426, 240, 11156}, {320, 180, 8322}, {256, 144, 6754}, {160, 90, 3951}, {80, 45, 1454}, {40, 23, 825}, {16, 9, 149}, {4, 3, 46}, {2, 2, 31}, {1, 1, 6}, } @@ -52,10 +58,11 @@ type Choice struct { Seed uint64 Label string QIndex int - // First is the film's first picture when planning had to code it - a size - // named by hand, or a quality the ceilings were not measured at. Nil means - // the picture is a ladder rung and writing codes it. - First *Coded + // tiles are what planning coded when it had to code a sample of the + // film's pictures - a size named by hand, or a quality the ceilings were + // not measured at - by key, which writing takes rather than codes again. + // Nil means the picture is a ladder rung and writing codes every tile. + tiles map[tileKey]tileCoded // Ceiling is the most tile bytes the plan allowed each picture, which // writing holds every picture to. Ceiling int @@ -71,10 +78,11 @@ func (c Choice) Labelled() bool { return Labelled(c.Width, c.Label) } // ClockShown is whether this film's pictures show the clock. func (c Choice) ClockShown(t Timeline) bool { return ClockShown(c.Width, c.Height, c.Label, t) } -// sampleReserve codes the sample of a film's pictures, SampleChanges, and -// gives back the first picture, which writing reuses, and the reserve every -// picture is held to: the largest of the sample when the sample is the whole -// film, and a tenth above it when it is not. +// sampleReserve codes the sample of a film's pictures, SampleChanges, tile by +// tile and each tile once, and gives back the tiles, which writing reuses, and +// the reserve every picture is held to: the largest of the sample - all its +// tiles together - when the sample is the whole film, and a tenth above it +// when it is not. // // The first picture alone is no base for it. The pictures of one film differ // only in the clock and the square, and on a small picture those are much of @@ -92,31 +100,61 @@ func (c Choice) ClockShown(t Timeline) bool { return ClockShown(c.Width, c.Heigh // The sample is coded the way a film is (ahead.go), all of it offered at once, // because at 1920x1080 ten pictures are three seconds of planning on one // goroutine, and planning is what a preview waits for. -func sampleReserve(w, h int, seed uint64, label string, t Timeline, qindex int) (Coded, int, error) { +func sampleReserve(w, h int, seed uint64, label string, t Timeline, qindex int) (map[tileKey]tileCoded, int, error) { f := newFilm(w, h, seed, label, t) + ks := newKeyer(f.geometry, gridFor(f.geometry, t.FPS)) sample := SampleChanges(t) - c := newCrew(f, qindex, int64(len(sample))) - defer c.stop() - jobs := make([]*job, len(sample)) + s := sampleJobs{keys: ks, byKey: make(map[tileKey]*job)} + pictures := make([][]*job, len(sample)) for i, ch := range sample { - jobs[i] = newJob(f.lookOf(ch), ch) + pictures[i] = s.picture(f.lookOf(ch), ch) } - coded, err := c.all(jobs) - if err != nil { - return Coded{}, 0, err + c := newCrew(f, ks, qindex, int64(len(s.jobs))) + defer c.stop() + if err := c.all(s.jobs); err != nil { + return nil, 0, err } - var first Coded largest := 0 - for i, ch := range sample { - if ch == 0 { - first = coded[i] + for _, tiles := range pictures { + size := 0 + for _, j := range tiles { + size += len(j.coded.data) } - largest = max(largest, coded[i].Size()) + largest = max(largest, size) + } + coded := make(map[tileKey]tileCoded, len(s.byKey)) + for key, j := range s.byKey { + coded[key] = j.coded } if int64(len(sample)) == t.Changes() { - return first, largest, nil + return coded, largest, nil + } + return coded, largest + (largest+9)/10, nil +} + +// sampleJobs are the tiles of a sample, each key once, in the order they +// first come up. +type sampleJobs struct { + keys keyer + byKey map[tileKey]*job + jobs []*job +} + +// picture is the job of each tile of the picture with this look at change, +// a new job for each key not seen in the sample before. +func (s *sampleJobs) picture(l look, change int64) []*job { + out := make([]*job, len(s.keys.tiles)) + for k := range s.keys.tiles { + key := s.keys.key(k, l) + j, ok := s.byKey[key] + if !ok { + j = newJob(l, change, k) + s.byKey[key] = j + s.jobs = append(s.jobs, j) + } + out[k] = j } - return first, largest + (largest+9)/10, nil + return out } // SampleChanges is the pictures planning codes to settle the reserve of a film @@ -177,14 +215,14 @@ func onRung(base Choice, rung Rung, s Settings) (Choice, Stream, error) { c := base c.Width, c.Height, c.Ceiling = rung.Width, rung.Height, rung.Ceiling if !s.QualityNamed { - return c, NewStream(s.Timeline, rung.Ceiling, rung.Width, rung.Height), nil + return c, NewStream(s.Timeline, rung.Ceiling, rung.Width, rung.Height, c.Label), nil } - first, reserve, err := sampleReserve(c.Width, c.Height, c.Seed, c.Label, s.Timeline, c.QIndex) + tiles, reserve, err := sampleReserve(c.Width, c.Height, c.Seed, c.Label, s.Timeline, c.QIndex) if err != nil { return Choice{}, Stream{}, err } - c.First, c.Ceiling = &first, reserve - return c, NewStream(s.Timeline, c.Ceiling, c.Width, c.Height), nil + c.tiles, c.Ceiling = tiles, reserve + return c, NewStream(s.Timeline, c.Ceiling, c.Width, c.Height, c.Label), nil } // named settles a film whose picture size the request gave by coding a sample @@ -205,12 +243,12 @@ func named(formatID string, r format.Request, s Settings, c Choice) (Choice, Str if err := checkOneTile(formatID, w, h); err != nil { return Choice{}, Stream{}, err } - first, reserve, err := sampleReserve(w, h, c.Seed, c.Label, s.Timeline, c.QIndex) + tiles, reserve, err := sampleReserve(w, h, c.Seed, c.Label, s.Timeline, c.QIndex) if err != nil { return Choice{}, Stream{}, err } - c.Width, c.Height, c.First, c.Ceiling, c.Named = w, h, &first, reserve, true - return c, NewStream(s.Timeline, c.Ceiling, w, h), nil + c.Width, c.Height, c.tiles, c.Ceiling, c.Named = w, h, tiles, reserve, true + return c, NewStream(s.Timeline, c.Ceiling, w, h, c.Label), nil } // checkJointLimits asks the registry's own declaration, so the refusal, the diff --git a/internal/format/video/grid.go b/internal/format/video/grid.go new file mode 100644 index 00000000..0dc0e793 --- /dev/null +++ b/internal/format/video/grid.go @@ -0,0 +1,228 @@ +package video + +import ( + "image" + "slices" +) + +// grid is how a film's picture is cut into AV1 tiles: columns and rows of +// superblocks, 64 by 64 pixels, adding up to the picture's own. +// +// Why there are tiles. A film's pictures differ only in the clock and the +// square, and an AV1 tile is decoded without looking past its own edges - its +// entropy and its neighbours start afresh at every edge (AV1 specification, +// AOMediaCodec/av1-spec 5e04f3f: 06.bitstream.syntax.md lines 1821-1880 and +// 4106-4111, 09.parsing.process.md lines 38-70). So a tile gav1d codes as a +// picture of its own is the same tile in a frame of many, and a tile no +// picture changes is coded once a film. Measured on 2026-10-06 against +// libaom, dav1d and gav1d's own decoder, bit for bit, at five sizes. Per +// picture it is 17.7 times faster at 1920x1080 and 43.4 at 3840x2160 +// (docs/WEBM-WYDAJNOSC-2026-10-06.md section 8). +// +// The layout is part of the bytes of every film (D11), so it is a rule rather +// than a search, measured against two others (section 9.1): one tile row under +// the clock's band, two around the square's, each superblock the clock's +// characters reach a column of its own and the rest of the width one column. +// The clock's tile is coded at every change, so it is small. The square has +// ten places, so its tiles are coded ten times a film at most, and only have +// to be low. The rest is coded once. More columns cost bytes - every seam +// loses the prediction across it - and buy no time. +type grid struct { + width, height int + cols, rows []int +} + +const superblock = 64 + +// sbOf is how many superblocks a side of this many pixels takes, which is +// what tile_info counts: MiCols rounded to whole superblocks. +func sbOf(px int) int { return (px + superblock - 1) / superblock } + +func oneTile(width, height int) grid { + return grid{width: width, height: height, cols: []int{sbOf(width)}, rows: []int{sbOf(height)}} +} + +// gridFor is the layout of a film's picture. +// +// The limits are those of the lowest level the picture's size and rate allow, +// before its bytes are known: the level a film declares is chosen from its +// bytes, the bytes depend on the tiles, and a level never allows fewer tiles +// than one below it (annex.a.levels.md lines 99-114, MaxTiles and MaxTileCols +// rise with the level), so a layout that fits the lowest fits every level the +// film can end up declaring. Over the limits, the square's rows go first, then +// the clock's columns are joined, then the picture is one tile. +// +// A picture is one tile when its clock's tiles would be more than a quarter of +// it - coding them at every change would save less than three quarters of the +// time a whole picture takes, and every seam costs bytes. That is every rung +// of the ladder from 160x90 down. +func gridFor(g geometry, fps int) grid { + maxTiles, maxCols := tileLimits(chooseLevel(g.width, g.height, fps, 0, 1)) + for _, try := range [...]struct{ square, apart bool }{{true, true}, {false, true}, {false, false}} { + out := grid{width: g.width, height: g.height, rows: tileRows(g, try.square), cols: tileCols(g, try.apart)} + if out.tiles() > maxTiles || len(out.cols) > maxCols { + continue + } + if out.tiles() == 1 || 4*out.clockArea(g) > g.width*g.height { + break + } + return out + } + return oneTile(g.width, g.height) +} + +// tileLimits is MaxTiles and MaxTileCols of a level, annex.a.levels.md lines +// 99-114 (read 2026-10-06). The maximum parameters level has none of its own, +// which leaves the specification's MAX_TILE_ROWS and MAX_TILE_COLS, 64 each. +func tileLimits(level int) (tiles, cols int) { + switch { + case level <= 1: + return 8, 4 + case level <= 5: + return 16, 6 + case level <= 9: + return 32, 8 + case level <= 15: + return 64, 8 + case level <= 19: + return 128, 16 + } + return 64 * 64, 64 +} + +// tileRows cuts the rows under the clock's band, which is always in the first +// row of superblocks, and - when square - above and under the square's band. +func tileRows(g geometry, square bool) []int { + var cuts []int + if g.showClock() { + cuts = append(cuts, sbOf(g.clockEnd)) + } + if square { + cuts = append(cuts, g.squareY/superblock, sbOf(g.squareY+g.side)) + } + return sizesBetween(cuts, sbOf(g.height), g.height) +} + +// tileCols gives each superblock the clock's characters reach a column of its +// own when apart, or one column for all of them, and the rest of the width one +// column. +func tileCols(g geometry, apart bool) []int { + clock := sbOf(g.clockRight()) + var cuts []int + if apart { + for c := 1; c < clock; c++ { + cuts = append(cuts, c) + } + } + cuts = append(cuts, clock) + return sizesBetween(cuts, sbOf(g.width), g.width) +} + +// sizesBetween turns cuts, in superblocks, into the sizes between them - cuts +// at the edges or past them dropped, and a last size under 8 pixels joined to +// the one before it, because Annex A asks every tile to be at least 8 by 8 of +// the picture (CroppedTileWidth and CroppedTileHeight, annex.a.levels.md +// lines 263-266). +func sizesBetween(cuts []int, total, px int) []int { + slices.Sort(cuts) + cuts = slices.Compact(cuts) + sizes := make([]int, 0, len(cuts)+1) + prev := 0 + for _, c := range cuts { + if c > prev && c < total { + sizes = append(sizes, c-prev) + prev = c + } + } + sizes = append(sizes, total-prev) + if n := len(sizes); n > 1 && px-superblock*(total-sizes[n-1]) < 8 { + sizes[n-2] += sizes[n-1] + sizes = sizes[:n-1] + } + return sizes +} + +func (g grid) tiles() int { return len(g.cols) * len(g.rows) } + +// rect is tile i in pixels, in the order the tiles follow one another in a +// frame - across, then down - cut to the picture. +func (g grid) rect(i int) image.Rectangle { + col, row := i%len(g.cols), i/len(g.cols) + x0, y0 := 0, 0 + for _, c := range g.cols[:col] { + x0 += superblock * c + } + for _, r := range g.rows[:row] { + y0 += superblock * r + } + return image.Rect(x0, y0, min(g.width, x0+superblock*g.cols[col]), min(g.height, y0+superblock*g.rows[row])) +} + +// writeTileInfo writes tile_info for this layout, 06.bitstream.syntax.md +// lines 1176-1268, with tileSizeBytes for the sizes of every tile but the last. +// +// One tile is written the way gav1d writes it - uniform spacing, no increment +// - so a film of one tile has the bytes it had before there were tiles, and +// the golden films of one tile hold the writer to that. Several are written +// with their sizes (uniform_tile_spacing_flag 0), because the layout's sizes +// are its own and not the uniform ones the specification would derive. +func (g grid) writeTileInfo(w *bitWriter, tileSizeBytes int) { + b := boundsOf(g.width, g.height) + if g.tiles() == 1 { + w.bit(1) // uniform_tile_spacing_flag + if b.minLog2Cols < b.maxLog2Cols { + w.bit(0) // increment_tile_cols_log2 + } + if max(b.minLog2Tiles-b.minLog2Cols, 0) < b.maxLog2Rows { + w.bit(0) // increment_tile_rows_log2 + } + return + } + w.bit(0) + widest, start := 0, 0 + for _, s := range g.cols { + w.ns(s-1, min(b.sbCols-start, maxTileWidth/superblock)) // width_in_sbs_minus_1 + widest, start = max(widest, s), start+s + } + area := b.sbRows * b.sbCols + if b.minLog2Tiles > 0 { + area >>= b.minLog2Tiles + 1 + } + start = 0 + for _, s := range g.rows { + w.ns(s-1, min(b.sbRows-start, max(area/widest, 1))) // height_in_sbs_minus_1 + start += s + } + // context_update_tile_id says which tile's probabilities a frame keeps for + // the next, and every frame here keeps none (disable_frame_end_update_cdf), + // so it is the first. + w.bits(0, tileLog2(1, len(g.cols))+tileLog2(1, len(g.rows))) + w.bits(uint32(tileSizeBytes-1), 2) // tile_size_bytes_minus_1 +} + +// tileSizeBytes is how many bytes each tile's size takes in a frame of this +// film: enough for the largest tile a picture may have, which is no larger +// than the picture's reserve. One number for the film rather than one per +// frame, so a frame's length is known before its tiles are coded. +func tileSizeBytes(reserve int) int { + n := 1 + for n < 4 && max(reserve-1, 0)>>(8*n) != 0 { + n++ + } + return n +} + +// clockArea is the pixels of the tiles the clock's characters are drawn in. +func (g grid) clockArea(geo geometry) int { + if !geo.showClock() { + return 0 + } + band := image.Rect(0, geo.clockFrom, geo.clockRight(), geo.clockEnd) + area := 0 + for i := range g.tiles() { + if r := g.rect(i); r.Overlaps(band) { + area += r.Dx() * r.Dy() + } + } + return area +} diff --git a/internal/format/video/picture.go b/internal/format/video/picture.go index 9f2c9026..29dc7517 100644 --- a/internal/format/video/picture.go +++ b/internal/format/video/picture.go @@ -88,20 +88,60 @@ func Clock(c int64, t Timeline) string { return fmt.Sprintf("%s.%03d", hms, ms%1000) } +// geometry is where the pictures of a film can differ from one another: the +// clock's band and where each of its characters stands, and the square's +// band. Worked out from the size, the label and the timeline without painting +// anything, because planning needs it to cut the picture into tiles before +// any picture exists (grid.go), and a film paints by the same numbers. +type geometry struct { + width, height int + clockFrom int // the row the clock's band starts at + clockEnd int // the row under the band, where Draw stops painting it + clockChars int // nought when the picture shows no clock + cells imagelabel.Cells + side int + squareY int +} + +func geometryOf(width, height int, label string, t Timeline) geometry { + g := geometry{width: width, height: height, clockFrom: labelBand(width, label)} + g.clockEnd = g.clockFrom + if ClockShown(width, height, label, t) { + g.clockChars = len(Clock(0, t)) + g.clockEnd = min(height, g.clockFrom+imagelabel.BandHeight(width, g.clockChars)) + g.cells = imagelabel.CellsOf(width, g.clockChars) + } + g.side = min(max(width/squareDivisor, 1), maxSquareSide, height) + g.squareY = (height - g.side) * 3 / 4 + return g +} + +func (g geometry) showClock() bool { return g.clockChars > 0 } + +// squareX is where the square stands in picture c. +func (g geometry) squareX(c int64) int { + return (g.width - g.side) * int(c%squareSteps) / (squareSteps - 1) +} + +// clockRight is the column after the clock's last character, nought without +// a clock. +func (g geometry) clockRight() int { + if !g.showClock() { + return 0 + } + return g.cells.First + (g.clockChars-1)*g.cells.Step + g.cells.Width +} + // film is what every picture of one film has in common, made once and only // read after: the gradient with the label burned in, the same gradient in // planes, and where the clock and the square go. The painters of one film // share it, which is what lets several of them paint at once (ahead.go). type film struct { + geometry t Timeline base *image.RGBA basePlanes Planes planesBuf []uint8 // what basePlanes lie over, copied whole by a painter - clockFrom int // the row the clock's band starts at - clockEnd int // the row under the band, where Draw stops painting it - showClock bool - side int - squareY int square *image.Uniform // strips are the only rows a picture can differ from the gradient in - // the clock's band and the square's - each widened to whole pairs of @@ -121,20 +161,9 @@ func newFilm(width, height int, seed uint64, label string, t Timeline) *film { if Labelled(width, label) { imagelabel.Draw(base, label) } - f := &film{ - t: t, base: base, - clockFrom: labelBand(width, label), - showClock: ClockShown(width, height, label, t), - side: min(max(width/squareDivisor, 1), maxSquareSide, height), - square: image.NewUniform(ink), - } + f := &film{geometry: geometryOf(width, height, label, t), t: t, base: base, square: image.NewUniform(ink)} f.basePlanes, f.planesBuf = newPlanes(width, height) toPlanes(base, f.basePlanes) - f.squareY = (height - f.side) * 3 / 4 - f.clockEnd = f.clockFrom - if f.showClock { - f.clockEnd = min(height, f.clockFrom+imagelabel.BandHeight(width, len(Clock(0, t)))) - } f.strips = changingRows(width, height, [][2]int{{f.clockFrom, f.clockEnd}, {f.squareY, f.squareY + f.side}}) return f } @@ -187,10 +216,14 @@ type look struct { x int } -func (f *film) lookOf(c int64) look { - l := look{x: (f.base.Rect.Dx() - f.side) * int(c%squareSteps) / (squareSteps - 1)} - if f.showClock { - l.clock = Clock(c, f.t) +func (f *film) lookOf(c int64) look { return lookAt(f.geometry, f.t, c) } + +// lookAt is the look of picture c, from the film's geometry alone - planning +// walks the looks of a whole film without painting it (Stream.Work). +func lookAt(g geometry, t Timeline, c int64) look { + l := look{x: g.squareX(c)} + if g.showClock() { + l.clock = Clock(c, t) } return l } @@ -201,7 +234,7 @@ func (f *film) lookOf(c int64) look { func (f *film) whole(l look) Planes { work := image.NewRGBA(f.base.Rect) copy(work.Pix, f.base.Pix) - if f.showClock { + if f.showClock() { b := work.Rect imagelabel.Draw(work.SubImage(image.Rect(0, f.clockFrom, b.Dx(), b.Dy())).(*image.RGBA), l.clock) } @@ -243,27 +276,38 @@ func (f *film) painter() *painter { return p } -// draw paints the picture with this look into the painter's planes and +// draw paints the whole picture with this look into the painter's planes and // returns them. They are overwritten by the next call. +func (p *painter) draw(l look) Planes { return p.drawIn(l, p.f.base.Rect) } + +// drawIn paints the picture with this look and converts only the pixels +// inside r - the tile a film is about to code - so only r of the planes it +// returns may be read: the rest holds whatever an earlier picture left. r +// starts on an even row and an even column, as every tile does. // -// Each strip is the gradient's rows copied, the clock drawn when its band -// starts in the strip, the square drawn where it crosses the strip, and the -// rows converted - the same steps, in the same order, as whole, on fewer -// rows. The clock's band always lies whole inside one strip, because the -// strips were cut around it, so Draw sizes it as it would on the whole -// picture. -func (p *painter) draw(l look) Planes { +// Each strip crossing r is the gradient's rows copied, the clock drawn when +// its band starts in the strip, the square drawn where it crosses the strip, +// and the part of it inside r converted - the same steps, in the same order, +// as whole, on fewer pixels. The rows are copied across the whole width, +// because the clock's characters are placed from the picture's left edge. +// The clock's band always lies whole inside one strip, because the strips +// were cut around it, so Draw sizes it as it would on the whole picture. +func (p *painter) drawIn(l look, r image.Rectangle) Planes { f := p.f w := f.base.Rect.Dx() for i := range p.strips { s := &p.strips[i] - r := s.Rect - copy(s.Pix, f.base.Pix[r.Min.Y*f.base.Stride:r.Max.Y*f.base.Stride]) - if f.showClock && r.Min.Y <= f.clockFrom && f.clockFrom < r.Max.Y { - imagelabel.Draw(s.SubImage(image.Rect(0, f.clockFrom, w, r.Max.Y)).(*image.RGBA), l.clock) + in := s.Rect.Intersect(r) + if in.Empty() { + continue + } + rows := s.Pix[(in.Min.Y-s.Rect.Min.Y)*s.Stride : (in.Max.Y-s.Rect.Min.Y)*s.Stride] + copy(rows, f.base.Pix[in.Min.Y*f.base.Stride:in.Max.Y*f.base.Stride]) + if f.showClock() && s.Rect.Min.Y <= f.clockFrom && f.clockFrom < s.Rect.Max.Y { + imagelabel.Draw(s.SubImage(image.Rect(0, f.clockFrom, w, s.Rect.Max.Y)).(*image.RGBA), l.clock) } draw.Draw(s, image.Rect(l.x, f.squareY, l.x+f.side, f.squareY+f.side), f.square, image.Point{}, draw.Src) - convertRows(s, p.planes, r.Min.Y, r.Max.Y) + convertRect(s, p.planes, in) } return p.planes } @@ -283,25 +327,25 @@ func (p *painter) draw(l look) Planes { // // Chroma is the rounded mean of the two by two block it covers, so an odd // width or height averages the pixels that are there. -func toPlanes(img *image.RGBA, p Planes) { convertRows(img, p, 0, p.Height) } +func toPlanes(img *image.RGBA, p Planes) { convertRect(img, p, img.Rect) } -// convertRows converts rows r0 up to r1 of the picture p is, read from img - -// the whole picture, or a strip of it that starts at row r0. r0 is even and r1 -// is even or the picture's height, so every chroma sample written here covers -// rows img holds. -func convertRows(img *image.RGBA, p Planes, r0, r1 int) { +// convertRect converts the pixels inside r of the picture p is, read from img +// - the whole picture, or a strip of it whose rows hold r's. r starts on an +// even row and column and ends on even ones or the picture's edge, so every +// chroma sample written here covers pixels inside r. +func convertRect(img *image.RGBA, p Planes, r image.Rectangle) { w, h := p.Width, p.Height top := img.Rect.Min.Y - for y := r0; y < r1; y++ { + for y := r.Min.Y; y < r.Max.Y; y++ { row := img.Pix[(y-top)*img.Stride:] - for x := range w { - r, g, b := int(row[4*x]), int(row[4*x+1]), int(row[4*x+2]) - p.Y[y*w+x] = uint8(16 + (47*r+157*g+16*b+128)>>8) + for x := r.Min.X; x < r.Max.X; x++ { + red, g, b := int(row[4*x]), int(row[4*x+1]), int(row[4*x+2]) + p.Y[y*w+x] = uint8(16 + (47*red+157*g+16*b+128)>>8) } } cw := (w + 1) / 2 - for cy := r0 / 2; cy < (r1+1)/2; cy++ { - for cx := range cw { + for cy := r.Min.Y / 2; cy < (r.Max.Y+1)/2; cy++ { + for cx := r.Min.X / 2; cx < (r.Max.X+1)/2; cx++ { p.U[cy*cw+cx], p.V[cy*cw+cx] = chroma(img, 2*cx, 2*cy, w, h) } } diff --git a/internal/format/video/pictures.go b/internal/format/video/pictures.go new file mode 100644 index 00000000..36fc8980 --- /dev/null +++ b/internal/format/video/pictures.go @@ -0,0 +1,244 @@ +package video + +import ( + "fmt" + + "github.com/donislawdev/TestingFilesGenerator/internal/core" +) + +// keptClockBytes is how many bytes of tiles that show the clock one film keeps +// to use again. The rest of its tiles it keeps whatever they come to - the +// tiles nothing changes, once each, and the square's, ten places each - but a +// tile of the clock is new at nearly every change, and a day of changes on a +// narrow picture, the whole clock in one tile, would be a hundred thousand of +// them. Eight megabytes holds every place of the clock's seconds tile at +// 1920x1080 several times over (a tile is a kilobyte or two). A tile not kept +// is coded again when its key comes back, which costs time and no bytes. +const keptClockBytes = 8 << 20 + +// keysAhead is how many keys a tile is given room for before its film's maps +// grow - the ten places of the square and its picture without it, so a short +// film, the one whose allocations are counted, never grows them. +const keysAhead = 12 + +// Pictures codes the pictures of one film in the order the film shows them, a +// tile at a time (grid.go), on the goroutine that asks for them and on the +// helpers it can take (ahead.go). A tile is coded the first time its key comes +// up and taken from what was coded every time after, and the pictures coded +// ahead of the one being written are a few - an hour at a change a second is +// 3600 pictures, and holding them all would hold the film. +// +// Close has to be called when the film is written or abandoned, because the +// helpers are goroutines. +type Pictures struct { + choice Choice + stream Stream + // crew holds the film and its keyer as well as the helpers. + crew *crew + // known is every tile coded or offered in this film, by its key, and kept + // counts the bytes of the ones that show the clock. + known map[tileKey]*job + kept int + // seen is every key that has come up, kept or not, for the work a change + // reports - the same walk Stream.Work makes. + seen map[tileKey]struct{} + // pending are the pictures offered and not yet reached, in order, each + // with the change it opens. Changes that look the same as the one before + // open none, so they are not in it. + pending []opened + next int64 // the first change not looked at yet + last look // the look of change next-1 + change int64 + work int64 + tiles []tileCoded + key []byte + copied []byte + // spare are the job lists of pictures already made, cleared, for the + // pictures opened after them. + spare [][]*job +} + +// opened is a picture offered: the change it opens, the job of each of its +// tiles in frame order, and the work its new keys are worth. +type opened struct { + change int64 + jobs []*job + work int64 +} + +// Pictures is the coder of this choice's film. +func (c Choice) Pictures(st Stream) *Pictures { + f := newFilm(c.Width, c.Height, c.Seed, c.Label, st.Timeline) + ks := newKeyer(f.geometry, st.shape.grid) + p := &Pictures{ + choice: c, stream: st, change: -1, + crew: newCrew(f, ks, c.QIndex, st.Changes()*int64(len(ks.tiles))), + known: make(map[tileKey]*job, len(c.tiles)+keysAhead*len(ks.tiles)), + seen: make(map[tileKey]struct{}, keysAhead*len(ks.tiles)), + tiles: make([]tileCoded, len(ks.tiles)), + } + p.pending = make([]opened, 0, p.crew.ahead()+1) + for k, t := range c.tiles { + p.known[k] = codedJob(t) + } + return p +} + +// At makes picture c the current one, coding the tiles of it nobody has coded +// yet, unless it looks the same as the one before. Pictures are asked for in +// the order the film shows them - the same change again is fine, an earlier +// one is a defect of the caller. +// +// Every picture is held to the stream's reserve, because the level and the +// file around it were planned on it. A picture over it is a ceiling that did +// not hold - the same class of failure as a ladder rung coding past its +// ceiling - and it is refused as a defect rather than written into a file that +// would come out the wrong size. +func (p *Pictures) At(c int64) error { + if c == p.change { + return nil + } + if c < p.change { + return core.Defect(fmt.Errorf("video: picture %d was asked for after picture %d, and a film's pictures are coded in the order it shows them", c, p.change)) + } + p.offerTo(c) + var reached []opened + n := 0 + for n < len(p.pending) && p.pending[n].change <= c { + n++ + } + reached = p.pending[:n] + p.change, p.work = c, 0 + for _, o := range reached { + p.work += o.work + } + if n == 0 { + // The same look as the picture before it: shown again, not coded. + return nil + } + err := p.make(reached[n-1].jobs) + for _, o := range reached { + clear(o.jobs) + p.spare = append(p.spare, o.jobs) + } + // Moved down rather than sliced off, so the one array made for the + // window lasts the whole film. + p.pending = p.pending[:copy(p.pending, p.pending[n:])] + return err +} + +// make waits for the tiles of the picture and builds the two samples it is +// carried in. +func (p *Pictures) make(jobs []*job) error { + size := 0 + for i, j := range jobs { + t, err := p.crew.wait(j) + if err != nil { + return err + } + p.tiles[i] = t + size += len(t.data) + if j.counted { + p.kept += len(t.data) + j.counted = false + } + } + if size > p.stream.Reserve { + return core.Defect(fmt.Errorf("video: picture %d of a %dx%d film coded to %d B of tiles and the film was planned on %d B a picture, so the file cannot be kept", + p.change, p.choice.Width, p.choice.Height, size, p.stream.Reserve)) + } + var err error + if p.key, err = p.stream.shape.sample(p.key, p.stream.seq, keyKind, p.tiles, nil); err != nil { + return err + } + p.copied, err = p.stream.shape.sample(p.copied, nil, copyKind, p.tiles, p.stream.show) + return err +} + +// offerTo looks at every change up to c and as far past it as the crew codes +// ahead, and offers the tiles of each one that looks different from the one +// before it. +func (p *Pictures) offerTo(c int64) { + n := p.stream.Changes() + for p.next < n && (p.next <= c || len(p.pending) < p.crew.ahead()) { + l := p.crew.film.lookOf(p.next) + if p.next == 0 || l != p.last { + p.pending = append(p.pending, p.open(l)) + } + p.last = l + p.next++ + } + if p.next == n { + p.crew.finish() + } +} + +// open is the picture with this look at change p.next: each tile a job +// already known by its key, or a new one offered to the helpers. +func (p *Pictures) open(l look) opened { + o := opened{change: p.next} + if n := len(p.spare); n > 0 { + o.jobs, p.spare = p.spare[n-1], p.spare[:n-1] + } else { + o.jobs = make([]*job, len(p.crew.keys.tiles)) + } + o.work = p.crew.keys.fresh(l, p.seen) * workPerPixel + for i := range p.crew.keys.tiles { + o.jobs[i] = p.jobFor(i, l) + } + return o +} + +// jobFor is the job of tile i of the picture with this look: the one already +// known by its key, or a new one offered to the helpers and kept by its key - +// a tile of the clock only while the film has room for more of them. +func (p *Pictures) jobFor(i int, l look) *job { + k := p.crew.keys.key(i, l) + if j, ok := p.known[k]; ok { + return j + } + j := newJob(l, p.next, i) + p.crew.offer(j) + if !k.showsClock(p.crew.keys) { + p.known[k] = j + } else if p.kept < keptClockBytes { + p.known[k], j.counted = j, true + } + return j +} + +// Close stops the helpers and waits for them, so nothing this film started is +// running once it returns. A helper in the middle of a tile finishes it first. +func (p *Pictures) Close() { p.crew.stop() } + +// KeySample is the current picture as a key frame, with the sequence header +// before it. It is written over by the next picture At makes. +func (p *Pictures) KeySample() []byte { return p.key } + +// CopySample is the current picture as a hidden intra only copy, and the frame +// that shows it. It is written over by the next picture At makes. +func (p *Pictures) CopySample() []byte { return p.copied } + +// CodedSize is the bytes of the tiles of picture change of this choice's +// film, each coded afresh under the film's layout - what a picture takes of +// its reserve. For the probe that measures the ladder's ceilings, which asks +// about one picture at a time. +func (c Choice) CodedSize(t Timeline, change int64) (int, error) { + f := newFilm(c.Width, c.Height, c.Seed, c.Label, t) + ks := newKeyer(f.geometry, gridFor(f.geometry, t.FPS)) + p, l := f.painter(), f.lookOf(change) + size := 0 + for _, tile := range ks.tiles { + coded, err := encodeTile(p.drawIn(l, tile.rect), tile.rect, c.QIndex) + if err != nil { + return 0, err + } + size += len(coded.data) + } + return size, nil +} + +// Work is what making the current picture was worth, for the bar a run draws +// - the pixels of its tiles whose keys came up for the first time, nought for +// a picture shown again. Added up over a film it is Stream.Work. +func (p *Pictures) Work() int64 { return p.work } diff --git a/internal/format/video/settings.go b/internal/format/video/settings.go index 976b44b2..ae715893 100644 --- a/internal/format/video/settings.go +++ b/internal/format/video/settings.go @@ -83,7 +83,7 @@ func Properties() []format.Property { { Name: SettingChangeInterval, Kind: format.PropertyDuration, Min: 1, Max: maxDurationMs, Default: core.FormatDuration(defaultChangeIntervalMs), - Detail: "How often the picture changes: the clock in it moves on and the square takes a step. Each change costs a whole picture in bytes and in time. An interval as long as the film or longer keeps one picture throughout.", + Detail: "How often the picture changes: the clock in it moves on and the square takes a step. Each change costs a whole picture in bytes. An interval as long as the film or longer keeps one picture throughout.", }, { Name: SettingFrameRate, Kind: format.PropertyChoice, @@ -200,11 +200,14 @@ func tooManyKeyFrames(formatID string, t Timeline, interval int64) error { // MaxChanges bounds how many pictures one film shows. // -// Every change is a picture coded on its own - gav1d codes stills, so nothing -// is carried over from the picture before - and a picture costs its bytes and -// its time: 34 ms at 640x360 and about a second at 3840x2160, measured on -// 2026-10-06 (docs/WIDEO-2026-10-06.md section 14). A change a second for a -// day is under the bound, a change every frame for an hour is not. +// Every change is a picture of its own - gav1d codes stills, so nothing is +// carried over from the picture before - and a picture costs its bytes. Its +// time was 34 ms at 640x360 and about a second at 3840x2160 when every picture +// was coded whole (2026-10-06, docs/WIDEO-2026-10-06.md section 14). Cut into +// tiles, a change codes only the tiles nobody coded before, a few +// milliseconds (docs/WEBM-WYDAJNOSC-2026-10-06.md section 10). A change a +// second for a day is under the bound, a change every frame for an hour is +// not. const MaxChanges = 100_000 func tooManyChanges(formatID string, t Timeline, interval int64) error { diff --git a/internal/format/video/still.go b/internal/format/video/still.go index 61d66567..a061c93f 100644 --- a/internal/format/video/still.go +++ b/internal/format/video/still.go @@ -2,6 +2,7 @@ package video import ( "fmt" + "image" "github.com/gen2brain/gav1d/av1" @@ -13,27 +14,54 @@ import ( // thousand files should not spend its time on it. const encodeSpeed = 10 -// Coded is one picture through gav1d, taken apart into what this package -// wraps in headers of its own: the frame header bits from tile_info to the end -// of the header, and the tile data after them. -type Coded struct { - suffix []byte - suffixBits uint - tile []byte +// tileCoded is one tile through gav1d, coded as a picture of its own and +// taken apart into what this package wraps in headers of its own: the frame +// header bits after tile_info - the quantizer to reduced_tx_set - and the +// tile data after them. tile_info itself is this package's, because it +// describes the frame the tile goes into rather than the picture gav1d coded. +// +// The bits are read where gav1d wrote them, from and to in frame, rather than +// copied out: a film codes a tile at every change, and what a file allocates +// is counted. +type tileCoded struct { + frame []byte + from, to uint + data []byte } -// Size is how many bytes the picture's tile data takes, which is what every -// frame that carries the picture costs above its few bytes of header. -func (c Coded) Size() int { return len(c.tile) } +// sameHeader says whether two tiles were coded under the same header bits, +// which every tile of one frame has to be: gav1d writes them from the +// quantizer alone (gav1d v0.2.5, av1/encode_hdr.go, writeFrameHdr - the loop +// filter off, no delta, no tx_mode_select), measured on 2026-10-06 as one +// suffix per quantizer across 27 pictures of 9 sizes. A later gav1d that chose +// them from the picture would break that, and the frame is refused rather +// than written with one tile's header over another's data. +func (t tileCoded) sameHeader(o tileCoded) bool { + if t.to-t.from != o.to-o.from { + return false + } + a, b := bitReader{b: t.frame, pos: t.from}, bitReader{b: o.frame, pos: o.from} + for range t.to - t.from { + if a.bit() != b.bit() { + return false + } + } + return true +} -func (c Coded) writeSuffix(w *bitWriter) { - r := bitReader{b: c.suffix} - for range c.suffixBits { +func (t tileCoded) writeRest(w *bitWriter) { + r := bitReader{b: t.frame, pos: t.from} + for r.pos < t.to { w.bit(r.bit()) } } -// Encode codes a picture. +// encodeTile codes the part r of a picture as a picture of its own. gav1d +// reads its source a row at a time by the stride and never past Width and +// Height (av1/encode_intra.go, residualEdge, the edge clamped to sw-1 and +// sh-1), so the tile is read where it lies in the picture's planes rather +// than copied out of them. r starts on an even row and column, as a tile does, +// so its chroma is the picture's. // // gav1d's output is read here by the specification rather than by what // gav1d's writer happens to do, and anything other than the one shape this @@ -41,26 +69,29 @@ func (c Coded) writeSuffix(w *bitWriter) { // point: a gav1d raised underneath us that changed its header would otherwise // go on producing files, and every one of them would carry a header this // package wrote for a different encoder. -func Encode(p Planes, qindex int) (Coded, error) { +func encodeTile(p Planes, r image.Rectangle, qindex int) (tileCoded, error) { + cw := (p.Width + 1) / 2 + luma, chroma := r.Min.Y*p.Width+r.Min.X, (r.Min.Y/2)*cw+r.Min.X/2 out := av1.Encode(av1.EncodeConfig{ - Width: p.Width, Height: p.Height, BitDepth: 8, QIndex: qindex, Speed: encodeSpeed, - Src: p.Y, SrcStride: p.Width, SrcU: p.U, SrcV: p.V, SrcUVStride: (p.Width + 1) / 2, + Width: r.Dx(), Height: r.Dy(), BitDepth: 8, QIndex: qindex, Speed: encodeSpeed, + Src: p.Y[luma:], SrcStride: p.Width, SrcU: p.U[chroma:], SrcV: p.V[chroma:], SrcUVStride: cw, }) if out == nil { - return Coded{}, core.Defect(fmt.Errorf("video: the encoder refused a %dx%d picture at quantizer %d", p.Width, p.Height, qindex)) + return tileCoded{}, core.Defect(fmt.Errorf("video: the encoder refused a %dx%d picture at quantizer %d", r.Dx(), r.Dy(), qindex)) } - units, err := splitUnits(out) + var room [4]unit + units, err := splitUnits(out, room[:0]) if err != nil { - return Coded{}, core.Defect(fmt.Errorf("video: the encoder's output is not a sequence of OBUs: %w", err)) + return tileCoded{}, core.Defect(fmt.Errorf("video: the encoder's output is not a sequence of OBUs: %w", err)) } if len(units) != 3 || units[0].typ != obuTemporalDelimiter || units[1].typ != obuSequenceHeader || units[2].typ != obuFrame { - return Coded{}, core.Defect(fmt.Errorf("video: the encoder wrote %s where a temporal delimiter, a sequence header and one frame were expected", describeUnits(units))) + return tileCoded{}, core.Defect(fmt.Errorf("video: the encoder wrote %s where a temporal delimiter, a sequence header and one frame were expected", describeUnits(units))) } - c, err := splitFrame(units[2].payload, p.Width, p.Height) + t, err := splitFrame(units[2].payload, r.Dx(), r.Dy()) if err != nil { - return Coded{}, core.Defect(fmt.Errorf("video: the encoder's frame header is not the one this package re-wraps: %w", err)) + return tileCoded{}, core.Defect(fmt.Errorf("video: the encoder's frame header is not the one this package re-wraps: %w", err)) } - return c, nil + return t, nil } type unit struct { @@ -68,8 +99,9 @@ type unit struct { payload []byte } -func splitUnits(b []byte) ([]unit, error) { - var out []unit +// splitUnits appends the OBUs of b to out, which a caller gives room in so +// that taking apart the few OBUs of one tile allocates nothing. +func splitUnits(b []byte, out []unit) ([]unit, error) { for len(b) > 0 { h := b[0] if h&0x80 != 0 || h&0x04 != 0 || h&0x02 == 0 { @@ -94,69 +126,83 @@ func describeUnits(units []unit) string { } // splitFrame reads a still picture's frame header - reduced still picture -// header, key frame, shown - and keeps everything from tile_info on. +// header, key frame, shown - and keeps everything after tile_info. // // The three bits before tile_info are disable_cdf_update, // allow_screen_content_tools and render_and_frame_size_different, and all // three have to be zero, because the headers this package writes say exactly -// that. From tile_info to reduced_tx_set the fields are the ones every intra -// frame carries, so they are copied as they are. Anything this reader does not -// expect - more than one tile, a quantizer matrix, segmentation, a delta -// quantizer, loop filter deltas - is refused, because none of it is something -// the copied bits would still mean once they sit under another header. -func splitFrame(frame []byte, width, height int) (Coded, error) { +// that. tile_info has to say one tile, the picture gav1d was given. After it, +// to reduced_tx_set, the fields are the ones every intra frame carries, so +// they are copied as they are. Anything this reader does not expect - more +// than one tile, a quantizer matrix, segmentation, a delta quantizer, loop +// filter deltas - is refused, because none of it is something the copied bits +// would still mean once they sit under another header. +func splitFrame(frame []byte, width, height int) (tileCoded, error) { r := bitReader{b: frame} if r.bits(3) != 0 { - return Coded{}, core.Defect(fmt.Errorf("disable_cdf_update, allow_screen_content_tools or render_and_frame_size_different is set")) + return tileCoded{}, core.Defect(fmt.Errorf("disable_cdf_update, allow_screen_content_tools or render_and_frame_size_different is set")) } - start := r.pos if err := readTileInfo(&r, width, height); err != nil { - return Coded{}, err + return tileCoded{}, err } + start := r.pos if err := readQuantizerToTxSet(&r); err != nil { - return Coded{}, err + return tileCoded{}, err } end := r.pos for r.pos%8 != 0 { if r.bit() != 0 { - return Coded{}, core.Defect(fmt.Errorf("byte_alignment after the header is not zero")) + return tileCoded{}, core.Defect(fmt.Errorf("byte_alignment after the header is not zero")) } } if r.overrun || r.pos/8 >= uint(len(frame)) { - return Coded{}, core.Defect(fmt.Errorf("the header runs to the end of the frame and leaves no tile")) + return tileCoded{}, core.Defect(fmt.Errorf("the header runs to the end of the frame and leaves no tile")) } + return tileCoded{frame: frame, from: start, to: end, data: frame[r.pos/8:]}, nil +} - var w bitWriter - copyBits := bitReader{b: frame, pos: start} - for copyBits.pos < end { - w.bit(copyBits.bit()) - } - return Coded{suffix: w.bytes(), suffixBits: end - start, tile: frame[r.pos/8:]}, nil +// tileBounds is what tile_info derives from a frame's size before it reads a +// bit, 06.bitstream.syntax.md lines 1176-1192: the frame in superblocks, the +// fewest columns and tiles it may be cut into, and the most columns and rows. +// The reader below and the writer in grid.go both start from it. +type tileBounds struct { + sbCols, sbRows int + minLog2Cols, maxLog2Cols int + maxLog2Rows, minLog2Tiles int +} + +func boundsOf(width, height int) tileBounds { + b := tileBounds{sbCols: sbOf(width), sbRows: sbOf(height)} + b.minLog2Cols = tileLog2(maxTileWidth/superblock, b.sbCols) + b.maxLog2Cols = tileLog2(1, min(b.sbCols, maxTileCols)) + b.maxLog2Rows = tileLog2(1, min(b.sbRows, maxTileRows)) + b.minLog2Tiles = max(b.minLog2Cols, tileLog2(maxTileArea/(superblock*superblock), b.sbRows*b.sbCols)) + return b } +// MAX_TILE_WIDTH, MAX_TILE_AREA, MAX_TILE_ROWS and MAX_TILE_COLS, from the +// specification's table of symbols (03.symbols.md lines 41-44). +const ( + maxTileWidth = 4096 + maxTileArea = 4096 * 2304 + maxTileRows = 64 + maxTileCols = 64 +) + // readTileInfo walks tile_info with uniform spacing, section 5.9.15, and -// accepts one tile only - the size of a frame this package codes stays within -// what one tile holds, and a frame that does not is one the copied header -// would describe wrongly. +// accepts one tile only - gav1d was given one tile's worth of picture, and a +// frame it cut into more is one whose data this package would misread. func readTileInfo(r *bitReader, width, height int) error { - miCols := 2 * ((width + 7) >> 3) - miRows := 2 * ((height + 7) >> 3) - sbCols := (miCols + 15) >> 4 - sbRows := (miRows + 15) >> 4 - minLog2TileCols := tileLog2(4096>>6, sbCols) - maxLog2TileCols := tileLog2(1, min(sbCols, 64)) - maxLog2TileRows := tileLog2(1, min(sbRows, 64)) - minLog2Tiles := max(minLog2TileCols, tileLog2((4096*2304)>>12, sbRows*sbCols)) - + b := boundsOf(width, height) if r.bit() != 1 { return core.Defect(fmt.Errorf("uniform_tile_spacing_flag is not set")) } - cols := minLog2TileCols - for cols < maxLog2TileCols && r.bit() == 1 { + cols := b.minLog2Cols + for cols < b.maxLog2Cols && r.bit() == 1 { cols++ } - rows := max(minLog2Tiles-cols, 0) - for rows < maxLog2TileRows && r.bit() == 1 { + rows := max(b.minLog2Tiles-cols, 0) + for rows < b.maxLog2Rows && r.bit() == 1 { rows++ } if cols > 0 || rows > 0 { diff --git a/internal/format/video/stream.go b/internal/format/video/stream.go index 8923f8d4..928d8871 100644 --- a/internal/format/video/stream.go +++ b/internal/format/video/stream.go @@ -1,45 +1,42 @@ package video -import ( - "fmt" - - "github.com/donislawdev/TestingFilesGenerator/internal/core" -) - -// maxSuffixBits is the longest frame header splitFrame accepts from tile_info -// on: tile_info at most 3 bits for one tile, the quantizer 14, the loop filter -// 29 with the tx mode, and reduced_tx_set 1. Planning counts every picture -// with it, so no picture the reader accepts can carry a longer header than -// planning counted. -const maxSuffixBits = 47 - -// The frame headers this package writes before a picture's suffix, in bits: -// a shown key frame says eight things, a hidden intra only copy eighteen. +// maxRestBits is the longest run of frame header bits after tile_info that +// splitFrame accepts: the quantizer 14, the loop filter 29 with the tx mode, +// and reduced_tx_set 1. Planning counts every picture with it, so no picture +// the reader accepts can carry a longer header than planning counted. +const maxRestBits = 44 + +// The frame headers this package writes before tile_info, in bits: a shown +// key frame says eight things, a hidden intra only copy eighteen. const ( keyHeaderBits = 8 hiddenHeaderBits = 18 ) // Stream is what a film's AV1 samples have in common: the sequence header and -// its level, the frame that shows a picture again, and the most bytes any one -// picture may take. The samples that carry a picture are made per picture, by -// Pictures - no temporal delimiters, every OBU with its size, as both -// containers carry them. +// its level, how its picture is cut into tiles, the frame that shows a picture +// again, and the most bytes any one picture may take. The samples that carry +// a picture are made per picture, by Pictures - no temporal delimiters, every +// OBU with its size, as both containers carry them. type Stream struct { Timeline Width, Height int // Level is the seq_level_idx the sequence header declares. Level int - // Reserve is the most tile bytes any picture of the film may take. + // Reserve is the most tile bytes any picture of the film may take - the + // bytes of all its tiles together. Reserve int + geo geometry + shape frameShape config []byte seq []byte show []byte } // NewStream is the stream of a film whose pictures each take at most reserve -// bytes of tile. +// bytes of tiles, labelled with label - the label moves the clock down, and +// the clock decides the tiles (grid.go). // // The level is chosen from the reserve rather than from the pictures, because // the sequence header is written before any picture after the first is coded. @@ -50,25 +47,38 @@ type Stream struct { // container's codec configuration, so a player that starts at a key frame has // it there - which is what the AV1 bindings of both containers ask of a sample // a player can start from. -func NewStream(t Timeline, reserve, width, height int) Stream { - key, _ := frameLens(maxSuffixBits, reserve) +func NewStream(t Timeline, reserve, width, height int, label string) Stream { + geo := geometryOf(width, height, label, t) + shape := frameShape{grid: gridFor(geo, t.FPS), tileSizeBytes: tileSizeBytes(reserve)} + key, _ := shape.frameLens(reserve) level := chooseLevel(width, height, t.FPS, key, int(t.CodedPerSecond())) seq := obu(obuSequenceHeader, sequenceHeader(width, height, level)) return Stream{ Timeline: t, Width: width, Height: height, Level: level, Reserve: reserve, - config: codecConfig(level, seq), seq: seq, show: showCopy(), + geo: geo, shape: shape, config: codecConfig(level, seq), seq: seq, show: showCopy(), } } +// Tiles is how many AV1 tiles each picture of the film is cut into. +func (s Stream) Tiles() int { return s.shape.grid.tiles() } + // frameLens is how long the key frame OBU and the hidden copy OBU around a -// picture are, from the length of its header suffix and of its tile - counted, -// not built, so planning a film never allocates its pictures. -func frameLens(suffixBits, tile int) (key, hidden int) { +// picture are when its tiles come to tiles bytes - counted, not built, so +// planning a film never allocates its pictures. The header is counted at the +// longest the reader accepts. +func (s frameShape) frameLens(tiles int) (key, hidden int) { + var w bitWriter + s.grid.writeTileInfo(&w, s.tileSizeBytes) + info := int(w.n) + group := 0 + if n := s.grid.tiles(); n > 1 { + group = 1 + (n-1)*s.tileSizeBytes + } obuLen := func(headerBits int) int { - payload := (headerBits+7)/8 + tile + payload := (headerBits+7)/8 + group + tiles return 1 + len(leb128(payload)) + payload } - return obuLen(keyHeaderBits + suffixBits), obuLen(hiddenHeaderBits + suffixBits) + return obuLen(keyHeaderBits + info + maxRestBits), obuLen(hiddenHeaderBits + info + maxRestBits) } // Config is the AV1 codec configuration record with the sequence header OBU, @@ -84,7 +94,7 @@ func (s Stream) ShowSample() []byte { return s.show } // picture again - for a container's arithmetic to plan with before any picture // is coded. func (s Stream) BoundBytes() (key, copied, shown int) { - k, h := frameLens(maxSuffixBits, s.Reserve) + k, h := s.shape.frameLens(s.Reserve) return len(s.seq) + k, h + len(s.show), len(s.show) } @@ -96,142 +106,41 @@ func (s Stream) BoundBytes() (key, copied, shown int) { // between 8 and 74 depending on the machine, and this is one of four to eight // threads. A wrong figure misjudges only the padding against the pictures, // and the padding takes seconds, so the estimate is off by seconds rather -// than by the film. +// than by the film. Since a film is coded a tile at a time, the pixels are +// those of the tiles coded, not of every picture. const workPerPixel = 16 -// PictureWork is what coding one picture of the film is worth in bytes of -// writing, which a container reports as each change is coded. -func (s Stream) PictureWork() int64 { return int64(s.Width) * int64(s.Height) * workPerPixel } - -// Work is what coding every picture of the film is worth (format.Plan.Work). -func (s Stream) Work() int64 { return s.Changes() * s.PictureWork() } - -// joined is a followed by b in one allocation of the right size. -func joined(a, b []byte) []byte { - return append(append(make([]byte, 0, len(a)+len(b)), a...), b...) -} - -// Pictures codes the pictures of one film in the order the film shows them, -// on the goroutine that asks for them and on the helpers it can take -// (ahead.go), and keeps only the last one it was asked for and the few coded -// ahead of it - an hour at a change a second is 3600 pictures, and holding -// them all would hold the film. -// -// Close has to be called when the film is written or abandoned, because the -// helpers are goroutines. -type Pictures struct { - choice Choice - stream Stream - film *film - crew *crew - // pending are the pictures offered and not yet reached, in order, each - // with the change it opens. Changes that look the same as the one before - // open none, so they are not in it. - pending []openedBy - next int64 // the first change not looked at yet - last look // the look of change next-1 - change int64 - key []byte - copied []byte -} - -type openedBy struct { - change int64 - job *job -} - -// Pictures is the coder of this choice's film. -func (c Choice) Pictures(st Stream) *Pictures { - f := newFilm(c.Width, c.Height, c.Seed, c.Label, st.Timeline) - cr := newCrew(f, c.QIndex, st.Changes()) - return &Pictures{choice: c, stream: st, film: f, crew: cr, change: -1, - pending: make([]openedBy, 0, cr.ahead()+1)} -} - -// At makes picture c the current one, coding it unless it looks the same as -// the one before. Pictures are asked for in the order the film shows them - -// the same change again is fine, an earlier one is a defect of the caller. -// -// Every picture is held to the stream's reserve, because the level and the -// file around it were planned on it. A picture over it is a ceiling that did -// not hold - the same class of failure as a ladder rung coding past its -// ceiling - and it is refused as a defect rather than written into a file that -// would come out the wrong size. -func (p *Pictures) At(c int64) error { - if c == p.change { - return nil - } - if c < p.change { - return core.Defect(fmt.Errorf("video: picture %d was asked for after picture %d, and a film's pictures are coded in the order it shows them", c, p.change)) - } - p.offerTo(c) - var opened *job - reached := 0 - for reached < len(p.pending) && p.pending[reached].change <= c { - opened = p.pending[reached].job - reached++ - } - // Moved down rather than sliced off, so the one array made for the - // window lasts the whole film. - p.pending = p.pending[:copy(p.pending, p.pending[reached:])] - if opened == nil { - // The same look as the picture before it: shown again, not coded. - p.change = c - return nil - } - coded, err := p.crew.wait(opened) - if err != nil { - return err - } - if coded.Size() > p.stream.Reserve { - return core.Defect(fmt.Errorf("video: picture %d of a %dx%d film coded to a %d B tile and the film was planned on %d B a picture, so the file cannot be kept", - c, p.choice.Width, p.choice.Height, coded.Size(), p.stream.Reserve)) - } - p.change = c - p.key = joined(p.stream.seq, keyFrame(coded)) - p.copied = joined(hiddenCopy(coded), p.stream.show) - return nil -} - -// offerTo looks at every change up to c and as far past it as the crew codes -// ahead, and offers each one that looks different from the one before it. -// Change 0 is the picture planning already coded, when it coded one. -func (p *Pictures) offerTo(c int64) { - n := p.stream.Changes() - for p.next < n && (p.next <= c || len(p.pending) < p.crew.ahead()) { - l := p.film.lookOf(p.next) - if p.next == 0 || l != p.last { - p.pending = append(p.pending, openedBy{change: p.next, job: p.jobFor(l)}) +// Work is what coding the film is worth (format.Plan.Work): every tile the +// first time its key comes up, at workPerPixel a pixel. Worked out by walking +// the changes' looks, with no painting and no coding - the walk Pictures makes +// while it writes, so what writing reports change by change adds up to this. +func (s Stream) Work() int64 { + ks := newKeyer(s.geo, s.shape.grid) + seen := make(map[tileKey]struct{}, ks.grid.tiles()) + var px int64 + var last look + for c := range s.Changes() { + l := lookAt(s.geo, s.Timeline, c) + if c > 0 && l == last { + continue } - p.last = l - p.next++ - } - if p.next == n { - p.crew.finish() + last = l + px += ks.fresh(l, seen) } + return px * workPerPixel } -// jobFor is the job of the picture with this look at change p.next, offered -// to the helpers - or, for change 0 when planning coded it, the picture -// planning already has. -func (p *Pictures) jobFor(l look) *job { - if p.next == 0 && p.choice.First != nil { - return codedJob(*p.choice.First) +// fresh is how many pixels the tiles of a picture with this look cover whose +// keys are not in seen yet, which it adds them to. +func (ks keyer) fresh(l look, seen map[tileKey]struct{}) int64 { + var px int64 + for i, t := range ks.tiles { + k := ks.key(i, l) + if _, ok := seen[k]; ok { + continue + } + seen[k] = struct{}{} + px += int64(t.rect.Dx()) * int64(t.rect.Dy()) } - j := newJob(l, p.next) - p.crew.offer(j) - return j + return px } - -// Close stops the helpers and waits for them, so nothing this film started is -// running once it returns. A helper in the middle of a picture finishes it -// first, which is at most one picture's time. -func (p *Pictures) Close() { p.crew.stop() } - -// KeySample is the current picture as a key frame, with the sequence header -// before it. -func (p *Pictures) KeySample() []byte { return p.key } - -// CopySample is the current picture as a hidden intra only copy, and the frame -// that shows it. -func (p *Pictures) CopySample() []byte { return p.copied } diff --git a/internal/format/video/tilekey.go b/internal/format/video/tilekey.go new file mode 100644 index 00000000..4d186742 --- /dev/null +++ b/internal/format/video/tilekey.go @@ -0,0 +1,97 @@ +package video + +import "image" + +// tileKey is everything the pixels of one tile of a film depend on, so two +// tiles with one key are the same tile and the second is not coded again. +// +// A tile is the gradient and the label, which no picture changes, the clock's +// characters drawn in it and the part of the square inside it - and nothing +// else, because a character is drawn from its own cell, the square is one +// colour, and a chroma sample covers two by two pixels that never straddle a +// tile's edge (tiles start on multiples of 64). So the key is the tile, those +// characters, and the square's columns in it - exact by construction, with no +// hash to collide, and a guard holds it to the pixels of pictures painted +// whole. +type tileKey struct { + tile int + // clock is the clock's characters drawn in the tile, four bits each - the + // low four bits of each, which tell the ten digits, ':' and '.' apart. The + // same tile always holds the same character positions, so the packed + // characters of one tile are comparable. + clock uint64 + // from and to are the square's columns inside the tile, both nought when + // it is not there. + from, to int +} + +// showsClock is whether the tile changes with the clock - the tiles a film +// keeps only while there is room (pictures.go). +func (k tileKey) showsClock(ks keyer) bool { return ks.tiles[k.tile].chars[1] > 0 } + +// keyer works out the keys of a film's tiles: for each tile, which of the +// clock's characters fall in it and whether it crosses the square's band. +type keyer struct { + geo geometry + grid grid + tiles []tilePlace +} + +type tilePlace struct { + rect image.Rectangle + // chars are the first and the after-last of the clock's characters drawn + // in the tile, both nought when none is. + chars [2]int + square bool +} + +func newKeyer(geo geometry, g grid) keyer { + ks := keyer{geo: geo, grid: g, tiles: make([]tilePlace, g.tiles())} + band := image.Rect(0, geo.clockFrom, geo.width, geo.clockEnd) + squareRows := image.Rect(0, geo.squareY, geo.width, geo.squareY+geo.side) + for i := range ks.tiles { + r := g.rect(i) + p := tilePlace{rect: r, square: r.Overlaps(squareRows)} + if geo.showClock() && r.Overlaps(band) { + p.chars = clockCharsIn(geo, r) + } + ks.tiles[i] = p + } + return ks +} + +// clockCharsIn is the run of the clock's characters whose cells cross the +// columns of r. +func clockCharsIn(geo geometry, r image.Rectangle) [2]int { + first, last := -1, -1 + for k := range geo.clockChars { + x := geo.cells.First + k*geo.cells.Step + if x >= r.Max.X || x+geo.cells.Width <= r.Min.X { + continue + } + if first < 0 { + first = k + } + last = k + } + if first < 0 { + return [2]int{} + } + return [2]int{first, last + 1} +} + +// key is tile i's key in the picture with this look. +func (ks keyer) key(i int, l look) tileKey { + p := ks.tiles[i] + k := tileKey{tile: i} + for c := p.chars[0]; c < p.chars[1]; c++ { + k.clock = k.clock<<4 | uint64(l.clock[c]&0x0f) + } + if p.square { + from, to := max(l.x, p.rect.Min.X), min(l.x+ks.geo.side, p.rect.Max.X) + if from < to { + k.from, k.to = from, to + } + } + return k +} diff --git a/internal/format/webm/webm.go b/internal/format/webm/webm.go index ffcf941e..9d9b3f58 100644 --- a/internal/format/webm/webm.go +++ b/internal/format/webm/webm.go @@ -83,7 +83,7 @@ func minimumBytes() int64 { panic(err) } smallest := video.Ladder[len(video.Ladder)-1] - return newLayout(video.NewStream(t, smallest.Ceiling, smallest.Width, smallest.Height)).boundBytes() + minVoid + return newLayout(video.NewStream(t, smallest.Ceiling, smallest.Width, smallest.Height, "")).boundBytes() + minVoid } type generator struct{} @@ -177,7 +177,7 @@ func (generator) Write(ctx context.Context, w io.Writer, p format.Plan) error { return ctx.Err() default: } - st := video.NewStream(m.settings.Timeline, m.choice.Ceiling, m.choice.Width, m.choice.Height) + st := video.NewStream(m.settings.Timeline, m.choice.Ceiling, m.choice.Width, m.choice.Height, m.choice.Label) l := newLayout(st) body := uint64(m.total - l.outside()) cuesAt := body - l.cuesLen() @@ -235,7 +235,8 @@ func (s *sticky) uint(id uint32, v uint64) { // writeClusters codes the pictures one change at a time and writes the // clusters as it goes, giving back where each key frame's cluster starts, for // the Cues, and where the last one ends. Every change opens a cluster, so each -// is reported as worked once, which adds up to the Work the plan counted. +// is reported as worked once - what its new tiles were worth - which adds up +// to the Work the plan counted. func writeClusters(ctx context.Context, out *sticky, l layout, pics *video.Pictures) ([]uint64, uint64, error) { s := l.stream keys := make([]uint64, 0, s.Keys()) @@ -252,7 +253,7 @@ func writeClusters(ctx context.Context, out *sticky, l layout, pics *video.Pictu return nil, 0, err } if change != worked { - format.Worked(ctx, s.PictureWork()) + format.Worked(ctx, pics.Work()) worked = change } if s.IsKey(first) { diff --git a/internal/guard/film_test.go b/internal/guard/film_test.go index b3e705df..f86e407a 100644 --- a/internal/guard/film_test.go +++ b/internal/guard/film_test.go @@ -504,9 +504,12 @@ func TestAFilmPictureChangesWhereTheManifestSays(t *testing.T) { {"duration": "5s", "change_interval": "2s", "keyframe_interval": "1s"}, {"duration": "2s", "change_interval": "1h"}, } - moving := 0 + moving, tiled := 0, 0 for _, props := range cases { b, facts := filmOne(t, filmTarget(2*1024*1024, props)) + if filmTileCount(t, b) > 1 { + tiled++ + } path := filepath.Join(t.TempDir(), "film.webm") if err := os.WriteFile(path, b, 0o600); err != nil { t.Fatal(err) @@ -539,6 +542,12 @@ func TestAFilmPictureChangesWhereTheManifestSays(t *testing.T) { if moving < 3 { t.Fatalf("only %d of the films had a picture that moves, so the guard did not ask its question", moving) } + // Asserted, not assumed: a picture cut into tiles is coded a tile at a + // time, and a tile kept from an earlier picture is where a picture that + // should move could stand still. + if tiled < 2 { + t.Fatalf("only %d of the films were cut into tiles, so the guard did not ask about tiles", tiled) + } } // libaom decodes every frame the manifest declares - not only "decodes". @@ -558,8 +567,12 @@ func TestEveryFrameOfAFilmSurvivesItsReferenceTool(t *testing.T) { {"duration": "10m", "keyframe_interval": "30s", "frame_rate": "30"}, {"duration": "40ms", "frame_rate": "25"}, } + tiled := 0 for _, props := range cases { b, facts := filmOne(t, filmTarget(4*1024*1024, props)) + if filmTileCount(t, b) > 1 { + tiled++ + } path := filepath.Join(t.TempDir(), "film.webm") if err := os.WriteFile(path, b, 0o600); err != nil { t.Fatal(err) @@ -578,6 +591,11 @@ func TestEveryFrameOfAFilmSurvivesItsReferenceTool(t *testing.T) { t.Errorf("%v: libaom decoded %d frames and the manifest declares %d", props, got, frames) } } + // A frame of tiles is the one whose tile_info and tile sizes this tool + // writes itself, so at least one film has to be one. + if tiled == 0 { + t.Fatalf("no film was cut into tiles, so libaom was never asked about a frame of tiles") + } } // A picture the encoder would code as more than one AV1 tile is refused as a @@ -624,9 +642,16 @@ func TestAFilmOfANamedSizeKeepsEveryPictureInsideItsReserve(t *testing.T) { {1 << 20, map[string]string{"width": "48", "height": "32", "duration": "10m"}}, {4 << 20, map[string]string{"width": "52", "height": "20", "duration": "1h"}}, {2 << 20, map[string]string{"width": "64", "height": "48", "duration": "2m", "change_interval": "100ms"}}, + // Cut into tiles: a picture is its tiles together, and the sample + // counts them that way. + {16 << 20, map[string]string{"width": "320", "height": "180", "duration": "2m", "change_interval": "100ms"}}, } + tiled := 0 for _, c := range cases { b, facts := filmOne(t, filmTarget(c.bytes, c.props)) + if filmTileCount(t, b) > 1 { + tiled++ + } if changes, _ := facts["change_count"].(int64); changes <= 10 { t.Fatalf("%v: %d pictures, which planning codes whole, so the sample was not asked", c.props, changes) } @@ -634,4 +659,7 @@ func TestAFilmOfANamedSizeKeepsEveryPictureInsideItsReserve(t *testing.T) { t.Errorf("%v: the film is %d B and %d B were asked for", c.props, len(b), c.bytes) } } + if tiled == 0 { + t.Fatalf("no film was cut into tiles, so no reserve of tiles was asked") + } } diff --git a/internal/guard/filmahead_test.go b/internal/guard/filmahead_test.go index 9ef553ac..7ecaf302 100644 --- a/internal/guard/filmahead_test.go +++ b/internal/guard/filmahead_test.go @@ -93,8 +93,12 @@ func TestAFilmCodedBySeveralGoroutinesHasTheBytesOfOne(t *testing.T) { filmTarget(2<<20, map[string]string{"width": "64", "height": "48", "duration": "40s"}), filmTarget(1<<20, map[string]string{"duration": "30s", "change_interval": "500ms"}), } + tiled := 0 for _, target := range targets { alone, helped := filmUnder(t, 1, target), filmUnder(t, 8, target) + if filmTileCount(t, alone.bytes) > 1 { + tiled++ + } if alone.coded != 0 { t.Errorf("%v: under one thread helpers coded %d pictures, so there is no film of one goroutine to compare with", target.Properties, alone.coded) } @@ -105,6 +109,12 @@ func TestAFilmCodedBySeveralGoroutinesHasTheBytesOfOne(t *testing.T) { t.Errorf("%v: the film coded with helpers differs from the film coded by one goroutine (%d B and %d B)", target.Properties, len(helped.bytes), len(alone.bytes)) } } + // Tiles are what helpers code, and a tile one helper coded is taken by + // pictures others are waiting for - the film of tiles is the one where + // the order of who coded what could show. + if tiled == 0 { + t.Fatalf("neither film was cut into tiles, so helpers coding tiles were never compared") + } } type filmRun struct { @@ -131,20 +141,29 @@ func filmUnder(t *testing.T, threads int, target engine.Target) filmRun { // all written the work has to be far ahead of the bytes. And a run of files // whose cost is their bytes has to report the same numbers as before - work // equal to bytes at every report. +// +// The second film is cut into tiles, which are coded the first time their key +// comes up, so most of its work is the first picture and the square's ten +// places - and the bar has to move with that too. func TestAFilmsProgressMovesWithItsPicturesNotItsPadding(t *testing.T) { - reports, planned := progressOf(t, filmTarget(32<<20, map[string]string{"width": "160", "height": "90", "duration": "5m"})) - if changes, _ := planned.Properties["change_count"].(int64); changes < 100 || planned.Work <= 0 { - t.Fatalf("the film has %d pictures and declares %d of work, so there were no pictures for the bar to move with", changes, planned.Work) - } - ahead := 0.0 - for _, r := range reports { - ahead = max(ahead, float64(r.WorkDone)/float64(r.WorkTotal)-float64(r.BytesDone)/float64(r.BytesTotal)) - } - if ahead < 0.4 { - t.Errorf("the work was never more than %.0f%% ahead of the bytes, so the bar counts the padding as the film", 100*ahead) - } - if last := reports[len(reports)-1]; last.WorkDone != last.WorkTotal || last.BytesDone != last.BytesTotal { - t.Errorf("the run ended at %d of %d work and %d of %d bytes", last.WorkDone, last.WorkTotal, last.BytesDone, last.BytesTotal) + for _, props := range []map[string]string{ + {"width": "160", "height": "90", "duration": "5m"}, + {"width": "640", "height": "360", "duration": "5m"}, + } { + reports, planned := progressOf(t, filmTarget(32<<20, props)) + if changes, _ := planned.Properties["change_count"].(int64); changes < 100 || planned.Work <= 0 { + t.Fatalf("%v: the film has %d pictures and declares %d of work, so there were no pictures for the bar to move with", props, changes, planned.Work) + } + ahead := 0.0 + for _, r := range reports { + ahead = max(ahead, float64(r.WorkDone)/float64(r.WorkTotal)-float64(r.BytesDone)/float64(r.BytesTotal)) + } + if ahead < 0.4 { + t.Errorf("%v: the work was never more than %.0f%% ahead of the bytes, so the bar counts the padding as the film", props, 100*ahead) + } + if last := reports[len(reports)-1]; last.WorkDone != last.WorkTotal || last.BytesDone != last.BytesTotal { + t.Errorf("%v: the run ended at %d of %d work and %d of %d bytes", props, last.WorkDone, last.WorkTotal, last.BytesDone, last.BytesTotal) + } } plain, _ := progressOf(t, engine.Target{ID: "plain", Format: "txt", Sizes: engine.Uniform(1, 1<<20)}) diff --git a/internal/guard/filmtiles_test.go b/internal/guard/filmtiles_test.go new file mode 100644 index 00000000..d8d215f8 --- /dev/null +++ b/internal/guard/filmtiles_test.go @@ -0,0 +1,394 @@ +package guard + +import ( + "bytes" + "context" + "fmt" + "os" + "path/filepath" + "testing" + + "github.com/gen2brain/gav1d/av1" + + "github.com/donislawdev/TestingFilesGenerator/internal/core" + "github.com/donislawdev/TestingFilesGenerator/internal/engine" + "github.com/donislawdev/TestingFilesGenerator/internal/format/video" +) + +// A film's picture is cut into AV1 tiles, each coded by gav1d as a picture of +// its own, and a tile nothing changes is coded once a film and taken from what +// was coded every time after (internal/format/video, grid.go and tilekey.go, +// and docs/WEBM-WYDAJNOSC-2026-10-06.md sections 8 and 9). Read back from the +// file: the reader below knows tile_info and the tile group from the AV1 +// specification (AOMediaCodec/av1-spec 5e04f3f, 06.bitstream.syntax.md lines +// 1176-1268 and 1771-1816), not from the writer. + +type filmBits struct { + b []byte + pos int +} + +func (r *filmBits) bit() int { + if r.pos/8 >= len(r.b) { + panic("the frame header ends inside itself") + } + v := int(r.b[r.pos/8]>>(7-r.pos%8)) & 1 + r.pos++ + return v +} + +func (r *filmBits) bits(n int) int { + v := 0 + for range n { + v = v<<1 | r.bit() + } + return v +} + +// ns is 04.conventions.md "ns(n)". +func (r *filmBits) ns(n int) int { + w := 0 + for x := n; x > 0; x >>= 1 { + w++ + } + m := (1 << w) - n + v := r.bits(w - 1) + if v < m { + return v + } + return (v << 1) - m + r.bit() +} + +func log2Up(blk, target int) int { + k := 0 + for blk< 0 { + if r.bit() != 0 { + return filmTiles{}, fmt.Errorf("delta_q_present in a film frame") + } + if l0, l1 := r.bits(6), r.bits(6); l0|l1 != 0 { + r.bits(12) + } + r.bits(3) // loop_filter_sharpness + if r.bit() != 0 { + return filmTiles{}, fmt.Errorf("loop_filter_delta_enabled in a film frame") + } + r.bit() // tx_mode_select + } + r.bit() // reduced_tx_set + pos := (r.pos + 7) / 8 + var out filmTiles + y := 0 + for _, rh := range rows { + x := 0 + for _, cw := range cols { + out.rects = append(out.rects, [4]int{x, y, min(width, x+64*cw), min(height, y+64*rh)}) + x += 64 * cw + } + y += 64 * rh + } + if len(out.rects) > 1 { + if frame[pos] != 0 { + return filmTiles{}, fmt.Errorf("tile_start_and_end_present_flag is set in an OBU_FRAME") + } + pos++ + } + for i := range out.rects { + size := len(frame) - pos + if i < len(out.rects)-1 { + size = 1 + for b := range sizeBytes { + size += int(frame[pos+b]) << (8 * b) + } + pos += sizeBytes + } + if pos+size > len(frame) { + return filmTiles{}, fmt.Errorf("tile %d of %d says %d B and %d remain", i, len(out.rects), size, len(frame)-pos) + } + out.data = append(out.data, frame[pos:pos+size]) + pos += size + } + return out, nil +} + +// readFilmTileInfo is tile_info: the columns and rows in superblocks, and the +// bytes of each tile size. +func readFilmTileInfo(r *filmBits, width, height int) (cols, rows []int, sizeBytes int) { + sbCols, sbRows := (2*((width+7)>>3)+15)>>4, (2*((height+7)>>3)+15)>>4 + minCols := log2Up(64, sbCols) + maxCols, maxRows := log2Up(1, min(sbCols, 64)), log2Up(1, min(sbRows, 64)) + minTiles := max(minCols, log2Up(2304, sbRows*sbCols)) + var colsLog2, rowsLog2 int + if r.bit() == 1 { + colsLog2 = minCols + for colsLog2 < maxCols && r.bit() == 1 { + colsLog2++ + } + rowsLog2 = max(minTiles-colsLog2, 0) + for rowsLog2 < maxRows && r.bit() == 1 { + rowsLog2++ + } + cols, rows = uniformSizes(sbCols, colsLog2), uniformSizes(sbRows, rowsLog2) + } else { + widest := 0 + for start := 0; start < sbCols; { + s := r.ns(min(sbCols-start, 64)) + 1 + cols, widest, start = append(cols, s), max(widest, s), start+s + } + area := sbRows * sbCols + if minTiles > 0 { + area >>= minTiles + 1 + } + for start := 0; start < sbRows; { + s := r.ns(min(sbRows-start, max(area/widest, 1))) + 1 + rows, start = append(rows, s), start+s + } + colsLog2, rowsLog2 = log2Up(1, len(cols)), log2Up(1, len(rows)) + } + sizeBytes = 4 + if colsLog2 > 0 || rowsLog2 > 0 { + r.bits(colsLog2 + rowsLog2) + sizeBytes = r.bits(2) + 1 + } + return cols, rows, sizeBytes +} + +func uniformSizes(sb, log2 int) []int { + step := (sb + (1 << log2) - 1) >> log2 + var out []int + for s := 0; s < sb; s += step { + out = append(out, min(step, sb-s)) + } + return out +} + +// frameOBU is the payload of the one frame OBU in a sample, and how many bits +// of its header come before tile_info: eight for a shown key frame, eighteen +// for a hidden intra only copy (the two this tool writes, film_test.go). +func frameOBU(sample []byte) ([]byte, int, error) { + for len(sample) > 0 { + size, used := 0, 0 + for i := 1; i < len(sample) && i < 9; i++ { + size |= int(sample[i]&0x7f) << (7 * (i - 1)) + if sample[i]&0x80 == 0 { + used = i + break + } + } + if used == 0 || 1+used+size > len(sample) { + return nil, 0, fmt.Errorf("an OBU runs past its sample") + } + if typ := int(sample[0]>>3) & 0xf; typ == 6 { + p := sample[1+used : 1+used+size] + if frameType := p[0] >> 5 & 3; frameType == 0 { + return p, 8, nil + } + return p, 18, nil + } + sample = sample[1+used+size:] + } + return nil, 0, fmt.Errorf("no frame OBU in the sample") +} + +// filmTileCount is how many tiles the first frame of a film is cut into, read +// from the file - for the guards that have to know they asked about a film of +// tiles rather than a film of one. +func filmTileCount(t *testing.T, film []byte) int { + t.Helper() + f, err := walkWebM(film) + if err != nil || len(f.blocks) == 0 { + t.Fatalf("reading the film back: %v", err) + } + frame, before, err := frameOBU(f.blocks[0].data) + if err != nil { + t.Fatal(err) + } + tiles, err := readFilmTiles(frame, before, f.width, f.height) + if err != nil { + t.Fatal(err) + } + return len(tiles.rects) +} + +// Every tile of a film is gav1d's own coding of the picture's pixels there, +// and decodes in the film exactly as it does alone. +// +// The first half holds the tile cache to the pixels: each tile in the file is +// the tail of what gav1d makes, here, of that tile cut from the picture +// painted whole (video.WholePicture) - so a key that missed something the +// tile's pixels depend on, a tile taken from the wrong place or coded from the +// wrong stride is a different tile. The pictures asked are the ones where a +// kept tile comes back: every step of the square, the clock's minute, and ten +// minutes in, where the seconds' tile shows 0:00 again. The second half holds +// the frame to the tiles: the film's frame, decoded by gav1d, is in each tile +// the picture gav1d decodes from that tile alone - tile_info, the sizes and +// the header bits the tiles share all have to be right for that. +// +// gav1d's decoder agreed with libaom and dav1d to the byte on frames of tiles +// (docs/WEBM-WYDAJNOSC-2026-10-06.md section 8.3), and it needs no tool +// installed, so this runs everywhere. +func TestEveryTileOfAFilmIsGav1dsCodingOfItsPixelsAndDecodesAsItDoesAlone(t *testing.T) { + cases := []struct { + props map[string]string + bytes int64 + changes []int64 + }{ + {map[string]string{"width": "320", "height": "180", "duration": "11m"}, 8 << 20, []int64{0, 1, 2, 3, 9, 10, 11, 59, 60, 61, 599, 600, 601, 659}}, + {map[string]string{"width": "640", "height": "360", "duration": "3s", "change_interval": "100ms"}, 4 << 20, rangeOf(30)}, + {map[string]string{"width": "1001", "height": "563", "duration": "12s"}, 4 << 20, rangeOf(12)}, + {map[string]string{"width": "1920", "height": "1080", "duration": "2s"}, 4 << 20, rangeOf(2)}, + } + tilesAsked, mostTiles := 0, 0 + for _, c := range cases { + n, most := tilesOfFilmHold(t, c.props, c.bytes, c.changes) + tilesAsked += n + mostTiles = max(mostTiles, most) + } + if tilesAsked < 300 || mostTiles < 8 { + t.Fatalf("%d tiles asked about, at most %d in one frame - the guard did not see films of tiles", tilesAsked, mostTiles) + } +} + +func rangeOf(n int64) []int64 { + out := make([]int64, n) + for i := range out { + out[i] = int64(i) + } + return out +} + +// tilesOfFilmHold makes one film and asks both halves about the pictures of +// the given changes, giving back how many tiles it asked about and the most in +// one frame. +func tilesOfFilmHold(t *testing.T, props map[string]string, size int64, changes []int64) (int, int) { + t.Helper() + b, facts, seed := filmWithSeed(t, filmTarget(size, props)) + f, err := walkWebM(b) + if err != nil { + t.Fatalf("%v: reading the film back: %v", props, err) + } + durationMs, _ := facts["duration_ms"].(int64) + changeMs, _ := facts["change_interval_ms"].(int64) + fps, _ := facts["frame_rate"].(int) + quality, _ := facts["quality"].(int) + tl, err := video.NewTimeline("webm", durationMs, 60_000, changeMs, fps) + if err != nil { + t.Fatal(err) + } + label := core.Label("webm", size, seed) + var dec av1.Decoder + if _, err := dec.DecodeOBUs(append([]byte{0x12, 0x00}, f.blocks[0].data...)); err != nil { + t.Fatalf("%v: gav1d does not decode the film's first frame: %v", props, err) + } + asked, most := 0, 0 + for _, c := range changes { + block := f.blocks[c*changeMs*int64(fps)/1000].data + frame, before, err := frameOBU(block) + if err != nil { + t.Fatal(err) + } + tiles, err := readFilmTiles(frame, before, f.width, f.height) + if err != nil { + t.Fatalf("%v, picture %d: %v", props, c, err) + } + pics, err := dec.DecodeOBUs(append([]byte{0x12, 0x00}, block...)) + if err != nil || len(pics) != 1 { + t.Fatalf("%v, picture %d: gav1d decoded %d pictures from its frame: %v", props, c, len(pics), err) + } + whole := video.WholePicture(f.width, f.height, seed, label, tl, c) + for i, r := range tiles.rects { + if why := tileHolds(whole, r, tiles.data[i], pics[0], (100-quality)*255/100); why != "" { + t.Errorf("%v, picture %d, tile %d at %v: %s", props, c, i, r, why) + } + } + asked, most = asked+len(tiles.rects), max(most, len(tiles.rects)) + } + return asked, most +} + +// tileHolds codes the tile r of whole through gav1d as a picture of its own +// and says what is wrong with the film's tile and with the film's decoded +// picture there, or nothing. +func tileHolds(whole video.Planes, r [4]int, inFilm []byte, decoded *av1.Picture, qindex int) string { + w, h := r[2]-r[0], r[3]-r[1] + cw, ch, pcw := (w+1)/2, (h+1)/2, (whole.Width+1)/2 + y, u, v := make([]uint8, w*h), make([]uint8, cw*ch), make([]uint8, cw*ch) + for row := range h { + copy(y[row*w:], whole.Y[(r[1]+row)*whole.Width+r[0]:][:w]) + } + for row := range ch { + copy(u[row*cw:], whole.U[(r[1]/2+row)*pcw+r[0]/2:][:cw]) + copy(v[row*cw:], whole.V[(r[1]/2+row)*pcw+r[0]/2:][:cw]) + } + alone := av1.Encode(av1.EncodeConfig{Width: w, Height: h, BitDepth: 8, QIndex: qindex, Speed: 10, + Src: y, SrcStride: w, SrcU: u, SrcV: v, SrcUVStride: cw}) + if alone == nil { + return "gav1d refused the tile" + } + // Temporal delimiter (2 bytes), sequence header, then the frame: the + // tile's data is the end of it, after a header of a few bytes. + if !bytes.HasSuffix(alone, inFilm) || len(alone)-len(inFilm) > 64 { + return fmt.Sprintf("the film's tile is %d B and is not the tail of gav1d's %d B coding of these pixels", len(inFilm), len(alone)) + } + var d av1.Decoder + pics, err := d.DecodeOBUs(alone) + if err != nil || len(pics) != 1 { + return fmt.Sprintf("gav1d does not decode its own tile: %v", err) + } + for plane, sub := range []int{0, 1, 1} { + for row := range (h + sub) >> sub { + got := decoded.Data[plane][(r[1]>>sub+row)*decoded.Stride[plane]+r[0]>>sub:][:(w+sub)>>sub] + want := pics[0].Data[plane][row*pics[0].Stride[plane]:][:(w+sub)>>sub] + if !bytes.Equal(got, want) { + return fmt.Sprintf("plane %d row %d of the film's decoded picture differs from the tile decoded alone", plane, row) + } + } + } + return "" +} + +// filmWithSeed is filmOne with the seed the file was made with, which its +// pictures are painted from. +func filmWithSeed(t *testing.T, target engine.Target) ([]byte, map[string]any, uint64) { + t.Helper() + dir := t.TempDir() + opt := engine.Options{OutDir: dir, Seed: goldenSeed, Command: "test"} + planned, err := engine.Plan([]engine.Target{target}, opt) + if err != nil { + t.Fatalf("planning %s: %v", target.Format, err) + } + if _, err := engine.Run(context.Background(), planned, opt); err != nil { + t.Fatalf("running %s: %v", target.Format, err) + } + b, err := os.ReadFile(filepath.Join(dir, planned[0].Name)) + if err != nil { + t.Fatal(err) + } + return b, planned[0].Plan.Properties, planned[0].Seed +} diff --git a/internal/gui/text/locale/registry/en.json b/internal/gui/text/locale/registry/en.json index a85e9a65..a5245ecf 100644 --- a/internal/gui/text/locale/registry/en.json +++ b/internal/gui/text/locale/registry/en.json @@ -1226,8 +1226,8 @@ }, "Detail.format/webm.change_interval": { "description": "The sentence under the change_interval setting of the webm format, after what it takes.", - "hash": "sha256-d94e8abf3718", - "other": "How often the picture changes: the clock in it moves on and the square takes a step. Each change costs a whole picture in bytes and in time. An interval as long as the film or longer keeps one picture throughout." + "hash": "sha256-13634dfb475a", + "other": "How often the picture changes: the clock in it moves on and the square takes a step. Each change costs a whole picture in bytes. An interval as long as the film or longer keeps one picture throughout." }, "Detail.format/webm.duration": { "description": "The sentence under the duration setting of the webm format, after what it takes.", diff --git a/internal/gui/text/locale/registry/pl.json b/internal/gui/text/locale/registry/pl.json index 86da57af..c8c0698e 100644 --- a/internal/gui/text/locale/registry/pl.json +++ b/internal/gui/text/locale/registry/pl.json @@ -244,7 +244,7 @@ "Detail.format/wav.channels": { "hash": "sha256-f94ddfa60e04", "other": "Ile kanałów ma sygnał, czyli 1 dla mono i 2 dla stereo." }, "Detail.format/wav.content": { "hash": "sha256-c2baa480c847", "other": "Jak brzmi sygnał." }, "Detail.format/wav.sample_rate": { "hash": "sha256-71e4eb77e129", "other": "Ile próbek na sekundę niesie sygnał." }, - "Detail.format/webm.change_interval": { "hash": "sha256-d94e8abf3718", "other": "Jak często zmienia się obraz: zegar w nim idzie dalej, a kwadrat robi krok. Każda zmiana kosztuje cały obraz w bajtach i w czasie, a odstęp równy długości filmu albo dłuższy zostawia jeden obraz przez cały film." }, + "Detail.format/webm.change_interval": { "hash": "sha256-13634dfb475a", "other": "Jak często zmienia się obraz: zegar w nim idzie dalej, a kwadrat robi krok. Każda zmiana kosztuje cały obraz w bajtach, a odstęp równy długości filmu albo dłuższy zostawia jeden obraz przez cały film." }, "Detail.format/webm.duration": { "hash": "sha256-adacce8ce234", "other": "Jak długo trwa film. Musi kończyć się na klatce, więc przy 30 klatkach na sekundę idzie co 100ms." }, "Detail.format/webm.frame_rate": { "hash": "sha256-f53baa5f4ccf", "other": "Ile klatek film pokazuje w każdej sekundzie." }, "Detail.format/webm.height": { "hash": "sha256-4b336c279584", "other": "Jak wysoki jest obraz. Gdy pole zostaje puste, wybierany jest rozmiar, który mieści się w zamówionych bajtach." }, From d088e3e7c3fc38abfe58df00e2b08a0ffbc7fe7b Mon Sep 17 00:00:00 2001 From: DonislawDev Date: Wed, 7 Oct 2026 07:59:40 +0200 Subject: [PATCH 2/4] webm: AllocCeiling 1024 - a picture in tiles is a gav1d call a tile, 22 objects each The default film, 640x360 in six tiles, makes 27 calls to gav1d, about 594 objects before anything of this package. Measured at 886 to 891 objects at one, four and sixteen threads, and 1177 with one object allocated per frame, the defect the ceiling is for. The owner's decision. Co-Authored-By: Claude Opus 5.5 --- internal/format/webm/webm.go | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/internal/format/webm/webm.go b/internal/format/webm/webm.go index 9d9b3f58..e1f7a38d 100644 --- a/internal/format/webm/webm.go +++ b/internal/format/webm/webm.go @@ -57,7 +57,19 @@ func init() { // one threads alike - each picture is a job, each helper one painter of // four allocations - measured the same day, still under the ceiling, // which was not moved. - AllocCeiling: 512, + // + // Raised to 1024 on 2026-10-07 by the owner's decision, the one time it + // went up, and why: a picture is cut into AV1 tiles since then, each + // tile one call to gav1d, and gav1d allocates 21 to 22 objects a call + // whatever the size (tools/probes/videotiles/allocs). The default film, + // 640x360 in six tiles, makes 27 calls - about 594 objects of gav1d's + // alone, over the old ceiling before this package allocates anything. + // With this package's own allocations cut down it is 886 to 891 at one, + // four and sixteen threads, and one object a frame, the defect the + // ceiling exists for, takes it to 1177. 1024 sits between the two, as + // 512 sat between 474 and 698 (docs/WEBM-WYDAJNOSC-2026-10-06.md + // section 10). + AllocCeiling: 1024, Padding: format.PaddingChannel{ Name: "a Void element before the index at the end of the file", From 81fefc67870c01f9697da89e0ad8ee5aa0297363 Mon Sep 17 00:00:00 2001 From: DonislawDev Date: Wed, 7 Oct 2026 08:17:27 +0200 Subject: [PATCH 3/4] webm: the smallest film is 3284 B - the site, the preset screens and the ejected preset follow Planning counts a frame's tile_info as this package now writes it, one bit for one tile at 1x1, rather than the three gav1d could write, so the bound on the smallest film at the default settings came ten bytes closer to the film: 3294 B to 3284 B. The film itself is the same - 3284 B is written and decodes in libaom frame for frame, 3283 B is refused with the new minimum. The ejected empty-and-minimal recipe asks for that size, so its bytes move again before any release carried the film, under the decision the row records. The site's format and preset pages and the preset screens' totals are regenerated from the program. Co-Authored-By: Claude Opus 5.5 --- internal/guard/presetbytes_test.go | 8 +++++++- .../guard/testdata/screens/preset-menu.png | Bin 123626 -> 123437 bytes .../guard/testdata/screens/preset-menu.xml | 2 +- internal/guard/testdata/screens/preset.png | Bin 126985 -> 126796 bytes internal/guard/testdata/screens/preset.xml | 2 +- .../testdata/screens/recipe-on-a-preset.png | Bin 136275 -> 136146 bytes .../testdata/screens/recipe-on-a-preset.xml | 2 +- web/public/ar/formats/index.html | 2 +- .../ar/presets/empty-and-minimal/index.html | 2 +- web/public/cs/formaty/index.html | 2 +- .../cs/predvolby/empty-and-minimal/index.html | 2 +- web/public/de/formate/index.html | 2 +- .../de/presets/empty-and-minimal/index.html | 2 +- web/public/es/formatos/index.html | 2 +- .../es/presets/empty-and-minimal/index.html | 2 +- web/public/formats/index.html | 2 +- web/public/fr/formats/index.html | 2 +- .../prereglages/empty-and-minimal/index.html | 2 +- web/public/hi/formats/index.html | 2 +- .../hi/presets/empty-and-minimal/index.html | 2 +- web/public/id/format/index.html | 2 +- .../id/preset/empty-and-minimal/index.html | 2 +- web/public/it/formati/index.html | 2 +- .../it/preset/empty-and-minimal/index.html | 2 +- web/public/ja/formats/index.html | 2 +- .../ja/presets/empty-and-minimal/index.html | 2 +- web/public/ko/formats/index.html | 2 +- .../ko/presets/empty-and-minimal/index.html | 2 +- web/public/nl/formaten/index.html | 2 +- .../nl/presets/empty-and-minimal/index.html | 2 +- web/public/pl/formaty/index.html | 2 +- .../pl/presety/empty-and-minimal/index.html | 2 +- .../presets/empty-and-minimal/index.html | 2 +- web/public/pt-br/formatos/index.html | 2 +- .../presets/empty-and-minimal/index.html | 2 +- web/public/ro/formate/index.html | 2 +- .../ro/presetari/empty-and-minimal/index.html | 2 +- web/public/ru/formats/index.html | 2 +- .../ru/presets/empty-and-minimal/index.html | 2 +- web/public/th/formats/index.html | 2 +- .../th/presets/empty-and-minimal/index.html | 2 +- web/public/tr/bicimler/index.html | 2 +- .../empty-and-minimal/index.html | 2 +- web/public/uk/formats/index.html | 2 +- .../uk/presets/empty-and-minimal/index.html | 2 +- web/public/vi/dinh-dang/index.html | 2 +- .../vi/preset/empty-and-minimal/index.html | 2 +- web/public/zh-hans/formats/index.html | 2 +- .../presets/empty-and-minimal/index.html | 2 +- web/public/zh-hant/formats/index.html | 2 +- .../presets/empty-and-minimal/index.html | 2 +- 51 files changed, 54 insertions(+), 48 deletions(-) diff --git a/internal/guard/presetbytes_test.go b/internal/guard/presetbytes_test.go index e4b77ae8..b006a741 100644 --- a/internal/guard/presetbytes_test.go +++ b/internal/guard/presetbytes_test.go @@ -81,7 +81,13 @@ func TestEjectingAPresetGivesTheBytesItAlwaysGave(t *testing.T) { // picture began to change, and the smallest film the recipe asks for // went from 3044 B to 3294 B (docs/WIDEO-2026-10-06.md section 15). // The same Changed entry covers it. - {id: "empty-and-minimal", args: preset.Args{}, bytes: 9272, sum: "1b08c93c31773b19cf13ac445671345aa8bf160eff8fe16a889d13b7e6ec6d6b"}, + // Moved a third time on 2026-10-07, still before any release carried + // the film, under the same decision: films are cut into tiles, and + // planning counts a frame's tile_info as this package writes it rather + // than at the longest gav1d could, so the smallest film went from + // 3294 B to 3284 B - the same film, a bound ten bytes closer to it + // (docs/WEBM-WYDAJNOSC-2026-10-06.md section 10). + {id: "empty-and-minimal", args: preset.Args{}, bytes: 9272, sum: "44600851ef3be72e73df2082c4a653416407efc770ca56bec62de87cf0f41905"}, {id: "empty-and-minimal", args: preset.Args{"formats": "jpg,png,txt"}, bytes: 1472, sum: "19b12800372395ad3bf5cba6407ca654d7ed51eba3a5bc5436f76a94bc09253c"}, // The preset of unusual file names, measured 2026-09-25 on its first // build: the default, and a format whose extension is a byte longer, diff --git a/internal/guard/testdata/screens/preset-menu.png b/internal/guard/testdata/screens/preset-menu.png index d08e730ddcba9cf454ce90d6055811a425a74120..a8ad7dd0b6077a5ac171816389cb7757bf23c591 100644 GIT binary patch delta 8595 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 T>ka^ufFvuaC{Zb99Q3~c6t*fu delta 8752 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 diff --git a/internal/guard/testdata/screens/preset-menu.xml b/internal/guard/testdata/screens/preset-menu.xml index 5a80dea2..d3841cf2 100644 --- a/internal/guard/testdata/screens/preset-menu.xml +++ b/internal/guard/testdata/screens/preset-menu.xml @@ -370,7 +370,7 @@ - 29 files · 35.1 KB (35 961 B) · 27 formats · will go to /tfg/out + 29 files · 35.1 KB (35 951 B) · 27 formats · will go to /tfg/out diff --git a/internal/guard/testdata/screens/preset.png b/internal/guard/testdata/screens/preset.png index 7368e8f2a597e0b60f330c973e0f3d91b1602b16..d00c967ceddc1e2dee36f4a21719fbcd467344ad 100644 GIT binary patch delta 8431 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 delta 8909 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 QXMIpkN=dR>+$ivW0fT}-rvLx| diff --git a/internal/guard/testdata/screens/preset.xml b/internal/guard/testdata/screens/preset.xml index 1c745368..94540179 100644 --- a/internal/guard/testdata/screens/preset.xml +++ b/internal/guard/testdata/screens/preset.xml @@ -370,7 +370,7 @@ - 29 files · 35.1 KB (35 961 B) · 27 formats · will go to /tfg/out + 29 files · 35.1 KB (35 951 B) · 27 formats · will go to /tfg/out diff --git a/internal/guard/testdata/screens/recipe-on-a-preset.png b/internal/guard/testdata/screens/recipe-on-a-preset.png index de1de7d5b94b8b865f11dbdeb674b5d12b2483e5..8e02749da6bb658f1a47eb116220dac2f9d570eb 100644 GIT binary patch delta 8664 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 delta 8878 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 - 30 files · 10.0 MB (10 521 721 B) · 27 formats · will go to /tfg/out + 30 files · 10.0 MB (10 521 711 B) · 27 formats · will go to /tfg/out diff --git a/web/public/ar/formats/index.html b/web/public/ar/formats/index.html index 59c5c172..1ea97409 100644 --- a/web/public/ar/formats/index.html +++ b/web/public/ar/formats/index.html @@ -335,7 +335,7 @@

27 صيغة ملف، كلٌّ منها يُولَّد بحجم دقيق

webm WebM video .webm - 3294 + 3284 full libaom diff --git a/web/public/ar/presets/empty-and-minimal/index.html b/web/public/ar/presets/empty-and-minimal/index.html index c20c20b8..e2c71799 100644 --- a/web/public/ar/presets/empty-and-minimal/index.html +++ b/web/public/ar/presets/empty-and-minimal/index.html @@ -180,7 +180,7 @@

ماذا في المجموعة؟

الملفات29 الأهداف في وصفته29 - الحجم الإجمالي35 961 B + الحجم الإجمالي35 951 B الصيغavif, bmp, csv, docx, gif, html, ico, jpg, json, jxl, log, md, pdf, png, pptx, svg, targz, tiff, toml, txt, wav, webm, webp, xlsx, xml, yaml, zip diff --git a/web/public/cs/formaty/index.html b/web/public/cs/formaty/index.html index 4b18bc9d..9aed34c2 100644 --- a/web/public/cs/formaty/index.html +++ b/web/public/cs/formaty/index.html @@ -335,7 +335,7 @@

27 formátů souborů, každý generovaný v přesné velikosti

webm WebM video .webm - 3294 + 3284 full libaom diff --git a/web/public/cs/predvolby/empty-and-minimal/index.html b/web/public/cs/predvolby/empty-and-minimal/index.html index f481ec0e..bbe20269 100644 --- a/web/public/cs/predvolby/empty-and-minimal/index.html +++ b/web/public/cs/predvolby/empty-and-minimal/index.html @@ -180,7 +180,7 @@

Co je v sadě?

Soubory29 Targety v receptu29 - Celková velikost35 961 B + Celková velikost35 951 B Formátyavif, bmp, csv, docx, gif, html, ico, jpg, json, jxl, log, md, pdf, png, pptx, svg, targz, tiff, toml, txt, wav, webm, webp, xlsx, xml, yaml, zip diff --git a/web/public/de/formate/index.html b/web/public/de/formate/index.html index 094b32c5..d2aef027 100644 --- a/web/public/de/formate/index.html +++ b/web/public/de/formate/index.html @@ -336,7 +336,7 @@

27 Dateiformate, jedes in exakter Größe erzeugt

webm WebM video .webm - 3294 + 3284 full libaom diff --git a/web/public/de/presets/empty-and-minimal/index.html b/web/public/de/presets/empty-and-minimal/index.html index 4a2f4b01..98e1c32d 100644 --- a/web/public/de/presets/empty-and-minimal/index.html +++ b/web/public/de/presets/empty-and-minimal/index.html @@ -180,7 +180,7 @@

Was steckt im Set?

Dateien29 Targets in seinem Rezept29 - Gesamtgröße35 961 B + Gesamtgröße35 951 B Formateavif, bmp, csv, docx, gif, html, ico, jpg, json, jxl, log, md, pdf, png, pptx, svg, targz, tiff, toml, txt, wav, webm, webp, xlsx, xml, yaml, zip diff --git a/web/public/es/formatos/index.html b/web/public/es/formatos/index.html index 327a776b..a310a9f0 100644 --- a/web/public/es/formatos/index.html +++ b/web/public/es/formatos/index.html @@ -336,7 +336,7 @@

27 formatos de archivo, cada uno generado a un tamaño exacto

webm WebM video .webm - 3294 + 3284 full libaom diff --git a/web/public/es/presets/empty-and-minimal/index.html b/web/public/es/presets/empty-and-minimal/index.html index 566ce537..8b3e0601 100644 --- a/web/public/es/presets/empty-and-minimal/index.html +++ b/web/public/es/presets/empty-and-minimal/index.html @@ -181,7 +181,7 @@

¿Qué hay en el conjunto?

Archivos29 Targets en su receta29 - Tamaño total35 961 B + Tamaño total35 951 B Formatosavif, bmp, csv, docx, gif, html, ico, jpg, json, jxl, log, md, pdf, png, pptx, svg, targz, tiff, toml, txt, wav, webm, webp, xlsx, xml, yaml, zip diff --git a/web/public/formats/index.html b/web/public/formats/index.html index 47b473af..453afecb 100644 --- a/web/public/formats/index.html +++ b/web/public/formats/index.html @@ -336,7 +336,7 @@

27 file formats, every one generated at an exact size

webm WebM video .webm - 3294 + 3284 full libaom diff --git a/web/public/fr/formats/index.html b/web/public/fr/formats/index.html index c8272a68..1bbfd88e 100644 --- a/web/public/fr/formats/index.html +++ b/web/public/fr/formats/index.html @@ -336,7 +336,7 @@

27 formats de fichiers, chacun généré à une taille exacte

webm WebM video .webm - 3294 + 3284 full libaom diff --git a/web/public/fr/prereglages/empty-and-minimal/index.html b/web/public/fr/prereglages/empty-and-minimal/index.html index 751f12ed..2fec158d 100644 --- a/web/public/fr/prereglages/empty-and-minimal/index.html +++ b/web/public/fr/prereglages/empty-and-minimal/index.html @@ -181,7 +181,7 @@

Que contient le jeu ?

Fichiers29 Cibles dans sa recette29 - Taille totale35 961 B + Taille totale35 951 B Formatsavif, bmp, csv, docx, gif, html, ico, jpg, json, jxl, log, md, pdf, png, pptx, svg, targz, tiff, toml, txt, wav, webm, webp, xlsx, xml, yaml, zip diff --git a/web/public/hi/formats/index.html b/web/public/hi/formats/index.html index f80733e6..4891d3d9 100644 --- a/web/public/hi/formats/index.html +++ b/web/public/hi/formats/index.html @@ -336,7 +336,7 @@

27 फ़ाइल फ़ॉर्मैट, हर एक सटीक आ webm WebM video .webm - 3294 + 3284 full libaom diff --git a/web/public/hi/presets/empty-and-minimal/index.html b/web/public/hi/presets/empty-and-minimal/index.html index 19c94b3d..f9f8d505 100644 --- a/web/public/hi/presets/empty-and-minimal/index.html +++ b/web/public/hi/presets/empty-and-minimal/index.html @@ -180,7 +180,7 @@

सेट में क्या है?

फ़ाइलें29 इसकी रेसिपी में टार्गेट29 - कुल आकार35 961 B + कुल आकार35 951 B फ़ॉर्मैटavif, bmp, csv, docx, gif, html, ico, jpg, json, jxl, log, md, pdf, png, pptx, svg, targz, tiff, toml, txt, wav, webm, webp, xlsx, xml, yaml, zip diff --git a/web/public/id/format/index.html b/web/public/id/format/index.html index 3824b975..f4c72dc4 100644 --- a/web/public/id/format/index.html +++ b/web/public/id/format/index.html @@ -336,7 +336,7 @@

27 format file, masing-masing dibuat dengan ukuran tepat

webm WebM video .webm - 3294 + 3284 full libaom diff --git a/web/public/id/preset/empty-and-minimal/index.html b/web/public/id/preset/empty-and-minimal/index.html index 678f699f..6e31111a 100644 --- a/web/public/id/preset/empty-and-minimal/index.html +++ b/web/public/id/preset/empty-and-minimal/index.html @@ -181,7 +181,7 @@

Apa isi set?

File29 Target dalam resepnya29 - Ukuran total35 961 B + Ukuran total35 951 B Formatavif, bmp, csv, docx, gif, html, ico, jpg, json, jxl, log, md, pdf, png, pptx, svg, targz, tiff, toml, txt, wav, webm, webp, xlsx, xml, yaml, zip diff --git a/web/public/it/formati/index.html b/web/public/it/formati/index.html index 86e63199..7a33e0ac 100644 --- a/web/public/it/formati/index.html +++ b/web/public/it/formati/index.html @@ -336,7 +336,7 @@

27 formati di file, ognuno generato a una dimensione esatta

webm WebM video .webm - 3294 + 3284 full libaom diff --git a/web/public/it/preset/empty-and-minimal/index.html b/web/public/it/preset/empty-and-minimal/index.html index d382fd82..81e46c2b 100644 --- a/web/public/it/preset/empty-and-minimal/index.html +++ b/web/public/it/preset/empty-and-minimal/index.html @@ -181,7 +181,7 @@

Cosa c'è nel set?

File29 Target nella sua ricetta29 - Dimensione totale35 961 B + Dimensione totale35 951 B Formatiavif, bmp, csv, docx, gif, html, ico, jpg, json, jxl, log, md, pdf, png, pptx, svg, targz, tiff, toml, txt, wav, webm, webp, xlsx, xml, yaml, zip diff --git a/web/public/ja/formats/index.html b/web/public/ja/formats/index.html index 65c3cb77..778a2e56 100644 --- a/web/public/ja/formats/index.html +++ b/web/public/ja/formats/index.html @@ -334,7 +334,7 @@

27種類のファイル形式、どれも正確なサイズで生成

webm WebM video .webm - 3294 + 3284 full libaom diff --git a/web/public/ja/presets/empty-and-minimal/index.html b/web/public/ja/presets/empty-and-minimal/index.html index 85210205..ebefc849 100644 --- a/web/public/ja/presets/empty-and-minimal/index.html +++ b/web/public/ja/presets/empty-and-minimal/index.html @@ -178,7 +178,7 @@

セットには何が入っていますか。

ファイル数29 レシピ内のターゲット数29 - 合計サイズ35 961 B + 合計サイズ35 951 B 形式avif, bmp, csv, docx, gif, html, ico, jpg, json, jxl, log, md, pdf, png, pptx, svg, targz, tiff, toml, txt, wav, webm, webp, xlsx, xml, yaml, zip diff --git a/web/public/ko/formats/index.html b/web/public/ko/formats/index.html index a68a3d58..c0fa96e2 100644 --- a/web/public/ko/formats/index.html +++ b/web/public/ko/formats/index.html @@ -334,7 +334,7 @@

파일 형식 27가지, 모두 정확한 크기로 생성됩니다

webm WebM video .webm - 3294 + 3284 full libaom diff --git a/web/public/ko/presets/empty-and-minimal/index.html b/web/public/ko/presets/empty-and-minimal/index.html index 51af8c71..574c6da7 100644 --- a/web/public/ko/presets/empty-and-minimal/index.html +++ b/web/public/ko/presets/empty-and-minimal/index.html @@ -179,7 +179,7 @@

세트에는 무엇이 들어 있나요?

파일 수29 레시피의 타깃 수29 - 총 크기35 961 B + 총 크기35 951 B 형식avif, bmp, csv, docx, gif, html, ico, jpg, json, jxl, log, md, pdf, png, pptx, svg, targz, tiff, toml, txt, wav, webm, webp, xlsx, xml, yaml, zip diff --git a/web/public/nl/formaten/index.html b/web/public/nl/formaten/index.html index 304edce2..a2a65232 100644 --- a/web/public/nl/formaten/index.html +++ b/web/public/nl/formaten/index.html @@ -336,7 +336,7 @@

27 bestandsformaten, elk gegenereerd in een exacte grootte

webm WebM video .webm - 3294 + 3284 full libaom diff --git a/web/public/nl/presets/empty-and-minimal/index.html b/web/public/nl/presets/empty-and-minimal/index.html index 4420362d..0d0d0f1d 100644 --- a/web/public/nl/presets/empty-and-minimal/index.html +++ b/web/public/nl/presets/empty-and-minimal/index.html @@ -181,7 +181,7 @@

Wat zit er in de set?

Bestanden29 Targets in het recept29 - Totale grootte35 961 B + Totale grootte35 951 B Formatenavif, bmp, csv, docx, gif, html, ico, jpg, json, jxl, log, md, pdf, png, pptx, svg, targz, tiff, toml, txt, wav, webm, webp, xlsx, xml, yaml, zip diff --git a/web/public/pl/formaty/index.html b/web/public/pl/formaty/index.html index f2eec7b0..cdcafe60 100644 --- a/web/public/pl/formaty/index.html +++ b/web/public/pl/formaty/index.html @@ -335,7 +335,7 @@

27 formatów plików, każdy generowany o dokładnym rozmiarze

webm WebM video .webm - 3294 + 3284 full libaom diff --git a/web/public/pl/presety/empty-and-minimal/index.html b/web/public/pl/presety/empty-and-minimal/index.html index 7297afe6..ca0c1deb 100644 --- a/web/public/pl/presety/empty-and-minimal/index.html +++ b/web/public/pl/presety/empty-and-minimal/index.html @@ -180,7 +180,7 @@

Co jest w zestawie?

Pliki29 Cele w przepisie29 - Łączny rozmiar35 961 B + Łączny rozmiar35 951 B Formatyavif, bmp, csv, docx, gif, html, ico, jpg, json, jxl, log, md, pdf, png, pptx, svg, targz, tiff, toml, txt, wav, webm, webp, xlsx, xml, yaml, zip diff --git a/web/public/presets/empty-and-minimal/index.html b/web/public/presets/empty-and-minimal/index.html index 9898ddb2..24d2fa40 100644 --- a/web/public/presets/empty-and-minimal/index.html +++ b/web/public/presets/empty-and-minimal/index.html @@ -180,7 +180,7 @@

What is in the set?

Files29 Targets in its recipe29 - Total size35 961 B + Total size35 951 B Formatsavif, bmp, csv, docx, gif, html, ico, jpg, json, jxl, log, md, pdf, png, pptx, svg, targz, tiff, toml, txt, wav, webm, webp, xlsx, xml, yaml, zip diff --git a/web/public/pt-br/formatos/index.html b/web/public/pt-br/formatos/index.html index 3f72d3f3..a86a592b 100644 --- a/web/public/pt-br/formatos/index.html +++ b/web/public/pt-br/formatos/index.html @@ -336,7 +336,7 @@

27 formatos de arquivo, cada um gerado em um tamanho exato

webm WebM video .webm - 3294 + 3284 full libaom diff --git a/web/public/pt-br/presets/empty-and-minimal/index.html b/web/public/pt-br/presets/empty-and-minimal/index.html index 23c01837..ef549359 100644 --- a/web/public/pt-br/presets/empty-and-minimal/index.html +++ b/web/public/pt-br/presets/empty-and-minimal/index.html @@ -180,7 +180,7 @@

O que há no conjunto?

Arquivos29 Targets na sua receita29 - Tamanho total35 961 B + Tamanho total35 951 B Formatosavif, bmp, csv, docx, gif, html, ico, jpg, json, jxl, log, md, pdf, png, pptx, svg, targz, tiff, toml, txt, wav, webm, webp, xlsx, xml, yaml, zip diff --git a/web/public/ro/formate/index.html b/web/public/ro/formate/index.html index 8f022da0..f3c1a6d9 100644 --- a/web/public/ro/formate/index.html +++ b/web/public/ro/formate/index.html @@ -336,7 +336,7 @@

27 formate de fișiere, fiecare generat la o dimensiune exactă

webm WebM video .webm - 3294 + 3284 full libaom diff --git a/web/public/ro/presetari/empty-and-minimal/index.html b/web/public/ro/presetari/empty-and-minimal/index.html index 86615cf9..b87beb65 100644 --- a/web/public/ro/presetari/empty-and-minimal/index.html +++ b/web/public/ro/presetari/empty-and-minimal/index.html @@ -181,7 +181,7 @@

Ce este în set?

Fișiere29 Targeturi în rețeta lui29 - Dimensiune totală35 961 B + Dimensiune totală35 951 B Formateavif, bmp, csv, docx, gif, html, ico, jpg, json, jxl, log, md, pdf, png, pptx, svg, targz, tiff, toml, txt, wav, webm, webp, xlsx, xml, yaml, zip diff --git a/web/public/ru/formats/index.html b/web/public/ru/formats/index.html index a098fc7b..1731cac2 100644 --- a/web/public/ru/formats/index.html +++ b/web/public/ru/formats/index.html @@ -336,7 +336,7 @@

27 форматов файлов, каждый создаётся точно webm WebM video .webm - 3294 + 3284 full libaom diff --git a/web/public/ru/presets/empty-and-minimal/index.html b/web/public/ru/presets/empty-and-minimal/index.html index 3d38b658..13eb66c3 100644 --- a/web/public/ru/presets/empty-and-minimal/index.html +++ b/web/public/ru/presets/empty-and-minimal/index.html @@ -180,7 +180,7 @@

Что входит в набор?

Файлов29 Целей в его рецепте29 - Общий размер35 961 B + Общий размер35 951 B Форматыavif, bmp, csv, docx, gif, html, ico, jpg, json, jxl, log, md, pdf, png, pptx, svg, targz, tiff, toml, txt, wav, webm, webp, xlsx, xml, yaml, zip diff --git a/web/public/th/formats/index.html b/web/public/th/formats/index.html index 85bafd0a..1ec22c8b 100644 --- a/web/public/th/formats/index.html +++ b/web/public/th/formats/index.html @@ -335,7 +335,7 @@

27 รูปแบบไฟล์ แต่ละรูปแบบสร้ webm WebM video .webm - 3294 + 3284 full libaom diff --git a/web/public/th/presets/empty-and-minimal/index.html b/web/public/th/presets/empty-and-minimal/index.html index 4bd03b9e..5434a1da 100644 --- a/web/public/th/presets/empty-and-minimal/index.html +++ b/web/public/th/presets/empty-and-minimal/index.html @@ -180,7 +180,7 @@

ในชุดมีอะไร

ไฟล์29 target ในสูตรของมัน29 - ขนาดรวม35 961 B + ขนาดรวม35 951 B รูปแบบavif, bmp, csv, docx, gif, html, ico, jpg, json, jxl, log, md, pdf, png, pptx, svg, targz, tiff, toml, txt, wav, webm, webp, xlsx, xml, yaml, zip diff --git a/web/public/tr/bicimler/index.html b/web/public/tr/bicimler/index.html index 13d29766..09086293 100644 --- a/web/public/tr/bicimler/index.html +++ b/web/public/tr/bicimler/index.html @@ -335,7 +335,7 @@

27 dosya biçimi, her biri tam boyutta üretilir

webm WebM video .webm - 3294 + 3284 full libaom diff --git a/web/public/tr/hazir-ayarlar/empty-and-minimal/index.html b/web/public/tr/hazir-ayarlar/empty-and-minimal/index.html index 059aceb6..116a365f 100644 --- a/web/public/tr/hazir-ayarlar/empty-and-minimal/index.html +++ b/web/public/tr/hazir-ayarlar/empty-and-minimal/index.html @@ -181,7 +181,7 @@

Sette ne var?

Dosya29 Tarifindeki hedefler29 - Toplam boyut35 961 B + Toplam boyut35 951 B Biçimleravif, bmp, csv, docx, gif, html, ico, jpg, json, jxl, log, md, pdf, png, pptx, svg, targz, tiff, toml, txt, wav, webm, webp, xlsx, xml, yaml, zip diff --git a/web/public/uk/formats/index.html b/web/public/uk/formats/index.html index 7bcfad1d..de5f1540 100644 --- a/web/public/uk/formats/index.html +++ b/web/public/uk/formats/index.html @@ -336,7 +336,7 @@

27 форматів файлів, кожен створюється точн webm WebM video .webm - 3294 + 3284 full libaom diff --git a/web/public/uk/presets/empty-and-minimal/index.html b/web/public/uk/presets/empty-and-minimal/index.html index 14657443..41abd8a3 100644 --- a/web/public/uk/presets/empty-and-minimal/index.html +++ b/web/public/uk/presets/empty-and-minimal/index.html @@ -180,7 +180,7 @@

Що входить у набір?

Файлів29 Цілей у його рецепті29 - Загальний розмір35 961 B + Загальний розмір35 951 B Форматиavif, bmp, csv, docx, gif, html, ico, jpg, json, jxl, log, md, pdf, png, pptx, svg, targz, tiff, toml, txt, wav, webm, webp, xlsx, xml, yaml, zip diff --git a/web/public/vi/dinh-dang/index.html b/web/public/vi/dinh-dang/index.html index f656605b..b23c19e1 100644 --- a/web/public/vi/dinh-dang/index.html +++ b/web/public/vi/dinh-dang/index.html @@ -335,7 +335,7 @@

27 định dạng tệp, mỗi định dạng được tạo ở kích thư webm WebM video .webm - 3294 + 3284 full libaom diff --git a/web/public/vi/preset/empty-and-minimal/index.html b/web/public/vi/preset/empty-and-minimal/index.html index 94556532..42f8f265 100644 --- a/web/public/vi/preset/empty-and-minimal/index.html +++ b/web/public/vi/preset/empty-and-minimal/index.html @@ -180,7 +180,7 @@

Trong bộ có gì?

Tệp29 Target trong công thức của nó29 - Tổng kích thước35 961 B + Tổng kích thước35 951 B Định dạngavif, bmp, csv, docx, gif, html, ico, jpg, json, jxl, log, md, pdf, png, pptx, svg, targz, tiff, toml, txt, wav, webm, webp, xlsx, xml, yaml, zip diff --git a/web/public/zh-hans/formats/index.html b/web/public/zh-hans/formats/index.html index 10d16362..535f5a5b 100644 --- a/web/public/zh-hans/formats/index.html +++ b/web/public/zh-hans/formats/index.html @@ -334,7 +334,7 @@

27 种文件格式,每一种都按精确大小生成

webm WebM video .webm - 3294 + 3284 full libaom diff --git a/web/public/zh-hans/presets/empty-and-minimal/index.html b/web/public/zh-hans/presets/empty-and-minimal/index.html index f4f8fdbe..2e59287f 100644 --- a/web/public/zh-hans/presets/empty-and-minimal/index.html +++ b/web/public/zh-hans/presets/empty-and-minimal/index.html @@ -179,7 +179,7 @@

集合里有什么?

文件数29 其配方中的 target 数29 - 总大小35 961 B + 总大小35 951 B 格式avif, bmp, csv, docx, gif, html, ico, jpg, json, jxl, log, md, pdf, png, pptx, svg, targz, tiff, toml, txt, wav, webm, webp, xlsx, xml, yaml, zip diff --git a/web/public/zh-hant/formats/index.html b/web/public/zh-hant/formats/index.html index 40ff9907..a2363ae5 100644 --- a/web/public/zh-hant/formats/index.html +++ b/web/public/zh-hant/formats/index.html @@ -334,7 +334,7 @@

27 種檔案格式,每一種都以精確大小產生

webm WebM video .webm - 3294 + 3284 full libaom diff --git a/web/public/zh-hant/presets/empty-and-minimal/index.html b/web/public/zh-hant/presets/empty-and-minimal/index.html index ba4a970a..c14edcc8 100644 --- a/web/public/zh-hant/presets/empty-and-minimal/index.html +++ b/web/public/zh-hant/presets/empty-and-minimal/index.html @@ -179,7 +179,7 @@

組合裡有什麼?

檔案數29 其配方中的 target 數29 - 總大小35 961 B + 總大小35 951 B 格式avif, bmp, csv, docx, gif, html, ico, jpg, json, jxl, log, md, pdf, png, pptx, svg, targz, tiff, toml, txt, wav, webm, webp, xlsx, xml, yaml, zip From 58b083ecdb1bf4b2e37f562e759fc7cbd2124f5b Mon Sep 17 00:00:00 2001 From: DonislawDev Date: Wed, 7 Oct 2026 08:20:15 +0200 Subject: [PATCH 4/4] review #168: the tile guard's reader refuses a cut film with an error, not a panic A panic in a guard ends every guard of the package, not the one that read a broken film. The bit reader says it ran out instead, and the reader of tile_info and the tile group refuses a header that ends inside itself, a frame that ends before its tile group or inside a tile's size, and an empty frame OBU. filmOne is filmWithSeed without the seed rather than a copy of it. Co-Authored-By: Claude Opus 5.5 --- internal/guard/film_test.go | 17 ++--------------- internal/guard/filmtiles_test.go | 25 +++++++++++++++++++++---- 2 files changed, 23 insertions(+), 19 deletions(-) diff --git a/internal/guard/film_test.go b/internal/guard/film_test.go index f86e407a..e699b84b 100644 --- a/internal/guard/film_test.go +++ b/internal/guard/film_test.go @@ -1,7 +1,6 @@ package guard import ( - "context" "encoding/binary" "errors" "fmt" @@ -46,20 +45,8 @@ type webmFilm struct { func filmOne(t *testing.T, target engine.Target) ([]byte, map[string]any) { t.Helper() - dir := t.TempDir() - opt := engine.Options{OutDir: dir, Seed: goldenSeed, Command: "test"} - planned, err := engine.Plan([]engine.Target{target}, opt) - if err != nil { - t.Fatalf("planning %s: %v", target.Format, err) - } - if _, err := engine.Run(context.Background(), planned, opt); err != nil { - t.Fatalf("running %s: %v", target.Format, err) - } - b, err := os.ReadFile(filepath.Join(dir, planned[0].Name)) - if err != nil { - t.Fatalf("reading the film back: %v", err) - } - return b, planned[0].Plan.Properties + b, facts, _ := filmWithSeed(t, target) + return b, facts } // ebmlVint reads an element ID (keeping its marker) or a size (without it). diff --git a/internal/guard/filmtiles_test.go b/internal/guard/filmtiles_test.go index d8d215f8..9f76b767 100644 --- a/internal/guard/filmtiles_test.go +++ b/internal/guard/filmtiles_test.go @@ -23,14 +23,19 @@ import ( // specification (AOMediaCodec/av1-spec 5e04f3f, 06.bitstream.syntax.md lines // 1176-1268 and 1771-1816), not from the writer. +// filmBits reads a frame header a bit at a time. Reading past its end +// gives zeros and says so in overrun, which the reader turns into an error - +// a panic here would end every guard of the package, not this one. type filmBits struct { - b []byte - pos int + b []byte + pos int + overrun bool } func (r *filmBits) bit() int { if r.pos/8 >= len(r.b) { - panic("the frame header ends inside itself") + r.overrun = true + return 0 } v := int(r.b[r.pos/8]>>(7-r.pos%8)) & 1 r.pos++ @@ -102,6 +107,9 @@ func readFilmTiles(frame []byte, before, width, height int) (filmTiles, error) { r.bit() // tx_mode_select } r.bit() // reduced_tx_set + if r.overrun { + return filmTiles{}, fmt.Errorf("the frame ends inside its header") + } pos := (r.pos + 7) / 8 var out filmTiles y := 0 @@ -114,6 +122,9 @@ func readFilmTiles(frame []byte, before, width, height int) (filmTiles, error) { y += 64 * rh } if len(out.rects) > 1 { + if pos >= len(frame) { + return filmTiles{}, fmt.Errorf("the frame ends before its tile group") + } if frame[pos] != 0 { return filmTiles{}, fmt.Errorf("tile_start_and_end_present_flag is set in an OBU_FRAME") } @@ -122,13 +133,16 @@ func readFilmTiles(frame []byte, before, width, height int) (filmTiles, error) { for i := range out.rects { size := len(frame) - pos if i < len(out.rects)-1 { + if pos+sizeBytes > len(frame) { + return filmTiles{}, fmt.Errorf("the frame ends inside the size of tile %d", i) + } size = 1 for b := range sizeBytes { size += int(frame[pos+b]) << (8 * b) } pos += sizeBytes } - if pos+size > len(frame) { + if size < 0 || pos+size > len(frame) { return filmTiles{}, fmt.Errorf("tile %d of %d says %d B and %d remain", i, len(out.rects), size, len(frame)-pos) } out.data = append(out.data, frame[pos:pos+size]) @@ -206,6 +220,9 @@ func frameOBU(sample []byte) ([]byte, int, error) { } if typ := int(sample[0]>>3) & 0xf; typ == 6 { p := sample[1+used : 1+used+size] + if len(p) == 0 { + return nil, 0, fmt.Errorf("an empty frame OBU") + } if frameType := p[0] >> 5 & 3; frameType == 0 { return p, 8, nil }