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..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", @@ -83,7 +95,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 +189,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 +247,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 +265,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..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). @@ -504,9 +491,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 +529,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 +554,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 +578,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 +629,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 +646,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..9f76b767 --- /dev/null +++ b/internal/guard/filmtiles_test.go @@ -0,0 +1,411 @@ +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. + +// 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 + overrun bool +} + +func (r *filmBits) bit() int { + if r.pos/8 >= len(r.b) { + r.overrun = true + return 0 + } + 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 + if r.overrun { + return filmTiles{}, fmt.Errorf("the frame ends inside its header") + } + 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 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") + } + pos++ + } + 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 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]) + 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 len(p) == 0 { + return nil, 0, fmt.Errorf("an empty frame OBU") + } + 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/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 d08e730d..a8ad7dd0 100644 Binary files a/internal/guard/testdata/screens/preset-menu.png and b/internal/guard/testdata/screens/preset-menu.png differ 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 7368e8f2..d00c967c 100644 Binary files a/internal/guard/testdata/screens/preset.png and b/internal/guard/testdata/screens/preset.png differ 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 de1de7d5..8e02749d 100644 Binary files a/internal/guard/testdata/screens/recipe-on-a-preset.png and b/internal/guard/testdata/screens/recipe-on-a-preset.png differ diff --git a/internal/guard/testdata/screens/recipe-on-a-preset.xml b/internal/guard/testdata/screens/recipe-on-a-preset.xml index 1a2afac7..aaff80df 100644 --- a/internal/guard/testdata/screens/recipe-on-a-preset.xml +++ b/internal/guard/testdata/screens/recipe-on-a-preset.xml @@ -658,7 +658,7 @@ - 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/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." }, 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