From a65e1264ee2dbee5a9671496329949ed06f3a8d3 Mon Sep 17 00:00:00 2001 From: DonislawDev Date: Wed, 7 Oct 2026 10:19:21 +0200 Subject: [PATCH 1/3] guard: a golden film cut into tiles - the three before were films of one tile webm_tiles is 640x360 for three seconds: six tiles, three pictures, so how a picture is cut and which tile is coded again are pinned before the rule that cuts larger pictures changes. Measured on the code before that change, and the same hash from the command line with --id g --seed 7741. Co-Authored-By: Claude Opus 5.5 --- internal/guard/generatorbytes_test.go | 5 +++++ internal/guard/testdata/generator-golden.json | 5 +++++ 2 files changed, 10 insertions(+) diff --git a/internal/guard/generatorbytes_test.go b/internal/guard/generatorbytes_test.go index ad4b9e98..8de0847a 100644 --- a/internal/guard/generatorbytes_test.go +++ b/internal/guard/generatorbytes_test.go @@ -164,6 +164,11 @@ func goldenCases() map[string]engine.Target { Properties: map[string]string{"width": "64", "height": "48", "duration": "2s", "keyframe_interval": "1s", "frame_rate": "25"}}, "webm_odd_size": {ID: "g", Format: "webm", Sizes: engine.Uniform(1, 65537), Label: true, Properties: map[string]string{"width": "64", "height": "48"}}, + // The three above are films of one AV1 tile. This one is cut into + // tiles - the clock's, the square's and the rest - so a change to how + // a picture is cut or to which tile is coded again moves its bytes. + "webm_tiles": {ID: "g", Format: "webm", Sizes: engine.Uniform(1, 65536), Label: true, + Properties: map[string]string{"width": "640", "height": "360", "duration": "3s"}}, // An odd size. The free box takes any length at all, so this is the case // that would catch padding that could only step in twos. diff --git a/internal/guard/testdata/generator-golden.json b/internal/guard/testdata/generator-golden.json index 48d25391..bd7540ac 100644 --- a/internal/guard/testdata/generator-golden.json +++ b/internal/guard/testdata/generator-golden.json @@ -346,6 +346,11 @@ "bytes": 65537, "sha256": "d4fc121f6b6714609207bd98d1f52fb232bfdf0520009b1b264b2151b732b3db", "measured_on": "2026-10-06" + }, + "webm_tiles": { + "bytes": 65536, + "sha256": "8ff27de05a089745a6510703c5c593651fc604cf0e3bd30c336adfad484f79b3", + "measured_on": "2026-10-07" } }, "remeasured": [ From 881e38b00feffe66cbfeeaa7f57fbc3a4d3c5aa1 Mon Sep 17 00:00:00 2001 From: DonislawDev Date: Wed, 7 Oct 2026 10:37:24 +0200 Subject: [PATCH 2/3] webm: pictures up to 8192x4352 - the bound is AV1's levels, no longer one tile gav1d codes one tile correctly and no more, and that held a film's picture to 4096x2304. Since pictures are cut into tiles here, every tile is within one of gav1d's, so the bound left is the format's: the largest picture an AV1 level describes, 8192 by 4352, with either side up to 16384. The cut rule keeps today's cuts and adds two, only where a frame cannot carry the layout: a column wider than MAX_TILE_WIDTH and a row taller than the area tile_info leaves a tile once the frame needs several are cut into the fewest equal parts, the larger last, and a frame more than one superblock tall gets columns narrow enough for rows of two superblocks - without that, a wide low picture's last row of tiles was a pixel tall, which Annex A refuses and every decoder took. A picture of 4096x2304 or less keeps the layout and the bytes it had: the golden films, and twelve sizes made by main and by this. Measured on 8K, 8192x4352, 16384x2176, 4097x65, 4096x2305 and 2048x16384: libaom and dav1d decode every frame and agree, dav1d 1.4.1 too, Chromium plays and seeks. New guard: TestEveryFilmLayoutIsOneTheAV1SpecificationAllows holds every layout up to the declared bound to tile_info and Annex A, derived from the specification rather than the writer. A golden film wider than one tile, and wide cases in the tile and libaom guards. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 10 +- internal/format/video/choose.go | 25 --- internal/format/video/grid.go | 78 ++++++-- internal/format/video/settings.go | 45 +++-- internal/format/video/still.go | 13 ++ internal/guard/film_test.go | 41 ++-- internal/guard/filmlayout_test.go | 178 ++++++++++++++++++ internal/guard/filmtiles_test.go | 6 + internal/guard/generatorbytes_test.go | 5 + internal/guard/testdata/generator-golden.json | 5 + internal/gui/text/locale/registry/en.json | 4 +- internal/gui/text/locale/registry/pl.json | 2 +- internal/gui/text/locale/said/en.json | 7 +- internal/gui/text/locale/said/pl.json | 1 - web/public/ar/formats/index.html | 2 +- web/public/cs/formaty/index.html | 2 +- web/public/de/formate/index.html | 2 +- web/public/es/formatos/index.html | 2 +- web/public/formats/index.html | 2 +- web/public/fr/formats/index.html | 2 +- web/public/hi/formats/index.html | 2 +- web/public/id/format/index.html | 2 +- web/public/it/formati/index.html | 2 +- web/public/ja/formats/index.html | 2 +- web/public/ko/formats/index.html | 2 +- web/public/nl/formaten/index.html | 2 +- web/public/pl/formaty/index.html | 2 +- web/public/pt-br/formatos/index.html | 2 +- web/public/ro/formate/index.html | 2 +- web/public/ru/formats/index.html | 2 +- web/public/th/formats/index.html | 2 +- web/public/tr/bicimler/index.html | 2 +- web/public/uk/formats/index.html | 2 +- web/public/vi/dinh-dang/index.html | 2 +- web/public/zh-hans/formats/index.html | 2 +- web/public/zh-hant/formats/index.html | 2 +- 36 files changed, 349 insertions(+), 115 deletions(-) create mode 100644 internal/guard/filmlayout_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index c416b90b..f310823f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -39,9 +39,13 @@ because it turns other people's test suites red. are - `width`, `height` and `quality`. The picture is the gradient with the self describing label the image formats draw, with the clock under the label and the square below. On a picture too small for the clock the film - says so in the manifest. It can be up to 4096 pixels wide and at most - 4096x2304 pixels in all, the largest the built-in encoder writes - correctly - a larger one is refused with a pair that fits. The manifest says what a + says so in the manifest. Either side can be up to 16384 pixels and the + picture up to 8192x4352 pixels in all, the largest any AV1 level + describes, so 7680x4320 and 8192x4320 films can be made - a larger + picture is refused before anything is written. A picture taller than 8704 + pixels belongs to no AV1 level, which a player may hold against it. An 8K + film takes a few seconds and up to about 1.3 GB of memory on sixteen + threads. The manifest says what a test can check: `duration_ms`, `frame_count`, `frame_rate`, `keyframe_count`, `change_count`, `change_interval_ms`, `width`, `height`, `compression: av1` and `audio: false`. A length that does not end on a diff --git a/internal/format/video/choose.go b/internal/format/video/choose.go index 583579af..8dad90b8 100644 --- a/internal/format/video/choose.go +++ b/internal/format/video/choose.go @@ -240,9 +240,6 @@ func named(formatID string, r format.Request, s Settings, c Choice) (Choice, Str if err := checkJointLimits(formatID, w, h); err != nil { return Choice{}, Stream{}, err } - if err := checkOneTile(formatID, w, h); err != nil { - return Choice{}, Stream{}, err - } tiles, reserve, err := sampleReserve(w, h, c.Seed, c.Label, s.Timeline, c.QIndex) if err != nil { return Choice{}, Stream{}, err @@ -270,28 +267,6 @@ func checkJointLimits(formatID string, w, h int) error { return nil } -// checkOneTile refuses a picture that would need a second AV1 tile, before -// the encoder makes a frame nobody can decode out of it. -// -// The declared limit counts pixels and this counts blocks of 64 by 64, each -// side rounded up, which is what the encoder goes by - 4000x2359 is under the -// pixels and one block too many. Said as a setting the request can change, -// not as a fault of the program. -func checkOneTile(formatID string, w, h int) error { - blocks := ((w + 63) / 64) * ((h + 63) / 64) - if blocks <= tileBlocks { - return nil - } - return &format.PropertyValueError{ - Format: formatID, Key: imagedim.SettingWidth + " and " + imagedim.SettingHeight, - Subject: core.Says("format.TwoSettings", "%s and %s", core.A("Of", core.LabelTerm(imagedim.SettingWidth)), core.A("By", core.LabelTerm(imagedim.SettingHeight))), - Value: fmt.Sprintf("%dx%d", w, h), - Reason: core.Says("video.MoreThanOneTile", - "a %dx%d picture is %d blocks of 64 by 64 pixels once its sides are rounded up to whole blocks, and the encoder codes one AV1 tile, which holds %d. Ask for a smaller pair, such as 4096x2304 or 3840x2160", - core.A("Width", w), core.A("Height", h), core.A("Blocks", blocks), core.A("Most", tileBlocks)), - } -} - // Facts is what a film's plan tells the manifest. The keys an image and a // sound already use mean the same here - width, height, frame_count, // duration_ms, compression - because they are public names a test asserts diff --git a/internal/format/video/grid.go b/internal/format/video/grid.go index 0dc0e793..dc1c3007 100644 --- a/internal/format/video/grid.go +++ b/internal/format/video/grid.go @@ -22,7 +22,8 @@ import ( // 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. +// characters reach a column of its own and the rest of the width one column - +// cut further only where a frame cannot carry it (cutToFit). // 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 @@ -50,16 +51,17 @@ func oneTile(width, height int) grid { // 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. +// the clock's columns are joined, then the picture is the fewest tiles it can +// be - one, for any picture of 4096 by 2304 or less. // -// 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. +// A picture is that fewest 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)} + out := cutToFit(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 } @@ -68,7 +70,59 @@ func gridFor(g geometry, fps int) grid { } return out } - return oneTile(g.width, g.height) + return cutToFit(oneTile(g.width, g.height)) +} + +// Layout is the columns and rows, in superblocks, a film's picture is cut +// into - for the guard that holds every layout to what tile_info and Annex A +// allow, from the specification rather than from this package. +func Layout(width, height int, label string, t Timeline) (cols, rows []int) { + g := gridFor(geometryOf(width, height, label, t), t.FPS) + return g.cols, g.rows +} + +// cutToFit cuts a layout further where a frame cannot carry it: a column wider +// than MAX_TILE_WIDTH, and a row taller than tile_info lets a tile be once the +// frame is too large for one (tileBounds.maxArea). Each is cut into the fewest +// equal parts, the larger ones last. A picture of 4096 by 2304 or less needs +// neither, so its layout is the one it had before pictures this large were +// allowed - every such size in a sweep of 10.4 million layouts on 2026-10-07 +// (docs/WEBM-LIMIT-2026-10-07.md section 2), and the golden film of tiles. +// +// A column is also no wider than half that area when the frame has more than +// one row of superblocks, so that a row of tiles can be two superblocks tall. +// Without it, a picture wider than 4096 and only a little over 64 tall came out +// with a last row of one superblock holding a pixel or two, and Annex A asks +// every tile to be at least 8 pixels tall (CroppedTileHeight, annex.a.levels.md +// lines 265-267) - 81 900 layouts of that sweep did. The larger parts go last +// for the same reason: the last part is then at least two superblocks. +func cutToFit(g grid) grid { + b := boundsOf(g.width, g.height) + area := b.maxArea() + widest := maxTileWidth / superblock + if b.sbRows > 1 { + widest = min(widest, max(area/2, 1)) + } + g.cols = cutEach(g.cols, widest) + g.rows = cutEach(g.rows, max(area/slices.Max(g.cols), 1)) + return g +} + +// cutEach cuts every size over most into the fewest parts no larger than it, +// as equal as whole superblocks allow, the larger ones last. +func cutEach(sizes []int, most int) []int { + out := make([]int, 0, len(sizes)) + for _, s := range sizes { + n := (s + most - 1) / most + for k := range n { + part := s / n + if k >= n-s%n { + part++ + } + out = append(out, part) + } + } + return out } // tileLimits is MaxTiles and MaxTileCols of a level, annex.a.levels.md lines @@ -122,7 +176,7 @@ func tileCols(g geometry, apart bool) []int { // 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). +// lines 265-267). func sizesBetween(cuts []int, total, px int) []int { slices.Sort(cuts) cuts = slices.Compact(cuts) @@ -184,13 +238,9 @@ func (g grid) writeTileInfo(w *bitWriter, tileSizeBytes int) { 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 + w.ns(s-1, min(b.sbRows-start, max(b.maxArea()/widest, 1))) // height_in_sbs_minus_1 start += s } // context_update_tile_id says which tile's probabilities a frame keeps for diff --git a/internal/format/video/settings.go b/internal/format/video/settings.go index ae715893..2d134933 100644 --- a/internal/format/video/settings.go +++ b/internal/format/video/settings.go @@ -41,25 +41,24 @@ const ( maxQuality = 100 defaultQuality = 60 - // The picture has to fit one AV1 tile, because gav1d codes one: a frame - // wider than 4096 or larger than 2304 blocks of 64 by 64 pixels comes out - // of its encoder with a header that announces several tiles over the data - // of one, and both libaom and gav1d's own decoder refuse it. Measured on - // 2026-10-06 (docs/REVIEW-165-2026-10-06.md): 4096x2304 decodes, 4096x2305 - // and 4097x64 do not. That is a limit of the encoder this tool carries, not - // of AV1, so it is the number to raise if a later gav1d codes several tiles. + // The picture's bounds are AV1's, not the encoder's. gav1d codes one tile + // correctly and no more - a larger frame comes out of it with a header + // that announces several tiles over the data of one (measured 2026-10-06, + // docs/REVIEW-165-2026-10-06.md), and until 2026-10-07 that held the + // picture to 4096x2304. A picture is cut into tiles here now (grid.go) and + // every tile is within one of gav1d's, so the bound left is the format's: + // maxPixels is the largest picture an AV1 level describes, 8192 by 4352 + // (MaxPicSize of the 6.x levels, annex.a.levels.md lines 89-92). A larger + // one could declare no level but the maximum parameters one, which a + // player is free to refuse. The owner's decision of 2026-10-07 + // (docs/WEBM-LIMIT-2026-10-07.md section 10). // - // maxWidth is the widest tile. maxHeight is gav1d's longest side - a tall - // narrow picture is one tile as long as it stays within the area. - maxWidth = 4096 + // Either side reaches 16384, the widest picture of those levels. A picture + // taller than their 8704 declares the maximum parameters level, as it did + // before the bound moved - a tall narrow picture was always allowed. + maxWidth = 16384 maxHeight = 16384 - // tileBlocks is how many 64 by 64 blocks one tile holds, and - // maxTilePixels the same area in pixels, which is what the registry can - // declare: a product of the two sides. The blocks are what decides, because - // a side is rounded up to a whole block, so a pair just under the pixels - // can still be one block too many - checked before coding, see named. - tileBlocks = 2304 - maxTilePixels = 4096 * 2304 + maxPixels = 8192 * 4352 ) // frameRates are whole rates only. 23.976, 29.97 and 59.94 need a time scale @@ -104,16 +103,14 @@ func Properties() []format.Property { } // JointLimits is the bound on the picture, declared once for both formats: -// the area of one AV1 tile. In pixels rather than megapixels, because 9 437 184 -// read as "9 megapixels" would be a limit nobody can aim at. -// -// The memory bound AVIF declares is not here, and not by omission: one tile is -// far below it, so it could never be the limit a request meets. +// the largest picture an AV1 level describes. In pixels rather than +// megapixels, because 35 651 584 read as "36 megapixels" would be a limit +// nobody can aim at. func JointLimits() []format.JointLimit { return []format.JointLimit{{ - Of: imagedim.SettingWidth, By: imagedim.SettingHeight, Max: maxTilePixels, + Of: imagedim.SettingWidth, By: imagedim.SettingHeight, Max: maxPixels, Unit: "pixels", Base: "pixels", - Why: "the encoder codes a picture as one AV1 tile, which holds 4096 by 2304 pixels", + Why: "the largest picture any AV1 level describes is 8192 by 4352 pixels", }} } diff --git a/internal/format/video/still.go b/internal/format/video/still.go index a061c93f..86353b22 100644 --- a/internal/format/video/still.go +++ b/internal/format/video/still.go @@ -171,6 +171,19 @@ type tileBounds struct { maxLog2Rows, minLog2Tiles int } +// maxArea is maxTileAreaSb as tile_info derives it for tiles of coded sizes, +// 06.bitstream.syntax.md lines 1243-1247: the whole frame while it fits one +// tile, and once it does not, the frame shifted right by minLog2Tiles plus +// one - a quarter to a half of MAX_TILE_AREA. A row of tiles is at most this +// over the widest column, and a height tile_info cannot code is no height. +func (b tileBounds) maxArea() int { + area := b.sbRows * b.sbCols + if b.minLog2Tiles > 0 { + area >>= b.minLog2Tiles + 1 + } + return area +} + func boundsOf(width, height int) tileBounds { b := tileBounds{sbCols: sbOf(width), sbRows: sbOf(height)} b.minLog2Cols = tileLog2(maxTileWidth/superblock, b.sbCols) diff --git a/internal/guard/film_test.go b/internal/guard/film_test.go index e699b84b..d8e44121 100644 --- a/internal/guard/film_test.go +++ b/internal/guard/film_test.go @@ -553,6 +553,8 @@ func TestEveryFrameOfAFilmSurvivesItsReferenceTool(t *testing.T) { {}, {"duration": "10m", "keyframe_interval": "30s", "frame_rate": "30"}, {"duration": "40ms", "frame_rate": "25"}, + // Wider than one tile can be (grid.go, cutToFit). + {"width": "4240", "height": "1000", "duration": "2s"}, } tiled := 0 for _, props := range cases { @@ -585,29 +587,34 @@ func TestEveryFrameOfAFilmSurvivesItsReferenceTool(t *testing.T) { } } -// A picture the encoder would code as more than one AV1 tile is refused as a -// setting, before coding - not left to come out as an internal error. +// A picture larger than any AV1 level describes is refused as a setting, +// before coding, and the pictures that used to be refused for not fitting one +// tile are not. // -// gav1d codes one tile, and a larger frame comes out of it with a header that -// announces several tiles over the data of one, which libaom and gav1d's own -// decoder both refuse (measured 2026-10-06, docs/REVIEW-165-2026-10-06.md). -// Until the review of #165 the format declared sides up to 16384 and such a -// picture ended the run with exit 1 - "the program broke" - for a request the -// declaration had invited. 4000x2359 is the case the declared limit cannot -// catch: under the pixels, one block too many once its sides are rounded up. -func TestAFilmPictureLargerThanOneTileIsRefusedNotBroken(t *testing.T) { - for _, size := range [][2]string{{"4096", "2305"}, {"4000", "2359"}, {"4097", "64"}, {"7680", "4320"}} { - props := map[string]string{"width": size[0], "height": size[1], "duration": "1s"} +// Until 2026-10-07 the bound was one AV1 tile, 4096x2304, because gav1d codes +// one tile correctly and no more (docs/REVIEW-165-2026-10-06.md). A picture +// is cut into tiles of at most that now, and the bound is AV1's own: 8192 by +// 4352, the 6.x levels' MaxPicSize, with either side up to 16384 (the owner's +// decision, docs/WEBM-LIMIT-2026-10-07.md section 10). The refused pairs are +// one pixel over the area each way and a side over 16384. The accepted ones +// are the four the old bound refused, cheap to plan, and the two longest +// sides - not 8192x4352 itself, whose plan codes a sample of 8K pictures. +func TestAFilmPictureLargerThanAnyAV1LevelIsRefusedNotBroken(t *testing.T) { + plan := func(w, h string) error { + props := map[string]string{"width": w, "height": h, "duration": "1s"} _, err := engine.Plan([]engine.Target{filmTarget(32*1024*1024, props)}, engine.Options{OutDir: t.TempDir(), Seed: goldenSeed, Command: "test"}) + return err + } + for _, size := range [][2]string{{"8192", "4353"}, {"8193", "4352"}, {"16384", "2177"}, {"16385", "64"}, {"64", "16385"}} { var refused *format.PropertyValueError - if !errors.As(err, &refused) { + if err := plan(size[0], size[1]); !errors.As(err, &refused) { t.Errorf("%sx%s was not refused as a setting: %v", size[0], size[1], err) } } - // And the largest picture one tile holds is not refused. - props := map[string]string{"width": "4096", "height": "2304", "duration": "1s"} - if _, err := engine.Plan([]engine.Target{filmTarget(32*1024*1024, props)}, engine.Options{OutDir: t.TempDir(), Seed: goldenSeed, Command: "test"}); err != nil { - t.Errorf("4096x2304 is one tile and was refused: %v", err) + for _, size := range [][2]string{{"4096", "2305"}, {"4000", "2359"}, {"4097", "64"}, {"4352", "512"}, {"16384", "64"}, {"64", "16384"}} { + if err := plan(size[0], size[1]); err != nil { + t.Errorf("%sx%s is inside every bound the format declares and was refused: %v", size[0], size[1], err) + } } } diff --git a/internal/guard/filmlayout_test.go b/internal/guard/filmlayout_test.go new file mode 100644 index 00000000..c88f054b --- /dev/null +++ b/internal/guard/filmlayout_test.go @@ -0,0 +1,178 @@ +package guard + +import ( + "fmt" + "testing" + + "github.com/donislawdev/TestingFilesGenerator/internal/core" + "github.com/donislawdev/TestingFilesGenerator/internal/format" + "github.com/donislawdev/TestingFilesGenerator/internal/format/video" +) + +// annexATiles is MaxTiles and MaxTileCols of each level a film can declare, by +// seq_level_idx, copied from the specification's own table (AOMediaCodec/av1-spec +// 5e04f3f, annex.a.levels.md lines 99-115) rather than from the package, so +// the two are held to the source and not to each other. The maximum parameters +// level, 31, has no row: no level constraint applies to it. +var annexATiles = map[int][2]int{ + 0: {8, 4}, 1: {8, 4}, 4: {16, 6}, 5: {16, 6}, 8: {32, 8}, 9: {32, 8}, + 12: {64, 8}, 13: {64, 8}, 14: {64, 8}, 15: {64, 8}, + 16: {128, 16}, 17: {128, 16}, 18: {128, 16}, 19: {128, 16}, +} + +// Every way a film's picture is cut into tiles is one the AV1 specification +// allows - over every size the format declares, up to its bound. +// +// A picture is cut by a rule (internal/format/video, grid.go) rather than by +// asking an encoder, and a decoder takes a layout it should refuse without a +// word: libaom, dav1d and Chromium all played a film whose last row of tiles +// was one pixel tall, which Annex A forbids (docs/WEBM-LIMIT-2026-10-07.md +// section 2 - 81 900 such layouts in a sweep before the rule was mended). So +// this asks the specification, not a decoder: tile_info's own bounds +// (06.bitstream.syntax.md lines 1176-1268 - a column at most 64 superblocks, +// a row at most the area tile_info leaves over the widest column, one tile +// only where the frame fits one), MAX_TILE_AREA, 64 columns and rows at most, +// and Annex A for the lowest level the size and rate allow (annex.a.levels.md +// lines 240-271 - tiles and columns per level, the last tile at least 8 pixels +// each way, the largest tile times the frames a second under 588 251 136). +// +// The sizes are every side from 1 to 17 and each side next to a multiple of +// 64, where a superblock starts or ends, paired up to the declared bound. Two +// clocks and two rates, alternating, because the clock decides the columns +// and the rate the level. +func TestEveryFilmLayoutIsOneTheAV1SpecificationAllows(t *testing.T) { + d, err := format.Get("webm") + if err != nil { + t.Fatal(err) + } + if len(d.JointLimits) != 1 { + t.Fatalf("webm declares %d joint limits and this guard reads one", len(d.JointLimits)) + } + most := d.JointLimits[0].Max + var sides []int + for s := 1; s <= 17; s++ { + sides = append(sides, s) + } + for k := 1; k <= 256; k++ { + for _, s := range []int{64*k - 1, 64 * k, 64*k + 1, 64*k + 8} { + if s > 17 && s <= 16384 { + sides = append(sides, s) + } + } + } + var films [2]video.Timeline + for i, v := range [][2]int64{{1000, 30}, {500, 60}} { + if films[i], err = video.NewTimeline("webm", 10_000, 60_000, v[0], int(v[1])); err != nil { + t.Fatal(err) + } + } + labels := [2]string{core.Label("webm", 268435456, 7741), ""} + seen := layoutsSeen{} + n := 0 + for _, w := range sides { + for _, h := range sides { + if int64(w)*int64(h) > most { + continue + } + film := films[n%2] + cols, rows := video.Layout(w, h, labels[n/2%2], film) + n++ + for _, why := range layoutBreaks(w, h, film.FPS, cols, rows, &seen) { + if seen.reported < 20 { + t.Errorf("%dx%d at %d frames a second, columns %v, rows %v: %s", w, h, film.FPS, cols, rows, why) + } + seen.reported++ + } + } + } + // The guard has to have asked about the layouts the rule cuts, or it says + // nothing about them (O118). + if seen.wide < 1000 || seen.byArea < 1000 || seen.narrowed < 100 || seen.mostTiles < 32 { + t.Fatalf("%d layouts, %d with a column cut for width, %d with rows cut for area, %d with columns narrowed for a row of two, %d tiles at most - the sweep did not reach the layouts it is for", + n, seen.wide, seen.byArea, seen.narrowed, seen.mostTiles) + } + t.Logf("%d layouts, %d wide, %d cut for area, %d narrowed, %d tiles at most", n, seen.wide, seen.byArea, seen.narrowed, seen.mostTiles) + if seen.reported > 0 { + t.Errorf("%d breaks in %d layouts", seen.reported, n) + } +} + +type layoutsSeen struct { + wide, byArea, narrowed, mostTiles, reported int +} + +// layoutBreaks is every rule of the specification this layout of a w by h +// picture at fps breaks, and counts what kind of layout it is. +func layoutBreaks(w, h, fps int, cols, rows []int, seen *layoutsSeen) []string { + var out []string + sbCols, sbRows := (w+63)/64, (h+63)/64 + if sum(cols) != sbCols || sum(rows) != sbRows { + return []string{fmt.Sprintf("the sizes add up to %dx%d superblocks and the picture is %dx%d", sum(cols), sum(rows), sbCols, sbRows)} + } + minTiles := max(log2Up(64, sbCols), log2Up(2304, sbRows*sbCols)) + area := sbRows * sbCols + if minTiles > 0 { + area >>= minTiles + 1 + } + widest := 0 + for _, c := range cols { + widest = max(widest, c) + } + tiles := len(cols) * len(rows) + switch { + case tiles == 1 && minTiles > 0: + out = append(out, "one tile, and tile_info asks this frame for more") + case tiles > 1: + for _, r := range rows { + if r > max(area/widest, 1) { + out = append(out, fmt.Sprintf("a row of %d superblocks, and tile_info codes at most %d", r, max(area/widest, 1))) + } + } + } + if widest > 64 { + out = append(out, fmt.Sprintf("a column of %d superblocks, wider than MAX_TILE_WIDTH", widest)) + } + if len(cols) > 64 || len(rows) > 64 { + out = append(out, "more than 64 columns or rows") + } + largest := 0 + for _, r := range rows { + largest = max(largest, r*widest*64*64) + } + if largest > 4096*2304 { + out = append(out, "a tile larger than MAX_TILE_AREA") + } + if limits, ok := annexATiles[video.LevelFor(w, h, fps, 0, 1)]; ok { + if tiles > limits[0] || len(cols) > limits[1] { + out = append(out, fmt.Sprintf("%d tiles in %d columns, and the level allows %d in %d", tiles, len(cols), limits[0], limits[1])) + } + if len(cols) > 1 && w-64*(sbCols-cols[len(cols)-1]) < 8 { + out = append(out, "the last column is under 8 pixels") + } + if len(rows) > 1 && h-64*(sbRows-rows[len(rows)-1]) < 8 { + out = append(out, "the last row is under 8 pixels") + } + if largest*fps > 588_251_136 { + out = append(out, "the largest tile times the frames a second is over 588 251 136") + } + } + if sbCols > 64 { + seen.wide++ + } + if minTiles > 0 && sbCols <= 64 { + seen.byArea++ + } + if sbCols > 64 && sbRows > 1 && area/2 < 64 { + seen.narrowed++ + } + seen.mostTiles = max(seen.mostTiles, tiles) + return out +} + +func sum(sizes []int) int { + n := 0 + for _, s := range sizes { + n += s + } + return n +} diff --git a/internal/guard/filmtiles_test.go b/internal/guard/filmtiles_test.go index 9f76b767..60d50387 100644 --- a/internal/guard/filmtiles_test.go +++ b/internal/guard/filmtiles_test.go @@ -280,6 +280,12 @@ func TestEveryTileOfAFilmIsGav1dsCodingOfItsPixelsAndDecodesAsItDoesAlone(t *tes {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)}, + // Wider than a tile can be, so the rest of the width is cut in two, + // and a row cut for the area tile_info leaves a tile. Then wider and + // only a superblock and a pixel tall, where the columns are narrowed so + // a row of tiles can be two superblocks (grid.go, cutToFit). + {map[string]string{"width": "4240", "height": "1000", "duration": "2s"}, 4 << 20, rangeOf(2)}, + {map[string]string{"width": "4097", "height": "65", "duration": "3s"}, 4 << 20, rangeOf(3)}, } tilesAsked, mostTiles := 0, 0 for _, c := range cases { diff --git a/internal/guard/generatorbytes_test.go b/internal/guard/generatorbytes_test.go index 8de0847a..335dd8ac 100644 --- a/internal/guard/generatorbytes_test.go +++ b/internal/guard/generatorbytes_test.go @@ -169,6 +169,11 @@ func goldenCases() map[string]engine.Target { // a picture is cut or to which tile is coded again moves its bytes. "webm_tiles": {ID: "g", Format: "webm", Sizes: engine.Uniform(1, 65536), Label: true, Properties: map[string]string{"width": "640", "height": "360", "duration": "3s"}}, + // Wider than one tile can be: the rest of the width is cut in two + // unequal columns, 32 and 33 superblocks, and a row of nine into four + // and five for the area tile_info leaves a tile, the larger last. + "webm_wide": {ID: "g", Format: "webm", Sizes: engine.Uniform(1, 524288), Label: true, + Properties: map[string]string{"width": "4240", "height": "1000", "duration": "2s"}}, // An odd size. The free box takes any length at all, so this is the case // that would catch padding that could only step in twos. diff --git a/internal/guard/testdata/generator-golden.json b/internal/guard/testdata/generator-golden.json index bd7540ac..1627e6cc 100644 --- a/internal/guard/testdata/generator-golden.json +++ b/internal/guard/testdata/generator-golden.json @@ -351,6 +351,11 @@ "bytes": 65536, "sha256": "8ff27de05a089745a6510703c5c593651fc604cf0e3bd30c336adfad484f79b3", "measured_on": "2026-10-07" + }, + "webm_wide": { + "bytes": 524288, + "sha256": "270aab596f2a59abe47cf9ab35236964b88dcd1be2ea24247cbce8c72c3c1cd0", + "measured_on": "2026-10-07" } }, "remeasured": [ diff --git a/internal/gui/text/locale/registry/en.json b/internal/gui/text/locale/registry/en.json index a5245ecf..902aedda 100644 --- a/internal/gui/text/locale/registry/en.json +++ b/internal/gui/text/locale/registry/en.json @@ -1591,8 +1591,8 @@ }, "Joint.format/webm.width.height": { "description": "Why width times height of the webm format have a ceiling, at the end of the note under the two, after the word because.", - "hash": "sha256-ed5704060118", - "other": "the encoder codes a picture as one AV1 tile, which holds 4096 by 2304 pixels" + "hash": "sha256-72e26aa125ce", + "other": "the largest picture any AV1 level describes is 8192 by 4352 pixels" }, "Joint.format/xlsx.rows.columns": { "description": "Why rows times columns of the xlsx format have a ceiling, at the end of the note under the two, after the word because.", diff --git a/internal/gui/text/locale/registry/pl.json b/internal/gui/text/locale/registry/pl.json index c8c0698e..61320d25 100644 --- a/internal/gui/text/locale/registry/pl.json +++ b/internal/gui/text/locale/registry/pl.json @@ -317,7 +317,7 @@ "Joint.format/jpg.width.height": { "hash": "sha256-0bed7aa664d8", "other": "obraz jest trzymany w pamięci podczas kodowania" }, "Joint.format/jxl.width.height": { "hash": "sha256-269a6c476761", "other": "koder trzyma cały obraz w pamięci podczas pracy" }, "Joint.format/png.width.height": { "hash": "sha256-0bed7aa664d8", "other": "obraz jest trzymany w pamięci podczas kodowania" }, - "Joint.format/webm.width.height": { "hash": "sha256-ed5704060118", "other": "koder zapisuje obraz jako jeden kafel AV1, a ten mieści 4096 na 2304 piksele" }, + "Joint.format/webm.width.height": { "hash": "sha256-72e26aa125ce", "other": "największy obraz, jaki opisuje którykolwiek poziom AV1, ma 8192 na 4352 piksele" }, "Joint.format/xlsx.rows.columns": { "hash": "sha256-e1ef283f3c5d", "other": "arkusz powstaje w pamięci, zanim zostanie spakowany" }, "Kind.file": { "hash": "sha256-3b9c358f36f0", "other": "plik" }, "Kind.folder": { "hash": "sha256-034a00624882", "other": "folder" }, diff --git a/internal/gui/text/locale/said/en.json b/internal/gui/text/locale/said/en.json index e1a2c504..860694f9 100644 --- a/internal/gui/text/locale/said/en.json +++ b/internal/gui/text/locale/said/en.json @@ -1591,7 +1591,7 @@ "other": "this size needs a padding chunk and the smallest one costs {{.ChunkHeader}} B, so nothing between {{.Fixed}} and {{.Fixed2}} B can be reached" }, "format.TwoSettings": { - "description": "Said by internal/format/video - a refusal under the box it is about or at the foot of the form, or a note after a run. Carries {{.Of}}, {{.By}}, written the way the program writes them - keep each spelled exactly that way.", + "description": "Said by internal/format/logfile - a refusal under the box it is about or at the foot of the form, or a note after a run. Carries {{.Of}}, {{.By}}, written the way the program writes them - keep each spelled exactly that way.", "hash": "sha256-a61ae8d11eb8", "other": "{{.Of}} and {{.By}}" }, @@ -3141,11 +3141,6 @@ "hash": "sha256-a53588601259", "other": "use one of: {{.Known}}" }, - "video.MoreThanOneTile": { - "description": "Said by internal/format/video - a refusal under the box it is about or at the foot of the form, or a note after a run. Carries {{.Width}}, {{.Height}}, {{.Blocks}}, {{.Most}}, written the way the program writes them - keep each spelled exactly that way.", - "hash": "sha256-319218310f03", - "other": "a {{.Width}}x{{.Height}} picture is {{.Blocks}} blocks of 64 by 64 pixels once its sides are rounded up to whole blocks, and the encoder codes one AV1 tile, which holds {{.Most}}. Ask for a smaller pair, such as 4096x2304 or 3840x2160" - }, "video.NoClock": { "description": "Said by internal/format/webm - a refusal under the box it is about or at the foot of the form, or a note after a run. Carries {{.Width}}, {{.Height}}, written the way the program writes them - keep each spelled exactly that way.", "hash": "sha256-9fb32f58b247", diff --git a/internal/gui/text/locale/said/pl.json b/internal/gui/text/locale/said/pl.json index a9fb5748..77069513 100644 --- a/internal/gui/text/locale/said/pl.json +++ b/internal/gui/text/locale/said/pl.json @@ -625,7 +625,6 @@ "tool.ThereIsNoToolCalled": { "hash": "sha256-151e818f434e", "other": "nie ma narzędzia o nazwie {{.ID}}" }, "tool.ThisBuildHasNoTools": { "hash": "sha256-42b26a93974c", "other": "ta wersja nie ma narzędzi" }, "tool.UseOneOf": { "hash": "sha256-a53588601259", "other": "użyj jednego z: {{.Known}}" }, - "video.MoreThanOneTile": { "hash": "sha256-319218310f03", "other": "obraz {{.Width}}x{{.Height}} po zaokrągleniu boków w górę do pełnych bloków 64 na 64 piksele ma ich {{.Blocks}}, a koder zapisuje jeden kafel AV1, który mieści ich najwyżej {{.Most}}. Poproś o mniejszą parę, na przykład 4096x2304 albo 3840x2160" }, "video.NoClock": { "hash": "sha256-9fb32f58b247", "other": "Obraz ma {{.Width}}x{{.Height}} i zegar potrzebuje więcej miejsca, więc ten film nie pokazuje zegara. Kwadrat dalej przesuwa się po nim tam, gdzie ma miejsce na ruch." }, "video.NotOnAFrame": { "hash": "sha256-4471642f4d67", "other": "przy {{.FPS}} klatkach na sekundę długość musi kończyć się na klatce, a przy tej szybkości długości idą co {{.Step}}. Poproś o {{.Below}} albo {{.Above}}" }, "video.NotOnAFrameShortest": { "hash": "sha256-e6b01f8c0aeb", "other": "przy {{.FPS}} klatkach na sekundę długość musi kończyć się na klatce, a przy tej szybkości długości idą co {{.Step}}. Poproś o {{.Above}}, najkrótszy film przy tej szybkości" }, diff --git a/web/public/ar/formats/index.html b/web/public/ar/formats/index.html index 1ea97409..d399f72f 100644 --- a/web/public/ar/formats/index.html +++ b/web/public/ar/formats/index.html @@ -780,7 +780,7 @@

الإعدادات التي تقبلها كل صيغة

webm width - 1 - 4096 بكسل + 1 - 16384 بكسل diff --git a/web/public/cs/formaty/index.html b/web/public/cs/formaty/index.html index 9aed34c2..96fb704d 100644 --- a/web/public/cs/formaty/index.html +++ b/web/public/cs/formaty/index.html @@ -781,7 +781,7 @@

Nastavení, která každý formát přijímá

webm width - 1 - 4096 pixelů + 1 - 16384 pixelů diff --git a/web/public/de/formate/index.html b/web/public/de/formate/index.html index d2aef027..bc06e3e5 100644 --- a/web/public/de/formate/index.html +++ b/web/public/de/formate/index.html @@ -783,7 +783,7 @@

Einstellungen, die jedes Format kennt

webm width - 1 - 4096 Pixel + 1 - 16384 Pixel diff --git a/web/public/es/formatos/index.html b/web/public/es/formatos/index.html index a310a9f0..869494ee 100644 --- a/web/public/es/formatos/index.html +++ b/web/public/es/formatos/index.html @@ -784,7 +784,7 @@

Ajustes que admite cada formato

webm width - 1 - 4096 píxeles + 1 - 16384 píxeles diff --git a/web/public/formats/index.html b/web/public/formats/index.html index 453afecb..f9a74933 100644 --- a/web/public/formats/index.html +++ b/web/public/formats/index.html @@ -782,7 +782,7 @@

Settings each format accepts

webm width - 1 - 4096 pixels + 1 - 16384 pixels diff --git a/web/public/fr/formats/index.html b/web/public/fr/formats/index.html index 1bbfd88e..8b31bc21 100644 --- a/web/public/fr/formats/index.html +++ b/web/public/fr/formats/index.html @@ -784,7 +784,7 @@

Réglages que chaque format accepte

webm width - 1 - 4096 pixels + 1 - 16384 pixels diff --git a/web/public/hi/formats/index.html b/web/public/hi/formats/index.html index 4891d3d9..d58ec728 100644 --- a/web/public/hi/formats/index.html +++ b/web/public/hi/formats/index.html @@ -782,7 +782,7 @@

हर फ़ॉर्मैट की स्वीकार की जा webm width - 1 - 4096 पिक्सेल + 1 - 16384 पिक्सेल diff --git a/web/public/id/format/index.html b/web/public/id/format/index.html index f4c72dc4..918f7bf4 100644 --- a/web/public/id/format/index.html +++ b/web/public/id/format/index.html @@ -782,7 +782,7 @@

Pengaturan yang diterima setiap format

webm width - 1 - 4096 piksel + 1 - 16384 piksel diff --git a/web/public/it/formati/index.html b/web/public/it/formati/index.html index 7a33e0ac..32c6ad41 100644 --- a/web/public/it/formati/index.html +++ b/web/public/it/formati/index.html @@ -784,7 +784,7 @@

Impostazioni che ogni formato accetta

webm width - 1 - 4096 pixel + 1 - 16384 pixel diff --git a/web/public/ja/formats/index.html b/web/public/ja/formats/index.html index 778a2e56..348698c9 100644 --- a/web/public/ja/formats/index.html +++ b/web/public/ja/formats/index.html @@ -774,7 +774,7 @@

形式ごとに受け付ける設定

webm width - 1 - 4096 ピクセル + 1 - 16384 ピクセル diff --git a/web/public/ko/formats/index.html b/web/public/ko/formats/index.html index c0fa96e2..a71984f2 100644 --- a/web/public/ko/formats/index.html +++ b/web/public/ko/formats/index.html @@ -775,7 +775,7 @@

각 형식이 받는 설정

webm width - 1 - 4096 픽셀 + 1 - 16384 픽셀 diff --git a/web/public/nl/formaten/index.html b/web/public/nl/formaten/index.html index a2a65232..1cafb76c 100644 --- a/web/public/nl/formaten/index.html +++ b/web/public/nl/formaten/index.html @@ -784,7 +784,7 @@

Instellingen die elk formaat accepteert

webm width - 1 - 4096 pixels + 1 - 16384 pixels diff --git a/web/public/pl/formaty/index.html b/web/public/pl/formaty/index.html index cdcafe60..8d7c0167 100644 --- a/web/public/pl/formaty/index.html +++ b/web/public/pl/formaty/index.html @@ -782,7 +782,7 @@

Ustawienia, które przyjmuje każdy format

webm width - 1 - 4096 pikseli + 1 - 16384 pikseli diff --git a/web/public/pt-br/formatos/index.html b/web/public/pt-br/formatos/index.html index a86a592b..1485bf15 100644 --- a/web/public/pt-br/formatos/index.html +++ b/web/public/pt-br/formatos/index.html @@ -783,7 +783,7 @@

Configurações que cada formato aceita

webm width - 1 - 4096 pixels + 1 - 16384 pixels diff --git a/web/public/ro/formate/index.html b/web/public/ro/formate/index.html index f3c1a6d9..d06d9826 100644 --- a/web/public/ro/formate/index.html +++ b/web/public/ro/formate/index.html @@ -783,7 +783,7 @@

Setările pe care le acceptă fiecare format

webm width - 1 - 4096 pixeli + 1 - 16384 pixeli diff --git a/web/public/ru/formats/index.html b/web/public/ru/formats/index.html index 1731cac2..47684caa 100644 --- a/web/public/ru/formats/index.html +++ b/web/public/ru/formats/index.html @@ -782,7 +782,7 @@

Настройки, которые принимает каждый форм webm width - 1 - 4096 пикселей + 1 - 16384 пикселей diff --git a/web/public/th/formats/index.html b/web/public/th/formats/index.html index 1ec22c8b..12e24590 100644 --- a/web/public/th/formats/index.html +++ b/web/public/th/formats/index.html @@ -780,7 +780,7 @@

การตั้งค่าที่แต่ละรูปแบบร webm width - 1 - 4096 พิกเซล + 1 - 16384 พิกเซล diff --git a/web/public/tr/bicimler/index.html b/web/public/tr/bicimler/index.html index 09086293..f1796af0 100644 --- a/web/public/tr/bicimler/index.html +++ b/web/public/tr/bicimler/index.html @@ -782,7 +782,7 @@

Her biçimin kabul ettiği ayarlar

webm width - 1 - 4096 piksel + 1 - 16384 piksel diff --git a/web/public/uk/formats/index.html b/web/public/uk/formats/index.html index de5f1540..9f22d304 100644 --- a/web/public/uk/formats/index.html +++ b/web/public/uk/formats/index.html @@ -782,7 +782,7 @@

Налаштування, які приймає кожен формат

webm width - 1 - 4096 пікселів + 1 - 16384 пікселів diff --git a/web/public/vi/dinh-dang/index.html b/web/public/vi/dinh-dang/index.html index b23c19e1..0e50f334 100644 --- a/web/public/vi/dinh-dang/index.html +++ b/web/public/vi/dinh-dang/index.html @@ -780,7 +780,7 @@

Các thiết lập mỗi định dạng nhận

webm width - 1 - 4096 pixel + 1 - 16384 pixel diff --git a/web/public/zh-hans/formats/index.html b/web/public/zh-hans/formats/index.html index 535f5a5b..0405c559 100644 --- a/web/public/zh-hans/formats/index.html +++ b/web/public/zh-hans/formats/index.html @@ -774,7 +774,7 @@

每种格式接受的设置

webm width - 1 - 4096 像素 + 1 - 16384 像素 diff --git a/web/public/zh-hant/formats/index.html b/web/public/zh-hant/formats/index.html index a2363ae5..98f6ab20 100644 --- a/web/public/zh-hant/formats/index.html +++ b/web/public/zh-hant/formats/index.html @@ -774,7 +774,7 @@

每種格式接受的設定

webm width - 1 - 4096 像素 + 1 - 16384 像素 From 8a6ed741b00090078288abe5876bca4fd811ad4c Mon Sep 17 00:00:00 2001 From: DonislawDev Date: Wed, 7 Oct 2026 11:04:51 +0200 Subject: [PATCH 3/3] webm: a film holds its picture once - an 8K film peaks at about 240 MB, it took 1.3 GB A film kept its starting picture whole in pixels and in planes, and every painter - one for each helper coding beside the writer - copied the planes whole, so a 7680x4320 film held 133 MB of pixels its painters read a sixth of and fifteen copies of 50 MB. Now the film makes its planes sixty four rows at a time and keeps pixels only along the rows a picture can change, a painter has room for the largest tile that can change, and a tile no picture changes is coded straight from the film's planes, which every goroutine reads and none writes. Measured before and after, interleaved, three rounds each: one 7680x4320 film 1276-1284 MB at the peak, now 187-241 MB. Sixteen of them at once 3733-3914 MB, now 1068-1106 MB. 3840x2160 356-358 MB, now 144 MB. The time is the same, and so are the bytes - the golden films, and nine large films made both ways. video.Picture now puts a picture together tile by tile from where the film takes each tile, so the guard that holds it to the picture painted whole holds the tiles' own road. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 4 +- internal/format/video/ahead.go | 8 +- internal/format/video/grid.go | 7 +- internal/format/video/picture.go | 292 ++++++++++++++++++++++-------- internal/format/video/pictures.go | 5 +- internal/format/video/settings.go | 6 + internal/format/video/still.go | 13 +- 7 files changed, 236 insertions(+), 99 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f310823f..9130fde5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -44,8 +44,8 @@ because it turns other people's test suites red. describes, so 7680x4320 and 8192x4320 films can be made - a larger picture is refused before anything is written. A picture taller than 8704 pixels belongs to no AV1 level, which a player may hold against it. An 8K - film takes a few seconds and up to about 1.3 GB of memory on sixteen - threads. The manifest says what a + film takes a few seconds and about 250 MB of memory, sixteen of them made + at once about 1.1 GB. The manifest says what a test can check: `duration_ms`, `frame_count`, `frame_rate`, `keyframe_count`, `change_count`, `change_interval_ms`, `width`, `height`, `compression: av1` and `audio: false`. A length that does not end on a diff --git a/internal/format/video/ahead.go b/internal/format/video/ahead.go index bbc477c8..c9f5e65f 100644 --- a/internal/format/video/ahead.go +++ b/internal/format/video/ahead.go @@ -124,7 +124,7 @@ type crew struct { // 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()} + c := &crew{film: f, keys: ks, qindex: qindex, own: f.painter(ks)} if most := min(int64(runtime.GOMAXPROCS(0)-1), jobs-1); most > 0 { c.most = int(most) c.queue = make(chan *job, c.ahead()*len(ks.tiles)) @@ -169,7 +169,7 @@ func (c *crew) offer(j *job) { func (c *crew) help() { defer c.wg.Done() defer helping.Add(-1) - p := c.film.painter() + p := c.film.painter(c.keys) for j := range c.queue { if c.stopped.Load() { continue @@ -192,8 +192,8 @@ func (c *crew) code(p *painter, j *job, beside bool) { j.coded, j.err = c.encode(j.change, beside) return } - r := c.keys.tiles[j.tile].rect - j.coded, j.err = encodeTile(p.drawIn(j.look, r), r, c.qindex) + src, at := p.source(j.look, c.keys.tiles[j.tile].rect) + j.coded, j.err = encodeTile(src, at, c.qindex) } // wait is the tile of j, coded here when no helper has taken it. diff --git a/internal/format/video/grid.go b/internal/format/video/grid.go index dc1c3007..f859f017 100644 --- a/internal/format/video/grid.go +++ b/internal/format/video/grid.go @@ -115,11 +115,8 @@ func cutEach(sizes []int, most int) []int { for _, s := range sizes { n := (s + most - 1) / most for k := range n { - part := s / n - if k >= n-s%n { - part++ - } - out = append(out, part) + // (k+s%n)/n is one for the last s%n parts and nought before them. + out = append(out, s/n+(k+s%n)/n) } } return out diff --git a/internal/format/video/picture.go b/internal/format/video/picture.go index 29dc7517..e2eded53 100644 --- a/internal/format/video/picture.go +++ b/internal/format/video/picture.go @@ -34,19 +34,27 @@ var ink = color.RGBA{R: 240, G: 240, B: 240, A: 255} // Picture draws picture c of a film: the gradient every image format here // draws, moved by the seed, with the label burned into the top of it when // there is room, the clock under it reading when the picture starts, and the -// square at its step. It is drawn the way a film draws it, and a film draws -// its pictures through painters of its own, so this is for the probes and -// guards that measure any one of them. +// square at its step. It is put together the way a film codes it, tile by +// tile, each from where the film takes it (painter.source), so this is for +// the probes and guards that measure any one of them. func Picture(width, height int, seed uint64, label string, t Timeline, c int64) Planes { f := newFilm(width, height, seed, label, t) - return f.painter().draw(f.lookOf(c)) + ks := newKeyer(f.geometry, gridFor(f.geometry, t.FPS)) + p, l := f.painter(ks), f.lookOf(c) + out, _ := newPlanes(width, height) + for _, tile := range ks.tiles { + src, at := p.source(l, tile.rect) + copyRect(out, tile.rect.Min, src, at) + } + return out } // WholePicture is picture c painted whole - the gradient copied, the clock and // the square drawn on the copy, and every pixel of it converted - which is how // every picture of a film was painted until 2026-10-06. A film now paints only -// the rows that can change (painter.draw), and a guard holds the two to the -// same planes, because the bytes of every film depend on them being the same. +// the tiles that can change, and only their rows that can (painter.source), and +// a guard holds the two to the same planes, because the bytes of every film +// depend on them being the same. func WholePicture(width, height int, seed uint64, label string, t Timeline, c int64) Planes { f := newFilm(width, height, seed, label, t) return f.whole(f.lookOf(c)) @@ -133,23 +141,100 @@ func (g geometry) clockRight() int { } // 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). +// read after: the gradient with the label burned in, in planes, the same +// gradient in pixels along the rows a picture can change in, 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). +// +// The gradient is kept in pixels along the strips and nowhere else, and a +// painter has room for one tile and nothing else, because the whole picture +// in each was what a film's memory was: at 7680x4320, 133 MB of pixels the +// painters read a sixth of, and 50 MB of planes copied to every one of +// fifteen helpers - 1.3 GB at the peak and a live heap of 1028 MB, against +// 236 MB on one thread. Without them the same film peaks at 187 to 241 MB, and +// sixteen such films at once at 1.1 GB where they took 3.8 (2026-10-07, three +// rounds each, docs/WEBM-LIMIT-2026-10-07.md section 11). type film struct { geometry t Timeline - base *image.RGBA + seed uint64 + label string basePlanes Planes - planesBuf []uint8 // what basePlanes lie over, copied whole by a painter 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 // rows, because one chroma sample covers two, and merged where they meet. - strips []image.Rectangle + // baseStrips are the gradient and the label along each of them, which a + // painter starts every picture's strip from. + strips []image.Rectangle + baseStrips []image.RGBA } func newFilm(width, height int, seed uint64, label string, t Timeline) *film { + f := &film{geometry: geometryOf(width, height, label, t), t: t, seed: seed, label: label, square: image.NewUniform(ink)} + f.strips = changingRows(width, height, [][2]int{{f.clockFrom, f.clockEnd}, {f.squareY, f.squareY + f.side}}) + f.baseStrips = stripsOver(f.strips, width) + g := newGround(width, height, seed, label) + for i := range f.baseStrips { + g.fill(&f.baseStrips[i]) + } + f.basePlanes, _ = newPlanes(width, height) + g.fillPlanes(f.basePlanes) + return f +} + +// ground makes the picture every picture of a film starts from - the +// gradient, moved by the seed, with the label burned into the top - a band of +// rows at a time, so the film never holds it whole: at 7680x4320 that whole +// was 133 MB that every film of a run made at once held at its start. The +// label's band is the one part that is not a sum of the row and the column, +// and Draw paints it solid before the text, so it is drawn once on a band of +// its own and copied - the pixels Draw makes on the whole picture, which the +// reference painter (gradient, whole) still draws whole and a guard compares. +type ground struct { + off int + label *image.RGBA +} + +func newGround(width, height int, seed uint64, label string) ground { + g := ground{off: int(seed % 256), label: &image.RGBA{}} + if band := labelBand(width, label); band > 0 { + g.label = image.NewRGBA(image.Rect(0, 0, width, min(height, band))) + imagelabel.Draw(g.label, label) + } + return g +} + +// fill paints the ground's rows into dst, which is the width of the picture. +func (g ground) fill(dst *image.RGBA) { + for y := dst.Rect.Min.Y; y < dst.Rect.Max.Y; y++ { + row := dst.Pix[(y-dst.Rect.Min.Y)*dst.Stride:][:dst.Stride] + if y < g.label.Rect.Max.Y { + copy(row, g.label.Pix[y*g.label.Stride:]) + continue + } + for x := range dst.Rect.Dx() { + row[4*x], row[4*x+1], row[4*x+2], row[4*x+3] = uint8((x+g.off)%256), uint8((y+g.off)%256), uint8((x+y+g.off)%256), 255 + } + } +} + +// fillPlanes converts the whole ground into p, sixty four rows at a time - an +// even number, so no chroma sample straddles two bands. +func (g ground) fillPlanes(p Planes) { + stride := 4 * p.Width + pix := make([]uint8, min(p.Height, superblock)*stride) + for y := 0; y < p.Height; y += superblock { + r := image.Rect(0, y, p.Width, min(p.Height, y+superblock)) + band := &image.RGBA{Pix: pix[:r.Dy()*stride], Stride: stride, Rect: r} + g.fill(band) + convertRect(band, p, image.Point{}, r) + } +} + +// gradient is the ground drawn whole, the way every picture of a film was +// until 2026-10-07, for the reference painter only (whole). +func gradient(width, height int, seed uint64, label string) *image.RGBA { off := int(seed % 256) base := image.NewRGBA(image.Rect(0, 0, width, height)) for y := range height { @@ -161,21 +246,49 @@ func newFilm(width, height int, seed uint64, label string, t Timeline) *film { if Labelled(width, label) { imagelabel.Draw(base, label) } - 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.strips = changingRows(width, height, [][2]int{{f.clockFrom, f.clockEnd}, {f.squareY, f.squareY + f.side}}) - return f + return base +} + +// stripsOver is a picture's strips in pixels, the width of the picture, all +// of them over one allocation. +func stripsOver(strips []image.Rectangle, width int) []image.RGBA { + stride := 4 * width + rows := 0 + for _, r := range strips { + rows += r.Dy() + } + pix := make([]uint8, rows*stride) + out := make([]image.RGBA, len(strips)) + for i, r := range strips { + n := r.Dy() * stride + out[i] = image.RGBA{Pix: pix[:n:n], Stride: stride, Rect: r} + pix = pix[n:] + } + return out +} + +// changes says whether a picture can differ from the gradient anywhere in r. +func (f *film) changes(r image.Rectangle) bool { + for _, s := range f.strips { + if s.Overlaps(r) { + return true + } + } + return false } // newPlanes is the three planes of a picture this size in one allocation, -// and that allocation, which a painter copies whole (film.painter). +// and that allocation. func newPlanes(width, height int) (Planes, []uint8) { - cw, ch := (width+1)/2, (height+1)/2 - buf := make([]uint8, width*height+2*cw*ch) + buf := make([]uint8, planesSize(width, height)) return planesOver(buf, width, height), buf } +// planesSize is the bytes of the three planes of a picture this size. +func planesSize(width, height int) int { + return width*height + 2*((width+1)/2)*((height+1)/2) +} + // planesOver lays the three planes out over buf, luma first. Each is capped // at its own length, so no plane can grow into the next. func planesOver(buf []uint8, width, height int) Planes { @@ -229,87 +342,102 @@ func lookAt(g geometry, t Timeline, c int64) look { } // whole paints a picture the long way, into planes of its own: the whole -// gradient copied, the clock and the square drawn on the copy, every pixel -// converted. It is the reference painter.draw is held to (WholePicture). +// gradient drawn again, the clock and the square drawn on it, every pixel +// converted. It is the reference painter.source is held to (WholePicture). func (f *film) whole(l look) Planes { - work := image.NewRGBA(f.base.Rect) - copy(work.Pix, f.base.Pix) + work := gradient(f.width, f.height, f.seed, f.label) if f.showClock() { b := work.Rect imagelabel.Draw(work.SubImage(image.Rect(0, f.clockFrom, b.Dx(), b.Dy())).(*image.RGBA), l.clock) } draw.Draw(work, image.Rect(l.x, f.squareY, l.x+f.side, f.squareY+f.side), f.square, image.Point{}, draw.Src) - planes, _ := newPlanes(f.base.Rect.Dx(), f.base.Rect.Dy()) + planes, _ := newPlanes(f.width, f.height) toPlanes(work, planes) return planes } -// painter draws pictures of one film into planes of its own. They start as -// the gradient's, and every row outside the film's strips stays the -// gradient's in every picture, so a picture repaints and converts the strips -// and nothing else - at 1920x1080 the clock's band and the square's are about -// a sixth of the rows. One painter is used by one goroutine at a time. +// painter draws the tiles of one film's pictures that can change into planes +// of its own, one tile at a time. Every row outside the film's strips stays +// the gradient's in every picture, so a tile is the film's planes copied and +// its strips repainted and converted over them - at 1920x1080 the clock's band +// and the square's are about a sixth of the rows. One painter is used by one +// goroutine at a time. type painter struct { f *film - planes Planes + tile []uint8 // the planes of the largest tile that can change strips []image.RGBA } -// painter is a painter of this film, in four allocations whatever the size: -// itself, its planes, its strips, and the pixels of all of them. Every helper -// coding a film makes one, and the allocations a file may cost are counted -// (the AllocCeiling of the formats built on this). -func (f *film) painter() *painter { - w := f.base.Rect.Dx() - stride := 4 * w - rows := 0 - for _, r := range f.strips { - rows += r.Dy() - } - pix := make([]uint8, rows*stride) - p := &painter{f: f, planes: planesOver(slices.Clone(f.planesBuf), w, f.base.Rect.Dy()), strips: make([]image.RGBA, len(f.strips))} - for i, r := range f.strips { - n := r.Dy() * stride - p.strips[i] = image.RGBA{Pix: pix[:n:n], Stride: stride, Rect: r} - pix = pix[n:] +// painter is a painter of this film's tiles, in four allocations whatever the +// size: itself, its tile, its strips, and the pixels of all of them. Every +// helper coding a film makes one, and the allocations a file may cost are +// counted (the AllocCeiling of the formats built on this). +func (f *film) painter(ks keyer) *painter { + most := 0 + for _, t := range ks.tiles { + if f.changes(t.rect) { + most = max(most, planesSize(t.rect.Dx(), t.rect.Dy())) + } } - return p + return &painter{f: f, tile: make([]uint8, most), strips: stripsOver(f.strips, f.width)} } -// 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) } +// source is the planes tile r of the picture with look l is coded from, and +// where r lies in them: the film's own planes for a tile no picture changes, +// read by every goroutine coding the film and written by none, and for a tile +// that can change, the painter's, overwritten by its next call. +func (p *painter) source(l look, r image.Rectangle) (Planes, image.Rectangle) { + if !p.f.changes(r) { + return p.f.basePlanes, r + } + return p.drawIn(l, r), image.Rectangle{Max: r.Size()} +} -// 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. +// drawIn paints tile r of the picture with this look into the painter's planes +// of the tile's size: the film's planes there copied, and each strip crossing +// r 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. +// r starts on an even row and an even column, as every tile does. // -// 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. +// The strip's 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() + out := planesOver(p.tile, r.Dx(), r.Dy()) + copyRect(out, image.Point{}, f.basePlanes, r) for i := range p.strips { - s := &p.strips[i] + s, base := &p.strips[i], &f.baseStrips[i] 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]) + from, to := (in.Min.Y-s.Rect.Min.Y)*s.Stride, (in.Max.Y-s.Rect.Min.Y)*s.Stride + copy(s.Pix[from:to], base.Pix[from:to]) 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) + imagelabel.Draw(s.SubImage(image.Rect(0, f.clockFrom, f.width, 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) - convertRect(s, p.planes, in) + convertRect(s, out, r.Min, in) + } + return out +} + +// copyRect copies the part r of src into dst with its top left at at. Both +// start on an even row and column, so the chroma samples r covers are whole +// samples of both. +func copyRect(dst Planes, at image.Point, src Planes, r image.Rectangle) { + for y := r.Min.Y; y < r.Max.Y; y++ { + copy(dst.Y[(at.Y+y-r.Min.Y)*dst.Width+at.X:][:r.Dx()], src.Y[y*src.Width+r.Min.X:]) + } + scw, dcw, cw := (src.Width+1)/2, (dst.Width+1)/2, (r.Dx()+1)/2 + for cy := r.Min.Y / 2; cy < (r.Max.Y+1)/2; cy++ { + d, s := (at.Y/2+cy-r.Min.Y/2)*dcw+at.X/2, cy*scw+r.Min.X/2 + copy(dst.U[d:][:cw], src.U[s:]) + copy(dst.V[d:][:cw], src.V[s:]) } - return p.planes } // toPlanes converts to BT.709 studio range in whole numbers. @@ -327,26 +455,30 @@ func (p *painter) drawIn(l look, r image.Rectangle) 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) { convertRect(img, p, img.Rect) } - -// 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 +func toPlanes(img *image.RGBA, p Planes) { convertRect(img, p, image.Point{}, img.Rect) } + +// convertRect converts the pixels inside r of a picture, read from img - the +// whole picture, or a strip of it whose rows hold r's - into p, the planes of +// the part of the picture whose top left is at: the whole of it, or one tile. +// 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. p ends +// where the picture does or on a multiple of 64, so a two by two block cut at +// p's edge is cut at the picture's. +func convertRect(img *image.RGBA, p Planes, at image.Point, r image.Rectangle) { + w := p.Width top := img.Rect.Min.Y for y := r.Min.Y; y < r.Max.Y; y++ { row := img.Pix[(y-top)*img.Stride:] 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) + p.Y[(y-at.Y)*w+x-at.X] = uint8(16 + (47*red+157*g+16*b+128)>>8) } } cw := (w + 1) / 2 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) + i := (cy-at.Y/2)*cw + cx - at.X/2 + p.U[i], p.V[i] = chroma(img, 2*cx, 2*cy, at.X+w, at.Y+p.Height) } } } diff --git a/internal/format/video/pictures.go b/internal/format/video/pictures.go index 36fc8980..85323e95 100644 --- a/internal/format/video/pictures.go +++ b/internal/format/video/pictures.go @@ -226,10 +226,11 @@ func (p *Pictures) CopySample() []byte { return p.copied } 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) + p, l := f.painter(ks), f.lookOf(change) size := 0 for _, tile := range ks.tiles { - coded, err := encodeTile(p.drawIn(l, tile.rect), tile.rect, c.QIndex) + src, at := p.source(l, tile.rect) + coded, err := encodeTile(src, at, c.QIndex) if err != nil { return 0, err } diff --git a/internal/format/video/settings.go b/internal/format/video/settings.go index 2d134933..37a441a5 100644 --- a/internal/format/video/settings.go +++ b/internal/format/video/settings.go @@ -106,6 +106,12 @@ func Properties() []format.Property { // the largest picture an AV1 level describes. In pixels rather than // megapixels, because 35 651 584 read as "36 megapixels" would be a limit // nobody can aim at. +// +// Memory is not what bounds it, and AVIF's bound of that kind is not here by +// choice: a film keeps its picture as planes, a byte and a half a pixel, and +// its painters one tile each (picture.go), so a 7680x4320 film peaked at 187 +// to 241 MB on sixteen threads, and sixteen of them written at once at 1.1 GB +// (measured 2026-10-07). func JointLimits() []format.JointLimit { return []format.JointLimit{{ Of: imagedim.SettingWidth, By: imagedim.SettingHeight, Max: maxPixels, diff --git a/internal/format/video/still.go b/internal/format/video/still.go index 86353b22..2b92dd89 100644 --- a/internal/format/video/still.go +++ b/internal/format/video/still.go @@ -56,12 +56,13 @@ func (t tileCoded) writeRest(w *bitWriter) { } } -// 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. +// encodeTile codes the part r of the planes p as a picture of its own - p the +// film's planes and r a tile no picture changes, or a painter's planes of one +// tile and r all of them (painter.source). 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 rather than copied out. 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