From 124bb1c00cc7e3ed296ae15ef8ce2e6b5edb7eb3 Mon Sep 17 00:00:00 2001 From: DonislawDev Date: Tue, 6 Oct 2026 19:27:00 +0200 Subject: [PATCH 1/4] format: webm films move - a new picture every change_interval, with a clock and a stepping square A film used to show one picture for its whole length, which a viewer saw as a photograph. Every change_interval (one second by default) the clock under the label moves on and a square takes a step, each change coded on its own. The WebM Void moved before the Cues, which are last, so the file is written while its pictures are coded one at a time. Every picture is held to a reserve: the rung's ceiling, measured again with the clock and the square, or for a named size the largest of a sample of ten coded at planning. The AV1 level counts every picture a second carries rather than two. Co-Authored-By: Claude Opus 5.5 --- internal/format/imagelabel/imagelabel.go | 12 ++ internal/format/video/choose.go | 144 ++++++++++++------ internal/format/video/doc.go | 23 +-- internal/format/video/level.go | 23 +-- internal/format/video/picture.go | 150 +++++++++++++++---- internal/format/video/settings.go | 49 +++++- internal/format/video/stream.go | 171 ++++++++++++++------- internal/format/video/timeline.go | 57 +++++-- internal/format/webm/ebml.go | 3 + internal/format/webm/layout.go | 169 ++++++++++++--------- internal/format/webm/webm.go | 183 ++++++++++++++--------- internal/oracle/oracle.go | 46 ++++++ internal/oracle/strict.py | 12 +- 13 files changed, 734 insertions(+), 308 deletions(-) diff --git a/internal/format/imagelabel/imagelabel.go b/internal/format/imagelabel/imagelabel.go index 22ac6775..41e6ae08 100644 --- a/internal/format/imagelabel/imagelabel.go +++ b/internal/format/imagelabel/imagelabel.go @@ -91,3 +91,15 @@ func drawGlyph(img draw.Image, g string, x, y, scale int, ink color.Color) { func Fits(width, chars int) bool { return scaleFor(width, chars) > 0 } + +// 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 +// draws, so planning can say whether that line will be there. +func BandHeight(width, chars int) int { + s := scaleFor(width, chars) + if s == 0 { + return 0 + } + return glyphHeight*s + 2*pad +} diff --git a/internal/format/video/choose.go b/internal/format/video/choose.go index b24a5c0d..f702d2e6 100644 --- a/internal/format/video/choose.go +++ b/internal/format/video/choose.go @@ -22,25 +22,28 @@ 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 the rung's picture coded to in two -// passes - every seed offset there is with every length the label can have, -// either format's name in it, and the label absent, then 512 seeds far from -// zero, because the label's text moves with the whole seed and not only with -// the offset. Measured by tools/probes/videoladder at the default quality on -// 2026-10-06 (docs/WIDEO-2026-10-06.md section 13). +// 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 +// 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 +// longest times a day shows. Then 512 seeds far from zero, because the +// label's text moves with the whole seed and not only with the offset. // -// The tenth is not caution for its own sake. On four rungs of eleven the far -// seeds coded larger than the largest of the first pass, by up to 3.7 percent -// (256x144: 4185 B, then 4338 B), so the first pass alone would have been a -// ceiling a seed nobody tried could pass. A tenth is close to three times the -// worst of that. A ceiling too high costs a smaller picture than there was +// The tenth is not caution for its own sake. On 640x360 the far seeds coded +// larger than the first pass by 1.9 percent (15194 B, then 15478 B), and +// before the pictures moved, on four rungs of eleven, by up to 3.7 percent +// (section 13), so a first pass alone would have been a ceiling a seed nobody +// tried could pass. A ceiling too high costs a smaller picture than there was // room for and nothing else. One too low would let planning promise a size -// writing cannot keep, so writing checks and says so rather than trusting -// this table. +// writing cannot keep, so writing checks every picture and says so rather +// than trusting this table. var Ladder = []Rung{ - {640, 360, 14644}, {426, 240, 8500}, {320, 180, 6192}, {256, 144, 4772}, - {160, 90, 2953}, {80, 45, 385}, {40, 23, 218}, {16, 9, 83}, - {4, 3, 38}, {2, 2, 21}, {1, 1, 6}, + {640, 360, 17026}, {426, 240, 10905}, {320, 180, 8191}, {256, 144, 6754}, + {160, 90, 3951}, {80, 45, 1454}, {40, 23, 825}, {16, 9, 149}, + {4, 3, 46}, {2, 2, 31}, {1, 1, 6}, } // Choice is the picture a film shows. @@ -49,12 +52,12 @@ type Choice struct { Seed uint64 Label string QIndex int - // Coded is the 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. - Coded *Coded - // Ceiling is the largest tile the plan allowed for, which writing holds - // the coded picture to. + // 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 + // Ceiling is the most tile bytes the plan allowed each picture, which + // writing holds every picture to. Ceiling int // Named is whether the request gave the picture's size. A picture chosen // to fit is the largest that does, so only a named one can be asked to be @@ -65,20 +68,67 @@ type Choice struct { // Labelled is whether this picture carries its label. func (c Choice) Labelled() bool { return Labelled(c.Width, c.Label) } -// Code is the picture's coded form, coding it when planning did not. -func (c Choice) Code() (Coded, error) { - if c.Coded != nil { - return *c.Coded, nil +// 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. +// +// 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 +// it: the largest of a film came out up to 42 percent above its first (48x32) +// and 9 percent above the largest of its first ten (52x20), because the +// clock's other digits change later. Against the spread sample the largest of +// whole films of 240 to 3600 pictures, from 16x16 to 640x360, five seeds and +// both shapes of the clock, came out at most 4.0 percent above (48x32) - +// tools/probes/videomotion/spread, 2026-10-06, docs/WIDEO-2026-10-06.md +// section 15. A tenth is two and a half times that, the margin the ladder's +// ceilings keep over their own measurement. The gradient does not move between +// pictures, by the owner's decision of the same day, because moving it changed +// a picture's size by up to 1.9 times. +func sampleReserve(w, h int, seed uint64, label string, t Timeline, qindex int) (Coded, int, error) { + p := newPainter(w, h, seed, label, t) + sample := SampleChanges(t) + var first Coded + largest := 0 + for _, c := range sample { + coded, err := Encode(p.paint(c), qindex) + if err != nil { + return Coded{}, 0, err + } + if c == 0 { + first = coded + } + largest = max(largest, coded.Size()) } - coded, err := Encode(Picture(c.Width, c.Height, c.Seed, c.Label), c.QIndex) - if err != nil { - return Coded{}, err + if int64(len(sample)) == t.Changes() { + return first, largest, nil + } + return first, largest + (largest+9)/10, nil +} + +// SampleChanges is the pictures planning codes to settle the reserve of a film +// whose picture it cannot take from the ladder: every one of a film of ten +// pictures or fewer, and of a longer film ten, one at each step of the square, +// in walks spread from its start to its end, so the sample sees the square +// everywhere and the clock with the digits of the whole film. +func SampleChanges(t Timeline) []int64 { + n := t.Changes() + if n <= squareSteps { + out := make([]int64, n) + for c := range out { + out[c] = int64(c) + } + return out } - if coded.Size() > c.Ceiling { - return Coded{}, core.Defect(fmt.Errorf("video: a %dx%d picture coded to a %d B tile and its rung allows %d B, so the file planned around it cannot be kept", - c.Width, c.Height, coded.Size(), c.Ceiling)) + walks := n / squareSteps + out := make([]int64, squareSteps) + for k := range out { + out[k] = int64(k)*(walks-1)/(squareSteps-1)*squareSteps + int64(k) } - return coded, nil + return out } // Choose settles the picture for a request, and the stream planning sizes the @@ -109,25 +159,27 @@ func Choose(formatID string, r format.Request, s Settings, label string, fits fu } // onRung is the picture of one rung and the stream planning sizes it by: the -// rung's bound, or - when the request named a quality the ceilings were not -// measured at - the picture itself, coded. That is the slow road AVIF takes -// for the same reason, and only runs that asked for it pay. +// rung's ceiling for every picture, or - when the request named a quality the +// ceilings were not measured at - a reserve from a sample of the film's +// pictures, coded (sampleReserve). That is the slow road AVIF takes for the +// same reason, and only runs that asked for it pay. 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, Bound(s.Timeline, rung.Ceiling, rung.Width, rung.Height), nil + return c, NewStream(s.Timeline, rung.Ceiling, rung.Width, rung.Height), nil } - coded, err := Encode(Picture(c.Width, c.Height, c.Seed, c.Label), c.QIndex) + first, reserve, err := sampleReserve(c.Width, c.Height, c.Seed, c.Label, s.Timeline, c.QIndex) if err != nil { return Choice{}, Stream{}, err } - c.Coded, c.Ceiling = &coded, coded.Size() - return c, NewStream(s.Timeline, coded, c.Width, c.Height), nil + c.First, c.Ceiling = &first, reserve + return c, NewStream(s.Timeline, c.Ceiling, c.Width, c.Height), nil } -// named codes a picture whose size the request gave, at planning, because -// nothing but coding it says how big it is. +// named settles a film whose picture size the request gave by coding a sample +// of its pictures at planning, because nothing but coding them says how big +// they are (sampleReserve). func named(formatID string, r format.Request, s Settings, c Choice) (Choice, Stream, error) { w, err := imagedim.Value(formatID, imagedim.SettingWidth, r.Properties, maxWidth, Ladder[0].Width) if err != nil { @@ -143,12 +195,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 } - coded, err := Encode(Picture(w, h, c.Seed, c.Label), c.QIndex) + first, 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.Coded, c.Ceiling, c.Named = w, h, &coded, coded.Size(), true - return c, NewStream(s.Timeline, coded, w, h), nil + 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 } // checkJointLimits asks the registry's own declaration, so the refusal, the @@ -205,6 +257,8 @@ func Facts(c Choice, s Settings) map[string]any { "frame_rate": s.FPS, "keyframe_count": s.Keys(), "keyframe_interval_ms": s.KeyEvery * 1000 / int64(s.FPS), + "change_count": s.Changes(), + "change_interval_ms": s.ChangeMs(), "quality": s.Quality, "compression": "av1", "audio": false, diff --git a/internal/format/video/doc.go b/internal/format/video/doc.go index 6f5b296e..95ea27e0 100644 --- a/internal/format/video/doc.go +++ b/internal/format/video/doc.go @@ -1,4 +1,4 @@ -// Package video is what the video formats share: the timeline, the picture, +// Package video is what the video formats share: the timeline, the pictures, // and the AV1 stream they carry. WebM and MP4 are thin containers around it, // the way docx, xlsx and pptx are thin around opc. // @@ -9,21 +9,26 @@ // a picture again without coding it again. The probe that settled this and // every measurement under it is docs/WIDEO-2026-10-06.md sections 9 to 11. // -// The stream has one shape, and the shape is the point: +// A film shows a new picture at every change - the clock in it moves on and a +// square takes a step, every change_interval (section 15) - and between +// changes it shows the same picture again. Each picture is coded on its own, +// because nothing in gav1d predicts one picture from another. The stream has +// one shape, and the shape is the point: // // a key frame, shown - where a player can start -// the same picture again, - an intra only frame, not shown, kept in a -// hidden and showable slot a later frame can show +// the picture as a hidden - an intra only frame, not shown, kept in a +// copy, then shown slot, and the frame that shows it: after +// each key frame and at each change // show_existing_frame - every other frame, three bytes each // // A shown key frame cannot be shown a second time - the specification sets its // showable_frame to 0 and libaom refuses the stream that tries ("Buffer does // not contain a showable frame"). gav1d's own decoder and Chromium both play // that stream without a word, which is why neither of them is a witness here. -// The hidden copy costs the picture's bytes once more and no second encoding, -// because it carries the very same tile. +// The hidden copy after a key frame costs the picture's bytes once more and no +// second encoding, because it carries the very same tile. // -// That shape is what makes a length independent of the size: an hour at thirty -// frames a second is about a megabyte, because 108 000 of its frames are three -// bytes each. +// That shape is what makes a length independent of the size: frames between +// changes are three bytes each, so the bytes go to the pictures, and a film +// that changes rarely or never is small whatever its length. package video diff --git a/internal/format/video/level.go b/internal/format/video/level.go index ac22c682..469e04f8 100644 --- a/internal/format/video/level.go +++ b/internal/format/video/level.go @@ -45,12 +45,15 @@ var levels = []level{ // to, or the maximum parameters level when none does. // // The shape matters to three of the constraints. Every frame is shown at the -// frame rate, so the display rate is width times height times fps. A coded -// picture travels twice within one frame time - the key frame and its hidden -// copy - so the one second buffer has to hold both. And each of those two -// frames has to meet the compression ratio, which is why the level is chosen -// after the picture is coded rather than from its size alone: a picture at -// quality 100 can be too big for the level its size would suggest. +// frame rate, so the display rate is width times height times fps. The one +// second buffer has to hold every coded picture one second of the film +// carries - perSecond of them, from Timeline.CodedPerSecond: a key frame and +// its hidden copy, and the picture each change opens with. Until 2026-10-06 +// this counted two whatever the film, which was short for a film whose key +// frames come more than once a second. And each coded frame has to meet the +// compression ratio, which is why the level is chosen from how big a picture +// may be rather than from its size alone: a picture at quality 100 can be too +// big for the level its size would suggest. // // A frame narrower or shorter than 16 conforms to no level - the specification // asks FrameWidth and FrameHeight to be at least 16 - so a small picture @@ -59,7 +62,7 @@ var levels = []level{ // Whole numbers throughout, because the answer is written into the sequence // header and so into the bytes (D11), and a float compared at a boundary is a // place for two machines to disagree. -func chooseLevel(width, height, fps, frameBytes int) int { +func chooseLevel(width, height, fps, frameBytes, perSecond int) int { if width < 16 || height < 16 { return levelMaximumParameters } @@ -72,7 +75,7 @@ func chooseLevel(width, height, fps, frameBytes int) int { if display > l.maxDisplayRate { continue } - if 2*8*int64(frameBytes) > l.mainKbps*1000 { + if int64(perSecond)*8*int64(frameBytes) > l.mainKbps*1000 { continue } if !compressedEnough(pic, display, int64(frameBytes), l) { @@ -85,8 +88,8 @@ func chooseLevel(width, height, fps, frameBytes int) int { // LevelFor is chooseLevel, exported for the guard that holds it to the // specification's own examples. -func LevelFor(width, height, fps, frameBytes int) int { - return chooseLevel(width, height, fps, frameBytes) +func LevelFor(width, height, fps, frameBytes, perSecond int) int { + return chooseLevel(width, height, fps, frameBytes, perSecond) } // compressedEnough is CompressedRatio >= MinPicCompressRatio, Annex A.3: diff --git a/internal/format/video/picture.go b/internal/format/video/picture.go index 96685c43..df3fba2e 100644 --- a/internal/format/video/picture.go +++ b/internal/format/video/picture.go @@ -1,8 +1,10 @@ package video import ( + "fmt" "image" "image/color" + "image/draw" "github.com/donislawdev/TestingFilesGenerator/internal/format/imagelabel" ) @@ -14,38 +16,135 @@ type Planes struct { Y, U, V []uint8 } -// Picture draws the one picture a film from this tool shows: the gradient -// every image format here draws, moved by the seed, with the label burned into -// the top of it when there is room. -// -// One picture for the whole film, by the owner's decision of 2026-10-06 -// (docs/WIDEO-2026-10-06.md section 12): it is coded once, so planning stays -// arithmetic and a run of ten thousand films costs ten thousand encodings -// rather than ten thousand times the number of key frames. -func Picture(width, height int, seed uint64, label string) Planes { +// The square that steps across the picture is the GIF animation's marker - +// an eighth of the width, never under a pixel, never over 128 - so the two +// formats that move move the same way. squareSteps is how many places it +// takes: at the default change a second it crosses the picture in ten +// seconds, once in a default film. +const ( + squareDivisor = 8 + maxSquareSide = 128 + squareSteps = 10 +) + +// ink is the colour the square is drawn in, the label's own. +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. A film draws its pictures through one painter, so this +// is for planning, which codes the first, and 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 { + return newPainter(width, height, seed, label, t).paint(c) +} + +// Labelled says whether a picture this wide carries a readable label - asked +// while planning, to report a film without one, and while drawing, so the +// manifest cannot claim a label the picture lacks. +func Labelled(width int, label string) bool { + return label != "" && imagelabel.Fits(width, len(label)) +} + +// ClockShown says whether a picture of this size shows the clock - under the +// label when the label is there, at the top when it is not, and only when the +// whole of it fits across and there is height left below the label for it. +func ClockShown(width, height int, label string, t Timeline) bool { + return imagelabel.Fits(width, len(Clock(0, t))) && labelBand(width, label) < height +} + +func labelBand(width int, label string) int { + if !Labelled(width, label) { + return 0 + } + return imagelabel.BandHeight(width, len(label)) +} + +// Clock is what the clock in picture c reads: when the picture starts, as a +// player's position shows it - hours, minutes and seconds, and milliseconds +// only when the changes do not fall on whole seconds, by the owner's decision +// of 2026-10-06 (docs/WIDEO-2026-10-06.md section 15). Every picture of a film +// reads the same number of characters, so a clock that fits the first fits +// them all. +func Clock(c int64, t Timeline) string { + ms := c * t.ChangeMs() + hms := fmt.Sprintf("%02d:%02d:%02d", ms/3_600_000, ms/60_000%60, ms/1000%60) + if t.ChangeMs()%1000 == 0 { + return hms + } + return fmt.Sprintf("%s.%03d", hms, ms%1000) +} + +// painter draws the pictures of one film. The gradient and the label are the +// same in every one of them, so they are drawn once, and each change copies +// them and adds only what moves - the clock and the square. +type painter struct { + t Timeline + base *image.RGBA + work *image.RGBA + planes Planes + clockFrom int // the row the clock's band starts at + showClock bool + side int + square *image.Uniform +} + +func newPainter(width, height int, seed uint64, label string, t Timeline) *painter { off := int(seed % 256) - img := image.NewRGBA(image.Rect(0, 0, width, height)) + base := image.NewRGBA(image.Rect(0, 0, width, height)) for y := range height { + row := base.Pix[y*base.Stride:] for x := range width { - img.SetRGBA(x, y, color.RGBA{ - R: uint8((x + off) % 256), - G: uint8((y + off) % 256), - B: uint8((x + y + off) % 256), - A: 255, - }) + row[4*x], row[4*x+1], row[4*x+2], row[4*x+3] = uint8((x+off)%256), uint8((y+off)%256), uint8((x+y+off)%256), 255 } } if Labelled(width, label) { - imagelabel.Draw(img, label) + imagelabel.Draw(base, label) + } + cw, ch := (width+1)/2, (height+1)/2 + return &painter{ + t: t, base: base, work: image.NewRGBA(base.Rect), + planes: Planes{Width: width, Height: height, Y: make([]uint8, width*height), U: make([]uint8, cw*ch), V: make([]uint8, cw*ch)}, + clockFrom: labelBand(width, label), + showClock: ClockShown(width, height, label, t), + side: min(max(width/squareDivisor, 1), maxSquareSide, height), + square: image.NewUniform(ink), } - return toPlanes(img) } -// Labelled says whether a picture this wide carries a readable label - asked -// while planning, to report a film without one, and while drawing, so the -// manifest cannot claim a label the picture lacks. -func Labelled(width int, label string) bool { - return label != "" && imagelabel.Fits(width, len(label)) +// look is what tells two pictures of one film apart: the clock's text when it +// is drawn, and where the square stands. Two changes with the same look are +// the same picture - a film too small for the clock whose square has nowhere +// to go - and the second one is not coded again. +type look struct { + clock string + x int +} + +func (p *painter) lookOf(c int64) look { + l := look{x: (p.base.Rect.Dx() - p.side) * int(c%squareSteps) / (squareSteps - 1)} + if p.showClock { + l.clock = Clock(c, p.t) + } + return l +} + +// paint draws picture c into the painter's own planes and returns them. They +// are overwritten by the next call. +func (p *painter) paint(c int64) Planes { return p.draw(p.lookOf(c)) } + +// draw is paint for a picture whose look is already worked out. +func (p *painter) draw(l look) Planes { + copy(p.work.Pix, p.base.Pix) + if p.showClock { + b := p.work.Rect + imagelabel.Draw(p.work.SubImage(image.Rect(0, p.clockFrom, b.Dx(), b.Dy())).(*image.RGBA), l.clock) + } + y := (p.base.Rect.Dy() - p.side) * 3 / 4 + draw.Draw(p.work, image.Rect(l.x, y, l.x+p.side, y+p.side), p.square, image.Point{}, draw.Src) + toPlanes(p.work, p.planes) + return p.planes } // toPlanes converts to BT.709 studio range in whole numbers. @@ -63,11 +162,9 @@ func Labelled(width int, label string) bool { // // 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) Planes { +func toPlanes(img *image.RGBA, p Planes) { w, h := img.Rect.Dx(), img.Rect.Dy() cw, ch := (w+1)/2, (h+1)/2 - p := Planes{Width: w, Height: h, Y: make([]uint8, w*h), U: make([]uint8, cw*ch), V: make([]uint8, cw*ch)} - for y := range h { row := img.Pix[y*img.Stride:] for x := range w { @@ -80,7 +177,6 @@ func toPlanes(img *image.RGBA) Planes { p.U[cy*cw+cx], p.V[cy*cw+cx] = chroma(img, 2*cx, 2*cy) } } - return p } // chroma is the Cb and Cr of the two by two block whose top left is x0, y0, diff --git a/internal/format/video/settings.go b/internal/format/video/settings.go index aba62e7f..976b44b2 100644 --- a/internal/format/video/settings.go +++ b/internal/format/video/settings.go @@ -13,18 +13,21 @@ import ( // and every video format declares them from here. const ( SettingDuration = "duration" + SettingChangeInterval = "change_interval" SettingFrameRate = "frame_rate" SettingKeyframeInterval = "keyframe_interval" SettingQuality = "quality" ) // The defaults are the owner's of 2026-10-06 (docs/WIDEO-2026-10-06.md -// section 12): ten seconds, and a key frame a minute, which leaves a short film -// with one key frame and an hour at about a megabyte and a half. +// sections 12 and 15): ten seconds, a key frame a minute, which leaves a short +// film with one key frame, and a new picture every second, so the clock in it +// ticks the way a clock does. const ( - defaultDurationMs = 10_000 - defaultKeyIntervalMs = 60_000 - defaultFPS = 30 + defaultDurationMs = 10_000 + defaultKeyIntervalMs = 60_000 + defaultChangeIntervalMs = 1_000 + defaultFPS = 30 // maxDurationMs is a day. A day at sixty frames a second is five million // frames, which a container writes in seconds and holds in no memory - @@ -77,6 +80,11 @@ func Properties() []format.Property { Min: 1, Max: maxDurationMs, Default: core.FormatDuration(defaultDurationMs), Detail: "How long the film plays. It has to end on a frame, so at 30 frames a second it goes in steps of 100ms.", }, + { + 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.", + }, { Name: SettingFrameRate, Kind: format.PropertyChoice, Choices: frameRates, Default: strconv.Itoa(defaultFPS), @@ -134,6 +142,10 @@ func Read(formatID string, props map[string]string) (Settings, error) { if err != nil { return Settings{}, err } + change, err := durationOr(props, SettingChangeInterval, defaultChangeIntervalMs) + if err != nil { + return Settings{}, err + } fps := defaultFPS if raw := props[SettingFrameRate]; raw != "" { if fps, err = strconv.Atoi(raw); err != nil { @@ -147,13 +159,16 @@ func Read(formatID string, props map[string]string) (Settings, error) { return Settings{}, core.Defect(fmt.Errorf("video: quality %q passed the registry and is not a number", raw)) } } - t, err := NewTimeline(formatID, duration, interval, fps) + t, err := NewTimeline(formatID, duration, interval, change, fps) if err != nil { return Settings{}, err } if t.Keys() > MaxKeyFrames { return Settings{}, tooManyKeyFrames(formatID, t, interval) } + if t.Changes() > MaxChanges { + return Settings{}, tooManyChanges(formatID, t, change) + } return Settings{ Timeline: t, Quality: quality, QIndex: (100 - quality) * 255 / 100, QualityNamed: named && raw != "" && quality != defaultQuality, @@ -183,6 +198,28 @@ 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. +const MaxChanges = 100_000 + +func tooManyChanges(formatID string, t Timeline, interval int64) error { + step := stepMs(t.FPS) + shortest := (t.DurationMs + MaxChanges - 1) / MaxChanges + shortest = (shortest + step - 1) / step * step + return &format.PropertyValueError{ + Format: formatID, Key: SettingChangeInterval, Value: core.FormatDuration(interval), + Reason: core.Says("video.TooManyChanges", + "a film of %s with a new picture every %s shows %d pictures, and a film can show at most %d. Ask for %s or longer, or a shorter film", + core.A("Duration", core.FormatDuration(t.DurationMs)), core.A("Interval", core.FormatDuration(interval)), + core.A("Pictures", t.Changes()), core.A("Most", MaxChanges), core.A("Shortest", core.FormatDuration(shortest))), + } +} + func durationOr(props map[string]string, key string, fallback int64) (int64, error) { raw := props[key] if raw == "" { diff --git a/internal/format/video/stream.go b/internal/format/video/stream.go index ccab1796..48f6a70f 100644 --- a/internal/format/video/stream.go +++ b/internal/format/video/stream.go @@ -1,57 +1,91 @@ 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. A bound stream is built with it, -// so no picture the reader accepts can carry a longer header than planning -// counted. +// 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 -// Stream is a film's AV1 samples, one for each frame, as both containers carry -// them: no temporal delimiters, every OBU with its size. +// 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. +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. 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 int config []byte - keyTU []byte - copyTU []byte - showTU []byte + seq []byte + show []byte } -// NewStream wraps a coded picture in the stream's three kinds of sample. +// NewStream is the stream of a film whose pictures each take at most reserve +// bytes of tile. +// +// 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. +// The reserve is an upper bound on every one of them, so a level that admits +// it admits the film. // // The sequence header travels in every key sample as well as in the // 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, c Coded, width, height int) Stream { - key := keyFrame(c) - return newStream(t, c, width, height, chooseLevel(width, height, t.FPS, len(key))) +func NewStream(t Timeline, reserve, width, height int) Stream { + key, _ := frameLens(maxSuffixBits, 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(), + } } -// Bound is a stream no smaller than any picture of this size whose tile is at -// most tileBytes can make, for planning without coding the picture. -// -// The level is the maximum parameters one, because a level above seven costs -// the sequence header a tier bit and the bound must not be the short one. -func Bound(t Timeline, tileBytes, width, height int) Stream { - c := Coded{suffix: make([]byte, (maxSuffixBits+7)/8), suffixBits: maxSuffixBits, tile: make([]byte, tileBytes)} - return newStream(t, c, width, height, levelMaximumParameters) +// 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) { + obuLen := func(headerBits int) int { + payload := (headerBits+7)/8 + tile + return 1 + len(leb128(payload)) + payload + } + return obuLen(keyHeaderBits + suffixBits), obuLen(hiddenHeaderBits + suffixBits) } -func newStream(t Timeline, c Coded, width, height, level int) Stream { - seq := obu(obuSequenceHeader, sequenceHeader(width, height, level)) - show := showCopy() - return Stream{ - Timeline: t, Width: width, Height: height, Level: level, - config: codecConfig(level, seq), - keyTU: joined(seq, keyFrame(c)), - copyTU: joined(hiddenCopy(c), show), - showTU: show, - } +// Config is the AV1 codec configuration record with the sequence header OBU, +// what MP4 puts in av1C and Matroska in CodecPrivate. +func (s Stream) Config() []byte { return s.config } + +// ShowSample is a frame that shows the current picture again. Shared between +// frames, and must not be written to. +func (s Stream) ShowSample() []byte { return s.show } + +// BoundBytes is the longest each of the three kinds of sample can be - a key +// frame, a hidden copy with the frame that shows it, and a frame that shows a +// 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) + return len(s.seq) + k, h + len(s.show), len(s.show) } // joined is a followed by b in one allocation of the right size. @@ -59,28 +93,65 @@ func joined(a, b []byte) []byte { return append(append(make([]byte, 0, len(a)+len(b)), a...), b...) } -// Config is the AV1 codec configuration record with the sequence header OBU, -// what MP4 puts in av1C and Matroska in CodecPrivate. -func (s Stream) Config() []byte { return s.config } +// Pictures codes the pictures of one film in order, one change at a time, and +// keeps only the last - an hour at a change a second is 3600 pictures, and +// holding them all would hold the film. +type Pictures struct { + choice Choice + stream Stream + painter *painter + change int64 + look look + key []byte + copied []byte +} -// Sample is frame i's bytes. The slices are shared between frames and must not -// be written to. -func (s Stream) Sample(i int64) []byte { - switch { - case s.IsKey(i): - return s.keyTU - case i%s.KeyEvery == 1: - return s.copyTU - default: - return s.showTU +// Pictures is the coder of this choice's film. +func (c Choice) Pictures(st Stream) *Pictures { + return &Pictures{choice: c, stream: st, change: -1, + painter: newPainter(c.Width, c.Height, c.Seed, c.Label, st.Timeline)} +} + +// At makes picture c the current one, coding it unless it looks the same as +// the one before. +// +// 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 + } + l := p.painter.lookOf(c) + if p.change >= 0 && l == p.look { + p.change = c + return nil } + var coded Coded + if c == 0 && p.choice.First != nil { + coded = *p.choice.First + } else { + var err error + if coded, err = Encode(p.painter.draw(l), p.choice.QIndex); 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, p.look = c, l + p.key = joined(p.stream.seq, keyFrame(coded)) + p.copied = joined(hiddenCopy(coded), p.stream.show) + return nil } -// SampleBytes is how long frame i's sample is. -func (s Stream) SampleBytes(i int64) int { return len(s.Sample(i)) } +// KeySample is the current picture as a key frame, with the sequence header +// before it. +func (p *Pictures) KeySample() []byte { return p.key } -// KindBytes is the length of each of the three kinds of sample - what a -// container's arithmetic needs instead of walking every frame. -func (s Stream) KindBytes() (key, copied, shown int) { - return len(s.keyTU), len(s.copyTU), len(s.showTU) -} +// 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/timeline.go b/internal/format/video/timeline.go index 904e690b..2c7c6f3a 100644 --- a/internal/format/video/timeline.go +++ b/internal/format/video/timeline.go @@ -5,8 +5,8 @@ import ( "github.com/donislawdev/TestingFilesGenerator/internal/format" ) -// Timeline is how many frames a film has, how far apart they are, and which -// of them are key frames. +// Timeline is how many frames a film has, how far apart they are, which of +// them are key frames and where the picture changes. type Timeline struct { FPS int Frames int64 @@ -14,18 +14,22 @@ type Timeline struct { // KeyEvery is how many frames apart key frames are. The first frame is // always one. KeyEvery int64 + // ChangeEvery is how many frames apart the picture changes. The first + // frame always opens a change, and an interval longer than the film + // leaves it one picture throughout. + ChangeEvery int64 } -// NewTimeline settles a length and a key frame interval against a frame rate. +// NewTimeline settles a length and the two intervals against a frame rate. // -// A length has to end on a frame, and so does the interval, or the film would +// A length has to end on a frame, and so does each interval, or the film would // last a different time from the one asked for - refused with the two nearest // lengths that do, by the owner's decision of 2026-10-06 // (docs/WIDEO-2026-10-06.md section 12). Lengths are whole milliseconds, so at // thirty frames a second they go in steps of 100ms, at twenty four in steps of // 125ms: the step is the shortest whole number of milliseconds that is a whole // number of frames. -func NewTimeline(formatID string, durationMs, keyIntervalMs int64, fps int) (Timeline, error) { +func NewTimeline(formatID string, durationMs, keyIntervalMs, changeIntervalMs int64, fps int) (Timeline, error) { frames, err := onFrames(formatID, SettingDuration, durationMs, fps) if err != nil { return Timeline{}, err @@ -34,7 +38,11 @@ func NewTimeline(formatID string, durationMs, keyIntervalMs int64, fps int) (Tim if err != nil { return Timeline{}, err } - return Timeline{FPS: fps, Frames: frames, DurationMs: durationMs, KeyEvery: keyEvery}, nil + changeEvery, err := onFrames(formatID, SettingChangeInterval, changeIntervalMs, fps) + if err != nil { + return Timeline{}, err + } + return Timeline{FPS: fps, Frames: frames, DurationMs: durationMs, KeyEvery: keyEvery, ChangeEvery: changeEvery}, nil } // onFrames is how many frames a length is, or the refusal naming the two @@ -78,18 +86,35 @@ func (t Timeline) IsKey(i int64) bool { return i%t.KeyEvery == 0 } // Keys is how many key frames the film has. func (t Timeline) Keys() int64 { return (t.Frames + t.KeyEvery - 1) / t.KeyEvery } -// Copies is how many hidden copies the film carries: one after each key frame -// that has a frame after it to show it in. A film whose every frame is a key -// frame needs none. -func (t Timeline) Copies() int64 { - if t.KeyEvery == 1 { - return 0 +// Changes is how many pictures the film shows. +func (t Timeline) Changes() int64 { return (t.Frames + t.ChangeEvery - 1) / t.ChangeEvery } + +// ChangeOf is which picture frame i shows. +func (t Timeline) ChangeOf(i int64) int64 { return i / t.ChangeEvery } + +// StartsChange is whether frame i is the first to show its picture. +func (t Timeline) StartsChange(i int64) bool { return i%t.ChangeEvery == 0 } + +// ChangeMs is how far apart the changes are in milliseconds, exact because +// the interval was settled on a frame. +func (t Timeline) ChangeMs() int64 { return t.ChangeEvery * 1000 / int64(t.FPS) } + +// CodedPerSecond is the most coded pictures any one second of the film +// carries: the key frames, the hidden copy after each, and the frame each +// change opens with. Where two of them fall on one frame they are counted +// twice, so the level this decides is never one the film can exceed - and +// no more than a second has frames, or the film has. +func (t Timeline) CodedPerSecond() int64 { + fps := int64(t.FPS) + keys := (fps + t.KeyEvery - 1) / t.KeyEvery + n := keys + if t.KeyEvery > 1 { + n += keys } - n := t.Keys() - if (t.Keys()-1)*t.KeyEvery+1 >= t.Frames { - n-- // the last key frame is the film's last frame + if t.ChangeEvery < t.Frames { + n += (fps + t.ChangeEvery - 1) / t.ChangeEvery } - return n + return min(n, fps, t.Frames) } // StartMs is when frame i is shown, in whole milliseconds rounded to the diff --git a/internal/format/webm/ebml.go b/internal/format/webm/ebml.go index 638dea2c..10f09e44 100644 --- a/internal/format/webm/ebml.go +++ b/internal/format/webm/ebml.go @@ -164,3 +164,6 @@ func elementLen(id uint32, content uint64) uint64 { func uintElementLen(id uint32, v uint64) uint64 { return elementLen(id, uint64(uintLen(v))) } + +// fixedUintElementLen is appendFixedUint's length without building it. +func fixedUintElementLen(id uint32) uint64 { return elementLen(id, 8) } diff --git a/internal/format/webm/layout.go b/internal/format/webm/layout.go index 4a7b1708..508e7547 100644 --- a/internal/format/webm/layout.go +++ b/internal/format/webm/layout.go @@ -24,97 +24,130 @@ const segmentSizeField = 8 // a byte at a time. const minVoid = 2 -// cluster is one cluster's place in the file: the frames it holds, when it -// starts, where it is, and how big its content is. -type cluster struct { - first, last int64 // frames [first, last) - ts int64 - position uint64 // from the start of the segment's content - content uint64 -} - -// layout is the whole file worked out without writing it. Planning and writing -// both build one from a stream - planning from the bound of a rung, writing -// from the coded picture - so the two cannot come to disagree about where -// anything is. +// layout is what a film's file is made of before its pictures are coded: the +// parts that do not depend on them, and the arithmetic of the clusters that do. +// +// A cluster's length is known only once its picture is coded, because the +// pictures of a film differ (docs/WIDEO-2026-10-06.md section 15), so the +// clusters' places are found while writing. What is fixed in advance is where +// the Cues go: last in the segment, at its end less their length - which does +// not move, because each cluster position in them is written in eight bytes. +// So the SeekHead points at them before anything after it is written, the +// clusters are written as their pictures are coded, and the Void between the +// last cluster and the Cues takes up whatever the pictures left. // -// The work is per cluster, not per frame. A day at sixty frames a second is -// five million frames and at most ninety thousand clusters, because the key -// frame interval is at least a second. +// A cluster opens at every key frame, at every change of picture and thirty +// seconds into a run of neither, so a cluster carries at most one picture and +// writing holds no more than that. The work is per cluster, not per frame: a +// day at sixty frames a second is five million frames, and the settings bound +// the clusters to a hundred thousand key frames, a hundred thousand changes and +// one every thirty seconds. type layout struct { - stream video.Stream - head []byte // the EBML header - seekHead []byte - info []byte - tracks []byte - clusters []cluster - cuesBody uint64 // what the Cues element holds - body uint64 // the segment's content without the Void + stream video.Stream + head []byte // the EBML header + info []byte + tracks []byte + seekHeadLen uint64 + cuesBody uint64 // what the Cues element holds } func newLayout(s video.Stream) layout { l := layout{stream: s, head: ebmlHeader(), info: info(s), tracks: tracks(s)} - l.seekHead = seekHead(0, 0, 0) - pos := uint64(len(l.seekHead) + len(l.info) + len(l.tracks)) - - var cues uint64 + l.seekHeadLen = uint64(len(seekHead(0, 0, 0))) for g := int64(0); g < s.Keys(); g++ { - pos, cues = l.addGroup(g, pos, cues) + l.cuesBody += cuePointLen(uint64(s.StartMs(g * s.KeyEvery))) } - l.cuesBody = cues - l.body = pos + elementLen(idCues, cues) - l.seekHead = seekHead(uint64(len(l.seekHead)), uint64(len(l.seekHead)+len(l.info)), pos) return l } -// addGroup lays out the clusters of one group of pictures - the frames from -// one key frame to the next - from pos on, and gives back where the next -// group starts and the Cues so far. The first cluster of a group opens with -// its key frame and gets the group's cue point. -func (l *layout) addGroup(g int64, pos, cues uint64) (uint64, uint64) { +// front is where the first cluster starts, counted from the start of the +// segment's content. +func (l layout) front() uint64 { return l.seekHeadLen + uint64(len(l.info)+len(l.tracks)) } + +// cuesLen is the whole Cues element. +func (l layout) cuesLen() uint64 { return elementLen(idCues, l.cuesBody) } + +// outside is the bytes of the file before the segment's content: the EBML +// header, the segment's ID and its size. +func (l layout) outside() int64 { + return int64(len(l.head)) + int64(idLen(idSegment)) + segmentSizeField +} + +// next is where the cluster that opens at frame first ends: at the next key +// frame, the next change of picture or thirty seconds on, whichever comes +// first, and never past the film. +func (l layout) next(first int64) int64 { s := l.stream - key, copied, shown := s.KindBytes() span := int64(s.FPS) * clusterSpanMs / 1000 - start, end := g*s.KeyEvery, min((g+1)*s.KeyEvery, s.Frames) - opening := len(l.clusters) - for first := start; first < end; first += span { - c := cluster{first: first, last: min(first+span, end), ts: s.StartMs(first), position: pos} - c.content = uintElementLen(idTimestamp, uint64(c.ts)) + blocks(c, s.KeyEvery, key, copied, shown) - pos += elementLen(idCluster, c.content) - l.clusters = append(l.clusters, c) + return min(s.Frames, (first/s.KeyEvery+1)*s.KeyEvery, (first/s.ChangeEvery+1)*s.ChangeEvery, first+span) +} + +// The three kinds of sample a frame carries. +const ( + keySample = iota // the picture as a key frame, the sequence header first + copySample // the picture as a hidden copy, and the frame showing it + showSample // the current picture shown again + kinds +) + +// kindAt is what frame i of the cluster opening at frame first carries. +// +// Only a cluster's first two frames can carry a picture. A key frame opens its +// cluster, and the frame after it carries the hidden copy, because a shown key +// frame cannot be shown again - unless that frame opens a change, which opens +// a cluster of its own. A change opens its cluster with the copy and the frame +// that shows it. Every other frame shows the current picture again. +func (l layout) kindAt(first, i int64) int { + s := l.stream + switch { + case i == first && s.IsKey(i): + return keySample + case i == first && s.StartsChange(i), i == first+1 && s.IsKey(first): + return copySample + } + return showSample +} + +// clusterContent is what the cluster of frames [first, last) holds, given how +// long each kind of sample is - counted from kindAt for the two frames that can +// carry a picture, so the length written before a cluster and the blocks +// written in it come from one answer. +func (l layout) clusterContent(first, last int64, lens [kinds]int) uint64 { + total := uintElementLen(idTimestamp, uint64(l.stream.StartMs(first))) + for i := first; i < min(first+2, last); i++ { + total += blockLen(lens[l.kindAt(first, i)]) + } + if rest := last - first - 2; rest > 0 { + total += uint64(rest) * blockLen(lens[showSample]) } - return pos, cues + cuePointLen(l.clusters[opening]) + return total } -// blocks is the bytes a cluster's SimpleBlocks take. A cluster that starts a -// group of pictures opens with the key sample and the hidden copy, and the -// rest of every cluster is frames that show the copy again. -func blocks(c cluster, keyEvery int64, key, copied, shown int) uint64 { - n := c.last - c.first - var total uint64 - if c.first%keyEvery == 0 { - total += blockLen(key) - n-- - if n > 0 && keyEvery > 1 { - total += blockLen(copied) - n-- - } +// boundBytes is the file without its Void when every picture takes its whole +// reserve - what planning promises, because no film of this stream comes to +// more. +func (l layout) boundBytes() int64 { + key, copied, shown := l.stream.BoundBytes() + lens := [kinds]int{keySample: key, copySample: copied, showSample: shown} + body := l.front() + l.cuesLen() + for first := int64(0); first < l.stream.Frames; { + last := l.next(first) + body += elementLen(idCluster, l.clusterContent(first, last, lens)) + first = last } - return total + uint64(n)*blockLen(shown) + return l.outside() + int64(body) } // blockLen is a SimpleBlock element carrying a sample: track number, a two // byte time offset and the flags, then the sample. func blockLen(sample int) uint64 { return elementLen(idSimpleBlock, uint64(4+sample)) } -func cuePointLen(c cluster) uint64 { - positions := uintElementLen(idCueTrack, 1) + uintElementLen(idCueClusterPosition, c.position) - return elementLen(idCuePoint, uintElementLen(idCueTime, uint64(c.ts))+elementLen(idCueTrackPositions, positions)) -} - -// fileBytes is the file without its Void. -func (l layout) fileBytes() int64 { - return int64(len(l.head)) + int64(idLen(idSegment)) + segmentSizeField + int64(l.body) +// cuePointLen is one cue point, with the cluster's position in eight bytes +// whatever it is - so the Cues are the same length before the clusters are +// written as after. +func cuePointLen(ts uint64) uint64 { + positions := uintElementLen(idCueTrack, 1) + fixedUintElementLen(idCueClusterPosition) + return elementLen(idCuePoint, uintElementLen(idCueTime, ts)+elementLen(idCueTrackPositions, positions)) } func ebmlHeader() []byte { diff --git a/internal/format/webm/webm.go b/internal/format/webm/webm.go index 129b1849..3d0456f7 100644 --- a/internal/format/webm/webm.go +++ b/internal/format/webm/webm.go @@ -1,19 +1,22 @@ // Package webm generates WebM films: AV1 in the WebM subset of Matroska, with // no sound. // -// The picture and the stream are internal/format/video's, shared with MP4. +// The pictures and the stream are internal/format/video's, shared with MP4. // What is here is the container: EBML written as a stream, a SeekHead, the one -// track, clusters, Cues, and a Void at the end that carries the padding. +// track, clusters, a Void that carries the padding, and the Cues last. // // The padding channel is a Void element, the one Matroska defines for bytes -// that mean nothing, after the Cues and inside the segment. The segment's size -// is always written in eight bytes, so the Void can grow a byte at a time and -// nothing before it moves. Measured on 2026-10-06 (docs/WIDEO-2026-10-06.md -// section 9.6) at a Void of 2, 3, 128, 129, 16 385, 16 386 and 1 048 576 -// bytes and with none: every file came out at the size asked for, libaom -// decoded every frame of every one, and Chromium played and seeked them all. -// A Void is always written, at least its two bytes, which leaves no size above -// the minimum that cannot be reached. +// that mean nothing, between the last cluster and the Cues. The segment's size +// is always written in eight bytes and the Cues are the same length whatever +// they point at, so the Void can grow a byte at a time and nothing before it +// moves. It sat after the Cues until the pictures began to change +// (docs/WIDEO-2026-10-06.md section 15): a cluster's length is known only +// once its picture is coded, and the SeekHead, written first, has to say where +// the Cues are - at the end, less their length, is the one place that does not +// depend on the pictures. The Void at the end was measured on 2026-10-06 +// (section 9.6) at 2, 3, 128, 129, 16 385, 16 386 and 1 048 576 bytes and with +// none. A Void is always written, at least its two bytes, which leaves no size +// above the minimum that cannot be reached. package webm import ( @@ -41,8 +44,18 @@ func init() { MinBytes: minimumBytes(), + // Every picture of a film is coded on its own, and gav1d allocates + // per picture - about forty objects each with the frames around it. + // The flat ceiling counts what a generator allocates per file, and a + // default film is ten pictures. Measured on 2026-10-06, lowest of the + // guard's rounds: 408 objects for the default film, and 698 with one + // object allocated per frame, the defect the ceiling exists for. The + // number below sits between the two, and like every ceiling here it + // goes down when work makes it lowerable, never up to turn a run green. + AllocCeiling: 512, + Padding: format.PaddingChannel{ - Name: "a Void element at the end of the file", + Name: "a Void element before the index at the end of the file", Where: format.PlacementEnd, Capacity: 0, }, @@ -56,16 +69,16 @@ func init() { } // minimumBytes is the smallest film at the default settings - the smallest -// rung's bound, ten seconds at thirty frames, one key frame - with its Void. -// Worked out from the same layout planning uses rather than written down, so -// the two cannot disagree. +// rung's bound, ten seconds at thirty frames, one key frame, a new picture +// every second - with its Void. Worked out from the same layout planning uses +// rather than written down, so the two cannot disagree. func minimumBytes() int64 { - t, err := video.NewTimeline(id, 10_000, 60_000, 30) + t, err := video.NewTimeline(id, 10_000, 60_000, 1_000, 30) if err != nil { panic(err) } smallest := video.Ladder[len(video.Ladder)-1] - return newLayout(video.Bound(t, smallest.Ceiling, smallest.Width, smallest.Height)).fileBytes() + minVoid + return newLayout(video.NewStream(t, smallest.Ceiling, smallest.Width, smallest.Height)).boundBytes() + minVoid } type generator struct{} @@ -85,12 +98,12 @@ func (generator) Plan(r format.Request) (format.Plan, error) { if err != nil { return format.Plan{}, err } - fits := func(st video.Stream) bool { return newLayout(st).fileBytes()+minVoid <= r.Bytes } + fits := func(st video.Stream) bool { return newLayout(st).boundBytes()+minVoid <= r.Bytes } c, st, err := video.Choose(id, r, s, label, fits) if err != nil { return format.Plan{}, err } - if need := newLayout(st).fileBytes() + minVoid; need > r.Bytes { + if need := newLayout(st).boundBytes() + minVoid; need > r.Bytes { return format.Plan{}, belowMinimum(r.Bytes, need, c, s) } @@ -107,6 +120,13 @@ func (generator) Plan(r format.Request) (format.Plan, error) { Detail: core.Says("format.ThePictureIsPxWideAnd", "The picture is %d px wide and the label needs more room, so this file carries no visible label. Its name and the manifest still identify it.", core.A("Width", c.Width)), }) } + if !c.ClockShown(s.Timeline) { + p.Notes = append(p.Notes, format.Note{ + Code: "clock_omitted", + Detail: core.Says("video.NoClock", "The picture is %dx%d and the clock needs more room, so this film shows no clock. The square still steps across it where it has room to move.", + core.A("Width", c.Width), core.A("Height", c.Height)), + }) + } return p, nil } @@ -117,11 +137,11 @@ func belowMinimum(requested, need int64, c video.Choice, s video.Settings) error Format: "WebM", Requested: requested, Minimum: need, - Reason: core.Says("webm.MinimumReason", - "a film of %s at %d frames a second with a key frame every %s and a %dx%d picture takes %d B, and the file always carries a Void element, which costs %d B even when it holds nothing", + Reason: core.Says("webm.MinimumReasonPictures", + "a film of %s at %d frames a second with a key frame every %s and a new %dx%d picture every %s takes %d B, and the file always carries a Void element, which costs %d B even when it holds nothing", core.A("Duration", core.FormatDuration(s.DurationMs)), core.A("FPS", s.FPS), core.A("Interval", core.FormatDuration(s.KeyEvery*1000/int64(s.FPS))), - core.A("Width", c.Width), core.A("Height", c.Height), + core.A("Width", c.Width), core.A("Height", c.Height), core.A("Change", core.FormatDuration(s.ChangeMs())), core.A("Bytes", need-minVoid), core.A("Void", minVoid)), Hint: hint(need, c), } @@ -132,12 +152,12 @@ func belowMinimum(requested, need int64, c video.Choice, s video.Settings) error // one would send somebody to a setting that changes nothing. func hint(need int64, c video.Choice) core.Said { if c.Named { - return core.Says("webm.AskForBOrMore", - "Ask for %d B or more, or a shorter film, fewer frames a second, key frames further apart or a smaller picture", + return core.Says("webm.AskForBOrMorePictures", + "Ask for %d B or more, or a shorter film, fewer frames a second, key frames further apart, a new picture less often or a smaller picture", core.A("Floor", need)) } - return core.Says("webm.AskForBOrMoreFilm", - "Ask for %d B or more, or a shorter film, fewer frames a second or key frames further apart", + return core.Says("webm.AskForBOrMoreFilmPictures", + "Ask for %d B or more, or a shorter film, fewer frames a second, key frames further apart or a new picture less often", core.A("Floor", need)) } @@ -151,33 +171,32 @@ func (generator) Write(ctx context.Context, w io.Writer, p format.Plan) error { return ctx.Err() default: } - coded, err := m.choice.Code() - if err != nil { - return err - } - st := video.NewStream(m.settings.Timeline, coded, m.choice.Width, m.choice.Height) + st := video.NewStream(m.settings.Timeline, m.choice.Ceiling, m.choice.Width, m.choice.Height) l := newLayout(st) - void := m.total - l.fileBytes() - if void < minVoid { - return core.Defect(fmt.Errorf("webm: the film came to %d B and the file was to be %d B, which leaves no room for the Void every one of these carries", - l.fileBytes(), m.total)) - } + body := uint64(m.total - l.outside()) + cuesAt := body - l.cuesLen() out := &sticky{w: w} out.write(l.head) out.write(idBytes(idSegment)) - out.write(sizeField(l.body+uint64(void), segmentSizeField)) - out.write(l.seekHead) + out.write(sizeField(body, segmentSizeField)) + out.write(seekHead(l.seekHeadLen, l.seekHeadLen+uint64(len(l.info)), cuesAt)) out.write(l.info) out.write(l.tracks) - if err := writeClusters(ctx, out, l); err != nil { + keys, end, err := writeClusters(ctx, out, l, m.choice.Pictures(st)) + if err != nil { return err } - writeCues(out, l) - if out.err != nil { - return out.err + void := int64(cuesAt) - int64(end) + if void < minVoid { + return core.Defect(fmt.Errorf("webm: the clusters came to %d B and the Cues are to start at %d B, which leaves no room for the Void every one of these carries", + end, cuesAt)) } - return writeVoid(ctx, w, void, m.choice.Seed) + if err := writeVoid(ctx, w, void, m.choice.Seed); err != nil { + return err + } + writeCues(out, l, keys) + return out.err } // sticky keeps the first error, so a long run of small writes checks once, @@ -205,59 +224,81 @@ func (s *sticky) uint(id uint32, v uint64) { s.write(appendUint(s.scratch[:0], id, v)) } -func writeClusters(ctx context.Context, out *sticky, l layout) error { - for _, c := range l.clusters { +// 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. +func writeClusters(ctx context.Context, out *sticky, l layout, pics *video.Pictures) ([]uint64, uint64, error) { + s := l.stream + keys := make([]uint64, 0, s.Keys()) + pos := l.front() + for first := int64(0); first < s.Frames; { if err := ctx.Err(); err != nil { - return err + return nil, 0, err } - writeCluster(out, l.stream, c) + // A cluster thirty seconds into a picture asks for the picture it + // already has, which At answers without coding anything. + if err := pics.At(s.ChangeOf(first)); err != nil { + return nil, 0, err + } + if s.IsKey(first) { + keys = append(keys, pos) + } + last := l.next(first) + pos += writeCluster(out, l, pics, first, last) if out.err != nil { - return out.err + return nil, 0, out.err } + first = last } - return nil + return keys, pos, nil } -// writeCluster writes one cluster: its time, then a SimpleBlock for each of -// its frames - track 1, the frame's time from the cluster's, and the key flag -// on a key frame. -func writeCluster(out *sticky, st video.Stream, c cluster) { - out.header(idCluster, c.content) - out.uint(idTimestamp, uint64(c.ts)) +// writeCluster writes the cluster of frames [first, last) and returns its +// length: its time, then a SimpleBlock for each frame - track 1, the frame's +// time from the cluster's, and the key flag on a key frame. Which sample each +// frame carries is the layout's kindAt, the same answer the cluster's length +// was counted from. +func writeCluster(out *sticky, l layout, pics *video.Pictures, first, last int64) uint64 { + s := l.stream + samples := [kinds][]byte{keySample: pics.KeySample(), copySample: pics.CopySample(), showSample: s.ShowSample()} + content := l.clusterContent(first, last, [kinds]int{len(samples[keySample]), len(samples[copySample]), len(samples[showSample])}) + ts := s.StartMs(first) + out.header(idCluster, content) + out.uint(idTimestamp, uint64(ts)) var block [4]byte block[0] = 0x81 - for i := c.first; i < c.last; i++ { - sample := st.Sample(i) - rel := st.StartMs(i) - c.ts + for i := first; i < last; i++ { + sample := samples[l.kindAt(first, i)] + rel := s.StartMs(i) - ts block[1], block[2], block[3] = byte(rel>>8), byte(rel), 0 - if st.IsKey(i) { + if s.IsKey(i) { block[3] = 0x80 } out.header(idSimpleBlock, uint64(4+len(sample))) out.write(block[:]) out.write(sample) } + return elementLen(idCluster, content) } -// writeCues points at every cluster that opens with a key frame, with the -// same arithmetic the layout counted them by. -func writeCues(out *sticky, l layout) { +// writeCues points at every cluster that opens with a key frame, each position +// in eight bytes, as the layout counted them. +func writeCues(out *sticky, l layout, keys []uint64) { + s := l.stream out.header(idCues, l.cuesBody) - for _, c := range l.clusters { - if !l.stream.IsKey(c.first) { - continue - } - positions := uintElementLen(idCueTrack, 1) + uintElementLen(idCueClusterPosition, c.position) - out.header(idCuePoint, uintElementLen(idCueTime, uint64(c.ts))+elementLen(idCueTrackPositions, positions)) - out.uint(idCueTime, uint64(c.ts)) + positions := uintElementLen(idCueTrack, 1) + fixedUintElementLen(idCueClusterPosition) + for g, pos := range keys { + ts := uint64(s.StartMs(int64(g) * s.KeyEvery)) + out.header(idCuePoint, uintElementLen(idCueTime, ts)+elementLen(idCueTrackPositions, positions)) + out.uint(idCueTime, ts) out.header(idCueTrackPositions, positions) out.uint(idCueTrack, 1) - out.uint(idCueClusterPosition, c.position) + out.write(appendFixedUint(out.scratch[:0], idCueClusterPosition, pos)) } } -// writeVoid fills the rest of the file with one Void element of exactly total -// bytes, without ever holding its content. +// writeVoid writes one Void element of exactly total bytes, without ever +// holding its content. func writeVoid(ctx context.Context, w io.Writer, total int64, seed uint64) error { k := 1 for ; k < 8; k++ { diff --git a/internal/oracle/oracle.go b/internal/oracle/oracle.go index 13821a38..4efa240a 100644 --- a/internal/oracle/oracle.go +++ b/internal/oracle/oracle.go @@ -369,6 +369,52 @@ func DecodedFrames(output string) (int, bool) { return n, found } +// PictureChanges is the frames at which a film's picture, as libaom decodes +// it, differs from the frame before - the first frame always among them. It +// answers what a viewer sees rather than what the file says: a film whose +// frames all decode and whose picture never moves passes every other check +// here (docs/WIDEO-2026-10-06.md section 14). +// +// Each frame is told apart by the MD5 of its decoded picture, from ffmpeg's +// framemd5 output, so nothing of the picture is held. As with the libaom +// oracle, what ffmpeg prints on standard error is the verdict. +func PictureChanges(path string) ([]int64, Result) { + res := Result{Tool: "ffmpeg with libaom"} + ffmpeg, ok := ffmpegWithLibaom() + if !ok { + return nil, res + } + res.Available = true + ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second) + defer cancel() + //nolint:gosec // the path is the one exec.LookPath just found for ffmpeg, + // run against a file this tool wrote a moment ago + // nosemgrep: go.lang.security.audit.dangerous-exec-command.dangerous-exec-command + cmd := exec.CommandContext(ctx, ffmpeg, "-nostdin", "-v", "error", "-c:v", "libaom-av1", "-i", path, "-f", "framemd5", "-") + var out, errOut strings.Builder + cmd.Stdout = &out + cmd.Stderr = &errOut + if err := cmd.Run(); err != nil || strings.TrimSpace(errOut.String()) != "" { + res.Err = fmt.Errorf("libaom complained: %s %v", strings.TrimSpace(errOut.String()), err) + return nil, res + } + var changes []int64 + prev, frame := "", int64(0) + for _, line := range strings.Split(out.String(), "\n") { + line = strings.TrimSpace(line) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + fields := strings.Split(line, ",") + if sum := strings.TrimSpace(fields[len(fields)-1]); sum != prev { + changes = append(changes, frame) + prev = sum + } + frame++ + } + return changes, res +} + // ffmpegWithLibaom is ffmpeg, when it has the libaom decoder. An ffmpeg // without it is no witness for a film, and counts as missing rather than as // one that turned every file away. diff --git a/internal/oracle/strict.py b/internal/oracle/strict.py index 40bf7da5..9918c002 100644 --- a/internal/oracle/strict.py +++ b/internal/oracle/strict.py @@ -1940,11 +1940,11 @@ def check_webm(data): """A WebM film as this generator writes it, read without its code. The questions are the ones a film can get wrong while every frame still - plays: the segment ends where the file does and its padding is last, the + plays: the segment ends where the file does, with its padding just before + the Cues and the Cues last (docs/WIDEO-2026-10-06.md section 15), the SeekHead points at what it names, every block is whole AV1 with no temporal delimiter, nothing shows a picture that may not be shown again - (docs/WIDEO-2026-10-06.md section 9.2), and the Cues point at the key - frames and nothing else. + (section 9.2), and the Cues point at the key frames and nothing else. """ top = list(ebml_elements(data, 0, len(data))) if len(top) != 2 or top[0][0] != 0x1A45DFA3 or top[1][0] != 0x18538067: @@ -1955,8 +1955,8 @@ def check_webm(data): seg_start, seg_end = top[1][2], top[1][3] children = list(ebml_elements(data, seg_start, seg_end)) - if not children or children[-1][0] != 0xEC: - fail("the last element of the segment is not a Void, and this generator pads at the end") + if len(children) < 2 or children[-1][0] != 0x1C53BB6B or children[-2][0] != 0xEC: + fail("the segment does not end in a Void and then the Cues, which is where this generator pads") at_offset = {at - seg_start: ident for ident, at, _, _ in children} blocks, cues, seeks = [], [], 0 @@ -2014,7 +2014,7 @@ def check_webm(data): keys = [(ts, cluster) for ts, key, _, cluster in blocks if key] if [ts for ts, _ in keys] != [ts for ts, _ in cues] or [c for _, c in keys] != [p for _, p in cues]: fail(f"the Cues name {len(cues)} points and the film has {len(keys)} key frames, not at the same places") - ok(f"{len(blocks)} frames, {len(keys)} key frames, every one in the Cues, Void last") + ok(f"{len(blocks)} frames, {len(keys)} key frames, every one in the Cues, Void before them") CHECKS = {"png": check_png, "wav": check_wav, "pdf": check_pdf, "zip": check_zip, From efc9db2ecd8d82ac6cfce917d3f214fec167ebea Mon Sep 17 00:00:00 2001 From: DonislawDev Date: Tue, 6 Oct 2026 19:27:01 +0200 Subject: [PATCH 2/4] guard: the decoded picture changes where the manifest says, a named film keeps its reserve, and the bytes that moved Co-Authored-By: Claude Opus 5.5 --- internal/guard/film_test.go | 113 ++++++++++++++++-- internal/guard/parity_test.go | 1 + internal/guard/presetbytes_test.go | 6 +- internal/guard/testdata/generator-golden.json | 15 ++- .../guard/testdata/screens/preset-menu.png | Bin 123394 -> 123626 bytes .../guard/testdata/screens/preset-menu.xml | 2 +- internal/guard/testdata/screens/preset.png | Bin 126753 -> 126985 bytes internal/guard/testdata/screens/preset.xml | 2 +- .../testdata/screens/recipe-on-a-preset.png | Bin 136215 -> 136275 bytes .../testdata/screens/recipe-on-a-preset.xml | 2 +- 10 files changed, 125 insertions(+), 16 deletions(-) diff --git a/internal/guard/film_test.go b/internal/guard/film_test.go index 0621aaf1..b3e705df 100644 --- a/internal/guard/film_test.go +++ b/internal/guard/film_test.go @@ -8,6 +8,7 @@ import ( "math" "os" "path/filepath" + "slices" "strings" "testing" @@ -38,7 +39,9 @@ type webmFilm struct { codecPrivate []byte blocks []filmBlock cuePoints int - voidLast bool + // tail is the IDs of the segment's last two elements, the padding and + // then the Cues (docs/WIDEO-2026-10-06.md section 15). + tail [2]uint64 } func filmOne(t *testing.T, target engine.Target) ([]byte, map[string]any) { @@ -134,7 +137,7 @@ func walkWebM(b []byte) (webmFilm, error) { func walkSegment(f *webmFilm, body []byte) error { return ebmlElements(body, func(id uint64, el []byte) error { - f.voidLast = false + f.tail = [2]uint64{f.tail[1], id} switch id { case 0x1549A966: // Info return ebmlElements(el, func(id uint64, v []byte) error { @@ -154,8 +157,6 @@ func walkSegment(f *webmFilm, body []byte) error { } return nil }) - case 0xEC: // Void - f.voidLast = true } return nil }) @@ -393,8 +394,8 @@ func TestAFilmHoldsTheFramesTheManifestDeclares(t *testing.T) { if h, _ := facts["height"].(int); h != f.height { t.Errorf("%v: the track is %d px tall and the manifest says %d", props, f.height, h) } - if !f.voidLast { - t.Errorf("%v: the padding is not the last element of the segment", props) + if f.tail != [2]uint64{0xEC, 0x1C53BB6B} { + t.Errorf("%v: the segment ends in elements %#x and %#x, and it ends in the padding and then the Cues", props, f.tail[0], f.tail[1]) } if last := f.blocks[len(f.blocks)-1].ts; last >= int64(durationMs) { t.Errorf("%v: the last frame starts at %d ms, at or after the film's end at %d ms", props, last, durationMs) @@ -421,22 +422,33 @@ func TestAFilmDeclaresTheLowestLevelTheSpecificationAllows(t *testing.T) { {7680, 4320, 30, 16, "6.0"}, {7680, 4320, 60, 17, "6.1"}, } for _, c := range cases { - if got := video.LevelFor(c.w, c.h, c.fps, small); got != c.want { + if got := video.LevelFor(c.w, c.h, c.fps, small, 2); got != c.want { t.Errorf("%dx%d at %d fps is the example of level %s (seq_level_idx %d) and declares %d", c.w, c.h, c.fps, c.example, c.want, got) } } // No level admits a frame under 16 on a side, so those declare the // maximum parameters level, 31. for _, s := range [][2]int{{1, 1}, {15, 240}, {320, 15}} { - if got := video.LevelFor(s[0], s[1], 30, 100); got != 31 { + if got := video.LevelFor(s[0], s[1], 30, 100, 2); got != 31 { t.Errorf("%dx%d declares level %d, and no defined level admits a side under 16", s[0], s[1], got) } } // A frame too big for a level's buffer, or too little compressed for it, // moves the film up - 640x360 at quality 100 is not a level 2.1 film. - if got := video.LevelFor(640, 360, 30, 300_000); got <= 1 { + if got := video.LevelFor(640, 360, 30, 300_000, 2); got <= 1 { t.Errorf("a 300 kB frame of 640x360 declares level %d, whose one second buffer it does not fit twice", got) } + // The buffer holds every picture a second carries, not two. A 20 kB + // picture of 640x360 is a level 2.1 film shown twice a second, and not + // one coded thirty times a second - a new picture every frame, or a key + // frame every frame - because thirty of them are 4.8 Mbit and level 2.1 + // holds 3. + if got := video.LevelFor(640, 360, 30, 20_000, 2); got != 1 { + t.Errorf("a 20 kB picture of 640x360 coded twice a second declares level %d, and level 2.1 (1) holds it", got) + } + if got := video.LevelFor(640, 360, 30, 20_000, 30); got <= 1 { + t.Errorf("a 20 kB picture of 640x360 coded thirty times a second declares level %d, whose one second buffer holds 3 Mbit", got) + } } // A length that does not end on a frame is refused with the two nearest that @@ -476,6 +488,59 @@ func TestALengthThatDoesNotEndOnAFrameIsRefusedWithTheTwoNearest(t *testing.T) { } } +// The picture a viewer sees changes exactly where the manifest says, and +// nowhere else - asked of libaom's decoded frames, not of the file's bytes. +// +// Every other guard here passed the film whose picture never moved, which is +// what the owner saw on 2026-10-06 and nothing in this repository could have +// (docs/WIDEO-2026-10-06.md section 14). The cases are the ones that can go +// wrong apart: a change a second, changes faster than key frames, key frames +// inside a change - where a new key frame must not move the picture - and a +// change interval longer than the film, which is one picture throughout. +func TestAFilmPictureChangesWhereTheManifestSays(t *testing.T) { + cases := []map[string]string{ + {}, + {"duration": "3s", "change_interval": "100ms"}, + {"duration": "5s", "change_interval": "2s", "keyframe_interval": "1s"}, + {"duration": "2s", "change_interval": "1h"}, + } + moving := 0 + for _, props := range cases { + b, facts := filmOne(t, filmTarget(2*1024*1024, props)) + path := filepath.Join(t.TempDir(), "film.webm") + if err := os.WriteFile(path, b, 0o600); err != nil { + t.Fatal(err) + } + got, res := oracle.PictureChanges(path) + if !res.Available { + t.Skipf("%s is not installed, so no picture was looked at - a skip, not a pass", res.Tool) + } + if res.Err != nil { + t.Errorf("%v: %v", props, res.Err) + continue + } + count, _ := facts["change_count"].(int64) + interval, _ := facts["change_interval_ms"].(int64) + fps, _ := facts["frame_rate"].(int) + if count == 0 || interval == 0 || fps == 0 { + t.Fatalf("%v: the manifest declares %d changes every %d ms at %d frames a second, so nothing here was checked", props, count, interval, fps) + } + want := make([]int64, count) + for c := range want { + want[c] = int64(c) * interval * int64(fps) / 1000 + } + if !slices.Equal(got, want) { + t.Errorf("%v: the decoded picture changes at frames %v and the manifest puts the changes at %v", props, got, want) + } + if count > 1 { + moving++ + } + } + if moving < 3 { + t.Fatalf("only %d of the films had a picture that moves, so the guard did not ask its question", moving) + } +} + // libaom decodes every frame the manifest declares - not only "decodes". // // The count is the half the registry-wide reference tool guard cannot ask: a @@ -540,3 +605,33 @@ func TestAFilmPictureLargerThanOneTileIsRefusedNotBroken(t *testing.T) { t.Errorf("4096x2304 is one tile and was refused: %v", err) } } + +// A film whose picture size is named by hand is written whole when it holds +// many pictures - every one of them inside the reserve planning settled from a +// sample of ten (docs/WIDEO-2026-10-06.md section 15). +// +// The sizes are the ones where the pictures of one film spread most, measured +// by tools/probes/videomotion/spread: a reserve a tenth above the first picture +// refused the 64x48 film of the golden set, and on 48x32 the largest of a film +// came out 42 percent above its first. Each case holds more than ten pictures, +// so it is the sample and its margin that is asked, not a film planning coded +// whole. +func TestAFilmOfANamedSizeKeepsEveryPictureInsideItsReserve(t *testing.T) { + cases := []struct { + bytes int64 + props map[string]string + }{ + {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"}}, + } + for _, c := range cases { + b, facts := filmOne(t, filmTarget(c.bytes, c.props)) + 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) + } + if int64(len(b)) != c.bytes { + t.Errorf("%v: the film is %d B and %d B were asked for", c.props, len(b), c.bytes) + } + } +} diff --git a/internal/guard/parity_test.go b/internal/guard/parity_test.go index 6f508c8d..5dddf89f 100644 --- a/internal/guard/parity_test.go +++ b/internal/guard/parity_test.go @@ -235,6 +235,7 @@ var reachableFromTheWindow = []string{ "property:wav.channels", "property:wav.content", "property:wav.sample_rate", + "property:webm.change_interval", "property:webm.duration", "property:webm.frame_rate", "property:webm.height", diff --git a/internal/guard/presetbytes_test.go b/internal/guard/presetbytes_test.go index 8efa8793..e4b77ae8 100644 --- a/internal/guard/presetbytes_test.go +++ b/internal/guard/presetbytes_test.go @@ -77,7 +77,11 @@ func TestEjectingAPresetGivesTheBytesItAlwaysGave(t *testing.T) { // set walks the whole registry, so the first film format is a target // more in it. A Changed entry and no major - not one generated byte // moved. Every format added later moves this row the same way. - {id: "empty-and-minimal", args: preset.Args{}, bytes: 9272, sum: "2c4b066cf7a4d2f124dfe00ff1e94f4be02df6d0d01cbbb65cda4987acb89970"}, + // Moved again the same day, before any release carried the film: its + // 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"}, {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/generator-golden.json b/internal/guard/testdata/generator-golden.json index 67b918cb..48d25391 100644 --- a/internal/guard/testdata/generator-golden.json +++ b/internal/guard/testdata/generator-golden.json @@ -334,21 +334,30 @@ }, "webm_64kib": { "bytes": 65536, - "sha256": "54b2c5610ecfe09798298b460dfbf40a20de9acf159ab4403450646996311b86", + "sha256": "9c1ee9c40292c2df4148fb580f8d9cc0c2430b760d390e6d011639542c43820d", "measured_on": "2026-10-06" }, "webm_named": { "bytes": 65536, - "sha256": "62d199e1e7a9e92ec14152a0156798735be3d715630d851f8ca96e4876a981ba", + "sha256": "e035a66d1b8a0caf420df0b7ff91958e3ed0785561f77e560cfdd04a9092adf3", "measured_on": "2026-10-06" }, "webm_odd_size": { "bytes": 65537, - "sha256": "775c5c40ac7f7089858fd62ca956be23d2a706796d809aa6f8d3d07114bf8a42", + "sha256": "d4fc121f6b6714609207bd98d1f52fb232bfdf0520009b1b264b2151b732b3db", "measured_on": "2026-10-06" } }, "remeasured": [ + { + "on": "2026-10-06", + "why": "A film's picture changes now. Every change_interval, one second by default, the clock in it moves on and a square takes a step, because a film that showed one picture for its whole length looked to a viewer like a photograph at thirty frames a second - the owner's report, the same day the format was merged. Each change is a picture coded on its own, the Void that pads the file moved from the end to just before the Cues so the SeekHead can point at them before the pictures are coded, and the ladder's ceilings were measured again with the clock and the square in the picture. No release carried webm before this, so its bytes were nobody's contract yet and this is not a breaking change. Sizes are unchanged and the smallest film at the default settings went from 3044 B to 3294 B. Guarded from now on by TestAFilmPictureChangesWhereTheManifestSays, which asks libaom's decoded frames where the picture changes, and TestAFilmOfANamedSizeKeepsEveryPictureInsideItsReserve.", + "files": [ + "webm_64kib", + "webm_named", + "webm_odd_size" + ] + }, { "on": "2026-09-01", "why": "TAR.GZ pads through the gzip EXTRA field now, where it used to pad through the header comment. The comment keeps the label and at most a byte beyond it. Sizes are unchanged - every one of these archives is the same length it was, and the same sizes are reachable, measured across 11 260 of them. What moved is where the padding sits in the header. The reason is O163: Go's compress/gzip reads a header comment into a fixed buffer and refuses one of 512 bytes or more, so 4 134 of those 11 260 sizes produced an archive no Go program could open - with the message gzip: invalid header, which reads like a corrupt file rather than like a field the reader will not take. 7-Zip, GNU tar, bsdtar, Python and node all took them without a word, which is why it went a month unnoticed, and testers write tools in Go. The extra field was measured against Go on the same day and accepted to 65 531 bytes beside a comment, at a cost of exactly two bytes more than it carries, so it is byte granular where a tar filler entry is aligned to 512. The cheaper looking fix - capping the comment at 511 and leaving the rest to the filler - was measured and rejected: 831 sizes in the same sweep stopped being reachable at all, because nothing could bridge the gap under one block. After the change: 11 260 reachable, 0 unreachable, 0 unreadable. Guarded from now on by TestEveryArchiveThisToolWritesCanBeReadByTheStandardLibrary.", diff --git a/internal/guard/testdata/screens/preset-menu.png b/internal/guard/testdata/screens/preset-menu.png index 8e9c6de9344609b8c99b4e8d65f776f9913909aa..d08e730ddcba9cf454ce90d6055811a425a74120 100644 GIT binary patch delta 8758 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 AxBvhE delta 8397 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 - 29 files · 34.9 KB (35 711 B) · 27 formats · will go to /tfg/out + 29 files · 35.1 KB (35 961 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 eab4168c9aa5a6212edfb223b58b413ccdd7fdfc..7368e8f2a597e0b60f330c973e0f3d91b1602b16 100644 GIT binary patch delta 8724 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%0AZ}`>}LadWO(@XI83^vH^eQ=P-uiwF&9r$ zL17`z*2xo|$-a8=>gsFL{%D-2KG3g*Lf@6qB8!2%4B-x-=b}fZ{I~AhgprD~XS`B9 zr9!0m-{zT3q3ii1m=rhrsPkej)ZZUg{t^ZGzm@bqUpsMh*)RkFQo}fn0(X}x{ymGxTmT$$geoQiY}w~M6W2kP>esk>d6@7BVFEa8R7x|3~->LVIJ*Z zK}}yc<*KjG;|VTIzIRou+iyu%gFo!I;;NjNa38rgX`uk;wT4KiVMQqeH00k zH)pq=O>>KTq&fM{F0rE%eEc#yyZ_l4?G>O8u9;grarIm>PhqTMDw174{AyM>nl$X}-&koPY-`amNr`hUk3OOLbitNOoMlAclim713oD z*U|jYUtBz}viy}*O6lq1dzIGk)(?hA4iWG^54sz?m_67VH|TL6(uAXvRK0v3Qysv? zGp%pf4DeFlQ1An&RS+UYasIQDT#7KX!YhIVC#}aiKEGd$_|Gz%oRpGemAFyh{{e#9 B3ZVc1 delta 8387 zcmZ8`1y~gC+CB(MNs5$6OLuokclXlWDb1(|2uSCWA|)Z+DM;fk(p}Ol-OV?@^PTg* zzVn}VuXlELW@p}c;(qS?*~k^Nyd|{vb;$DXpSbokD!xbVQ3ft4Ce~9fk?-i}=vE07 zo`j{pr+G?Rk3-WjcN1HmV)@2gT1r;adoq=cXG#geK`Y4YC*@E$HNUIpf|w$z)B8Bd zuPfgYrOnDL7c=+LQ5v-iPm+LLaRy^>^lxD=QNZb~*j>WbW!4rKvESzPIyr7U$~uye zBo4T{D~Y$N1!ld4e@GtDsp(pBk}NGf`Eem9FW*>HRzIQhCqf}D-Kk??i>qZREG#7APOVMR&8M@KF8B zt8%vWgLf!tWq7FO>~EAr&4#%$lQS&`19mn zmoShf`{%klW*wh+|6`)@(*dcL7vWgSywdC>!m$X-9&0a$>%piWKYC5P+B^0>uNvD# zOiB;`$3E~%X`G@mix#qfPvzP`@cEhHqQtE&qJgE`jEIo6MPradR&q+(}p z$PZoL*pQWz^K8y~!4L(3Kv13(OjqiAdwc(h6(bj4c3Sz#SvDDP{8u(<5KtMuq#?NW z3p}MfC%+0B`GA5p=iGorJYjqK7H8}9dTD7%z;)s4)9$U);Nal1fcDu10`i62)X32p zzuR}O7h5DoM>%?XdxI}#k+&M3s%(ChLb(pA92y!bEoGjIR-g-RGGSk&!N!j9@qxg1 z_Sx_vQN4$uLqnA0xzUnTcmU>8UEQ11RH6WBjOgBn)d;1zX;KLZ02j9r9#K$VuUa`h z@60c)ATQs)(iQpyrSP!hmSTT?5fu$By7%wyZss!ZXf{Tgr@@HlH+~Nny)vwt-RtT3 zw6e02FCUkZoc!m+jGC3m>2FWZTEDuE{_@%Ju?072qwD0uGk`d560qim4q6L`+zGM? zt{oiYB=Z`w5*;pgKAzZMvq^|PJp6SSFWTJfP{sO>@Bc|CB%w|L&%68k>2kx3&CR8m z8LAW|-~GAlT(cEP_!m+p?P9f2MA=nPWnW+48|73zef{yZtkqL7FR$rr@!*>io0v(* zlwk%xe}5obP?H>wYO%qkOoeEEYD(?)!kQ4%-*ML|;9eD{e-Z{`qqUkXR8HGg3tT() z%M@@UjFiFbWdXfjxA*&WB+j&Ip;^x+9#P*4^$;8#r5SWd6X-JLM5o13 zH?_6(c7MJR1qB5c42#=~*EoANc9mzRr-!0>!KX*79F5MiN+T9-qobPR#SG(Y-PSEl zEiG!$L=$~|0o&i{XJ-QbqDyzzJC5hF{Fx~k8O&+w_Rh{K3h4@Q?(}T?hqQ8cW`V8u zfR~4l4=ACJyXPWg)fJ(wFjJXYI(@&ek`n6a-8T5HP7 zouhldX$-8bZPXEacvw`=?llQsLqdf@w6u!k<>gsYhHho117n6am_th*mw)UWozXB2`9SoZoC<9K+4JAKfG0u@ z4wX*zEgQr7GtLcpZ-04tPNgO%+t;@Y3=FKVmnf&%Ca30D)EY(;a{#6_)}w9yr#l|- ztLtm8<^>;w=W78N6gr&#JCNs9;LiMRM=;9xn5kI{Z*5&>uqwE3fhKEf_RO>2iL4 zZ?@Lh$Y@6|?ds}^)u`?$Kkj00@fL%cPRM_^#)fpfs8G~faNu}tXQ$b9aW^Bwm_^^# z_=jGjN-~Y+V%7U}in0FbI!6wfU)5ff+ zfByVY&EzK{2J%N|I+-rdgPCb*Ys>do#m*kqw}=UQ-oUZl7&gHZo&?Gd%ke6>KuS8>_6;ts%$5tpa{uzN2ivOy4PWz z%n^Gm3Nyux>(3Mm)bsH0aHB0QDl*Db@H={QDQ9WfS2nrZFB_}Dn5;mDV?MCqk=+@k zwvPJr=}W>rKqTmjbN^O5@-vB^o}OGI1;^;@$dgK%zi(spe=4lC8=0J* zzC2tuE1QfVJ2!FIfsSe2ss;tN+JI>icoD>yqU7BY$Wxw}NFNaq@j{x2&FK61&bz}0 zDrQI4v|%AX4aLXIh*~>8KR@Sk;LFJ6gnb2QL%{L5K^7A}vgIY1m62g|vcg0`Bp}cT;;U+A0~p)k z;o%b#6GcUVjeBq26ys8`l8XiKvUw{Oi)P6Ijd(z&TXx5 znefZDR{WlK*}e`~2HZGF;MpS6odD!5W8;|JKWEnJD`9IT^1;{wN(i zeL9TIxWW1S$X#7cjR?I>SxJqUaL?;YrXpS9m7nZ##|LZx8(V23?{sSf?b$PUhRv3V zk&%(Vzkf&wa?)V(@W!tQTq{lz@Yzy^zPYN=DXZsJYL&2?Xox&}CLc?lkd&nS`F`g5 zbXljQL$7S|bhEwM_mDv%=lJRcsmMhbu~|?8p68Kyp240QH&nN@^7UJF42;U>q$euM z%EP1i{ZY|CXP&$u4+v2q_cs_}OUTG$3k$wR#>OB#F{mm%$HH=QbQBJ{`W*Tfm!5`3 zI=|VOKa-eMAx(kKsy`MR8=HxhHG`9Ss$ZTishu1jzr$v@;pQd)^bwPGVt4m5&^rf9 z?IL{Vlb6fO5J+&XmrMdFg}C(={PIvOF-wpO(4^VXhCtfgmXZ6N6(u^0H-AIh-|Y+c zPYnI}g9lY4$3rndB;1c1)Awiw*lCK~jU7uS@5b`(=R?dQiBis-EbRPR53 z`$km8h>Um=_h(9vB|2)d2Hl7-IjW|?=EuxaO$3Y5$+<#9$W%^K#kI^W_ zgFchulDv9_*xmIM#OD59s3G%hRngwrL_D|!sueA(s*3%Qij}CWp@B#$9z+*ogWtcV zaR}J}ir)qaKJUwkwJeO=IKv{rP6U z$gtCQWcDA;*RwqbMm8C`wJ!NGU zQBlv|Q?FTt%w1e~Y!Th%hCB!a;#gimPw$=GmIj0z+*xT1v$r1J6yO;0li+P2D5*l(}f0AZ)5D*a1)KtzVkjut#N=qs1{c~#S z;$puIB%CWNR055-N7>;zAe&fTUY??F{i0Q(1A&Nf9Srjvv`-tgEXs*l2HWXVq)gV03g|uQAx5N%*Wn58lN6*fKLKYu?0F zTSwJ-IVy5r+KU@-K@QT{RwOHEA;4Ml;9p2_$8 zlw5|x!8j}}b)<-!9v$tnOtC64A6S>9M+3Np;s-yd;8=T7C80pGBWJE?l;0$u2Rd)rt#Ky+7AYO?D(m1Mt``gRFz`(`DMb{w2uZdYE*6%mqGH!3Fq~45 zpZ}VYa>Sej6bLf1y(`fPV5L_oOauv|N+v(H8NalRjZLdB+RS|A01WmEvtrx3#d2IC z&u-+;*RPUJPPSTF+;NwvW}Zf^z^eqEh ze|w|P&(Aw=93|3Nlux#|x6uLA?%rq@M<>K@nB5yX^4Z42=vLpuzky%ki;6bED2a?* zZu369`P<9y;bE4b@Yek$DQQflR7-1XDyu&Vz*}xJoLXU6Yy0>BQq?uJMz{IR{BP1- zOZL*nb~s8D*=#Uo?Lm{gYWQBxp;Vt+XK#l*w-Qmgw2(vj*j>4 zL>v*3erc)oy)++ReH0pXLVQz`AEE5&>8ab&ov-uW*31Ssc%-JL#fPQ6vC-}EA@mdU zLt)`Yjm>akVj>SWcW`i{ot<4{-2ef;O3`G@D+fbEZj+$1ZT!&}vONyI}3uA_n;p2a(|8;(L zc5{6VLQ@Psjs^FR3bW1+R0BVZchuBkb#--T@S993%78K_LqpPcZgcaMoP624tJS2+ zKi~QIjO0pCN<6c4aemw)OF!Xd{vg;X{!9om*01HANS{j>*{!;qm>QIxqCmdeES_aST{5>LixfI z^@)Y!ZkS?{_f&-s1`f_#s{oO#rj?bIukR(uRWX>CGcuYa9{iY@na5^lp-P4Jj*dgc z75&@UoZQ?pc*DTM>Fp2W#s#2N|LE#C-iPEOu9vyL1{s&~VRyGBXLut=B3G&WP2wwp z$di?SU}tJ-YIeo|?b*SmCs>193krIAZJJac^W*M(_l{oSdYV9{FE1`)^-V5m99>*M z>c+2!S#JvqTwTo#I#CqlPhlrgW+LS7Sgo>!XLVT{IXF4Rpb4=9>~PQ>Qc~1P`eQIy z%&%2KBBH-oeUo?BrsAFn~DF-Gz7^W%M@<%mJ_lDN6MYnavw zOi+$&omSI@N>VYhu?4Ti2o$O3+FDzS+Qs@qAZ#6%?hCE@EBo!xE>7^cP4$qxvHUjT z6(>|ryNDzP_?XrGN5e2BEiw`#69yj{@%1)IO7wIE@h?GhSR)QX&y> zhOTKMoVp3i)HgMiGEdQx!^xNVCV09&=yuGf0JyrEs;Ce?prbzdAo=xC6M6kEh1zZ; zXK{10_T$Hokx{IKvYmG)!?Mvtuyn5cW~i}=Nx=P0%IJY(eqlO9QuC9Xb_(%#PI^_c zCk0VanB@ceLn$mVF)=teEO&PeuSiJf=tGiH`M-@`AFuVZvm^SvZU~}#XB6&1)+urC z4NT3=wS521$Y`%FD=QQxU0vk|o0&0KLgFmW%gZY+Mq5@VVq)6D#KK}@W4pb{S>M|F zryTrFqbG|R>8>>e2bQ9#atPPKSEJB!@P zN}``wg^o;Bmx=^gA?N`qMo!!Lfb7(Y-A`oR26%EzZ{9Ig2%*^aeOxRdhlM@pr zb@r zglC8J#|DR1lIIl_*)peqX3awta^|0(ddHNIesXrEpeQuGxES-MNLX07%9QHWt4sj2 zA>O8gw=jfm{Kzli4_NXR7u&(wpOV^H_w3nKd%IW?1Fj(w2nH-4hI$Ueg4lDfkNYh^ zm7DL<1Q!2Y#4M;q|M59{`6Xbzf^reY6OFsSU&qOya8AiW`24^yHM89DtTUL&r6nS zmchFdc9Z@oae`TTbcS5Qc3Fs3uS`{z%|ckL>w zAc*+xpHx=bv1rjG>|1a?2UJyyhY}(iy>}AhLvSO@czPIzw}Fup~_a z=(+3)`Q-L?1wukXuoLz3=T9UaWDeG#D%Zu9?(Xj8n}Z`i_3^O~91ILkclW9qBf^(2 zrMim&Om1%OVim5XI*0tIsHld9iyX1m;UR;V7}cwaw*--&z!pP#Za*y3A)1KIq<$Ad z5GhkU4jO!OeSHWZa1%@r1Pk(t5DlLQxCj6cAXhbD*`Y17^m?Xb3Su9OvWZ1^bq(f4TR<7zuI6NC)9r zeuMvqo0I>GuX76llJx!kTW>MLT6_=P*B-!R0IpWee0=KBa6|SFJ83!H8x2@H{Ei8Z zGw(b3)UpIg$;p$G&+-je=@S%6N{Bgm>OOt4!pF~TX%Z~^4t%d`Xz1&GO>&7xd%hwy znm~(T+~9Wzw)IRW^&WSM@zbM^{l1OrKvn4DF3+}d$FGAzywB_?0rG}-Kd`2R+}8&Z zw%*~G$NtL8qYeoXsrpy}#?d$2Pra7G_vh1B4Zn7`?SZSK=@b_IhpRQF5*@I0MoL)6 zAaqUmOl@lfC^l{OOp&3{)zhP=BR@Z1BqAeYO;G}cOvmkk@rD)k!0n;i_Q3&s@Veb0 z>m|#cUU}6o)&e1N_)`0AmB(p~jdG#ZPW0Ibo~N&`wzl@=kvsTGbUyx^ zpO3cxajoiccXhn9M4_(kODM}>)}f@KWoNgw)Lw02VL=(vl%hhPmQvj(`fFk$y1E+3 zEi7D|=~zsI+4lGMA1=MdBWa3Ip(iFJq@yE$U0q)e>-*OESYj!+eRn)m>%Y0fH9zazxT%2yx66hA+|X*&=QQ>E4By)(9z+h0_;cyx(NQtN>E~l{+OxHSaZhp-`+o~BqJkBVo(Q}nI7u{b%MgPXE$0qJl{Sd1i~sR zDr9IV_6<0TbaYEBt7SY=A^Q6IbOdTguf75bti}zjbaVsbUOb~%;F&;CUf%5Zcs}n& z9Ay9D;USG;_7Q&7`6vQ5!UAs2plP$$aadwvVg!mdbu5d@pSw6EHFX9pUwN7MT|9qH zo(!=gi0m=ve1?V=7?|?H1IW#`^pE1qPCmaDpx_PhQg0)@D$_Aw;<)eKY%rXMM6hNX(vf zd43*vbG8HJDTY9n^=tI>b#=ieEaKom^M)izfsTz0Ho8SC;RSnmD5g#Q2>!2xel}E) z&*R{(F)=$UenuS6STHp)L6gL!1pK6WD5b_jH~psM;{CZ7;3m`DGwR!lL`#0A$Uz~U z=iU|)IoeHzLimOdvzk9tpP4<)7y`LpALL|IqDwqBt&yZ6366XqPf#cTB|%$T+v5}l z9sxRPJ_K&67WD8lJt9$oE{Y>*WQ*n9@5gIQ+(++?p+>|mMbhB9z;ck+zERqEv3g@Y zcJQ_3PI4sB>8VMm+eWNGxPN#^d3XS-lc1xoO=V7~w|R`hn{JD{+uL*uxdhQ~UccV= z7G4~2YYh*V)6%NR$uV_uGM1I?{r*Z-cr#nv)Hi7i`d0Fvt!?+~=tO}QlLZMDG^^8! z9u4)$J;vDhI4=58V|8`jsJ*J{LUOVf6beywuEFZ{eGzi0pg6z8@{@?WIR=@HnMlJtzX}|NN6&o4(SOQN{ z@c)j=WcQ!aH;0zg*dWgb-2*Pkd)*6(sUPh^epwvD-3;g@p9J#3@5}x;J?Y3JgGDwl23m?Hx=}@x(|- zNaHW14JzH{F1R=zK4*2rjf}LyHr2Ma$+Pplykp^`Lc;R@`+h z<+tKRXYM)E;7uQAE%AqHqPWDx#2zlfy_h^ta*2J~jo8y - 29 files · 34.9 KB (35 711 B) · 27 formats · will go to /tfg/out + 29 files · 35.1 KB (35 961 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 ad55a1462f69ef59fb28341fc6bb69dd995a059c..de1de7d5b94b8b865f11dbdeb674b5d12b2483e5 100644 GIT binary patch delta 8878 zcmc(FWl$Vlw>1Qp;6Z{9Zh;^{0}t-5!6CT2Lx&LD1B9T1yA#}kI|O$P?t^>2eqQ_D zU-#Foy1lDZc#@8fO4fQ8CNQpEvYzS=7piA3q6Escum# zG8Uma*hZG_7slkSQ?_TF#;j_e(G~kp|^5ySc{}3ZbY$JY&v4Ezr}^n<(Z&a|NAY}7|l8?01Yp^ z$E*hBkzi=|bZ`yn9Z|-3S|TPeNADUxjVi2RrR< zk)k)gNuYy&J2OGsXV4RSO^X8B?mjIF+^6R*SWsqeVRb&>z7Zia_J;9&Rh7F3F{!O? zML=$gSC2!TkTq8v*}p7(Es2bQFK`|4GyWcv5)~ZWJ$B2?q~D3kMH3*-2oFvP+!F$t zz|p<{=8OLvo7-6baqw?P|NF1N|Gvxr$zyu5ic4QK1~w|{UwQ~(X!xrDL8Si#;oma= z_fsAvWzfvM?WYyDHs`{i8D}F4&IB)il@;Vq3(-WWz3MyLEB5593epe_+PA(jrp12^a zsHjjYP8{A|ZF23msmlQ({GB{JzR4Nkqu%=X+ge&O$lZwLPTcxAk};4(OC`E~S#cnW z^xt=zo7IvgdPzRC?ed|JPg!Gx8)y}d^Z`OGmwX!mM;`Po1`jevo?_6z)Qi69hg zPU1zCS9U#N8#L1QA6T`5IzPGipPf1Fx-|7HIrG^O5hd5xpX~4fF(^n#vb1=$3zG@7 z&oABmhO&Ly+8KgOz9%Fk$j}}wyn>B8GlQVr1|2_q*58gu_7qg573M(fquQ=M6QlgGSae%V&Mz6ghWm8=IV|eeqBq9F&26X)|t* z$9l?V_5rg=G<-&pz=d_+WT%-0MX z8yoxkEc;vi9@I%fZo?{Ce`&8@9V!|F-jnV}%wuC+ha{`FJD zZSSJZH&SKF@Ee;RAs*zgDUOYaX>>?8(&T#@DFZLSJ2x7gJGFo7*RJL3wAK#bR^Sm4 zrG4iMm4GOwa7N>Nr`Ts^U5=wmz6Vzz{*il6zX+Yml{s_yw635Lo)cBT~=FZ zjf?;&G@wLuhEiE=a+!o{wY;?S=FG{_!C`DbW#5gBGsW%dz)hnUhN4zH{o{wGxw(Iz znVDHn#H(SEha(m7?s3MOt`q@xIayg*1B3T+t&E97Jr}#|iOI>aov{fzaWgzqG z3Pvqz7yA1x=PJl8$I{tYSh}5sxVUl~8@&!^#WoL^xzv;5;%=8(D8;fl7#KpU0HpHk z^^I<$6+re0@}1jyaYf`a?iK;HZT2@K}CxU{6|&5Il)%1Ve4-1X4m@@<%5-_7&aHyWjMz9#d? zZbC+aUGw2@6^3nc#R_7BB3$aI>ZQaymM?WOgIb@)p?H*?g1h+?)}mWVpl5IMkA3*`UU(c){-af>c$fv97VP zF(-$U&E8x^<>$#ti@F-c(U*b(qe6w0ka`Y^*ak-<+-!6h49HbUw;YcFPWSfKFWNj$ zmYts;gCmL9+4LLNM|uC$_&;1vPist9VDs?sCL|EypWu>{XQVc7ZuZKTmzRUtHCJgA z8x`f}G6=i5f%4eye5D&MEiGjv7%EU~0>k07?dEymjSAaQH1Dd6qJg3M#kzweMAiEa zo6`RWHr+Go=BDZCDi81o{}rE7WLK3}<01LiFO_(iTgtC*6Eysg z__ND>E=u6hQ3G~^iWVR6=SlO&mD_+>&Cj2I{a2GrXGkXVqTikIqu;%SIbI*#F*t8) zsH;!S&oknjy~V^pN8f^ZNlLDGzPdhLQ_feDj>Q7RkI8T;gtf}GREr6M#j>?ZL9bBg z+GivT6>oN-)v8x4--rmd@(U`!SyFwSAZMgkD`lV(m;ANnd#7>MTVk z`aqM56%J?(A1o1Sj$WmYP#|F1>1|Gjg6RI8`$m;P_XbqD|2$ruaA)_DJe0# znOOh@C8gq$5~){|vVM<8rP>_~C!!yK@>yl!3A`Avs2CX-Y#$za27aq ztkwnh_A!S+g2iwN39sXTK5QBe4i1)KSg3M_039nUH#K#3VeZ!X;A=%ChYE}P%Y8FW zlDa($uqc6TrpFQoJve4?$jn7ytMu_n8n1TF)U?2Hwbgp1xehO!mdUz817MbH=@b(atI*&7Mur?D z3_Q0!KDL&z)+x}7YjPwpcMq37q`eSWsXJuk$j!~|2PYIg*rra7^~CjF{I6CYn8@{* zP+2k8+HVF1`armF92uRufr1)*l`@&gSzJ%Ox>YSFYuxG2=x?$<5vuCd=@)DImAKXA zyJzBRyn6+Y0ieUfzs%AkKteLZmYon39s{`$p8@J%j% zvOkvcqY)-1=4!waIkYk{JX}_PS(f>gqH+fiMKutP&oDBwmHyGv(s%u@%kEI@gs*Q> zQ&Xj+Py6S*!d+Y(gaBby;=!Sz`_(p)aE-Xx+1bTK0T6TewJ(>ImmeNv^78TyGvG)t zC#|-4jt>p#j@HJg)ztp^&Si-eUEpTy*C=D-GXl`jKFZX&$S=sWU&%9*cqlT`oE(kc5i+^42Pr>+Q?2eovR4QNt zo}Wj>n3PviI@$Mb5*eMIHXBRl7y08ycu|Ju3Hs9|Ur0O&6>`Jq?Wrj?|2ZERn?3C;C<_JgcuC93G?8m;|W zSYRUfW~zLZBN+*y>1JV}v@|yt6cFg^duGp2(nfrvq|^Qw)Eh}eM7U~WV`EyaV`yk- zJA1pcvm@kvPRnd#WM(Fzsj2C6pOnI7=`c?fiw1sCx*e2t0ayI3Yzux&hUmsK7PPFL{e`D)@(m3_<@i7$&%GoQ? zr1bC{=IYVcPY->T=7>no$Y{SmaFUmom+IrOw6qiv5g{U?leAmF+d4c{*U(6Zvg_2_ z$$H&pn%F`H5*Rc=hysw4lZ%-!edDk9blmz%7kP1x*xTDMS7Crpd`r@P!~wf|6!iRp z7oJyh?(FL7s;8HxqQi_A{-tYjR!h~@wN^zZ0_Nv*Hmg}G%*x2v>fiEruXNpQw=^|X zKvY&r;cUv=s^1)(d)!I;4Z@L>l$4nnWiXLeeNW$S;zd&emX;O+0|Rj7UcU5xxB&uo6du=}Ju(wZ z+id5?zJC1*lKklR+DrYG?#|9=T8ySej0AErGG|kT{VmXOLyUOyk5ZZ23Wh{Kn_YxD+Zmk_b-Zhoe zoHHWF*|`v8He6h_w|>brHRnA&w88iY2GyGgBctvI(+wadpI-sHK9}W~g1o#k2vgg1 zPhNIYydokh)N62{0~iXaP+e|L46s6jD6`Zskw1b+?8Ao?;4wb3VNm2n;Ch1cZ1uv? z^|gmE6q=DSy4#YMm#35|l+tNTK$Jj3EIoIBb@(+w{jGkh2iwKgpyO6Ks;Pwqh{L-S z(_Xwa`e0%*F)s4Q*wQlesV?w?s%m_0Zjc0Ln1K`uO6dL+P*`rzQjslsBHs$m@nFIl zEj>NBI-9nP+eb(GYLJ_s1Ox<)O-(fmtacbYJUn1;G_591<9_$<{Q4RTebMJndn@M` z6&*rK;Po2v=DM+wp5ELf1p6h33Bxy&U&Y)p3kobEym)73ZXPdZgyZpWZSU(VD3Fo; zdLAv4>zB}Cf(SJf6zNtgwc7`nbuQQ98=~lC@yt1+% zReB?;yt1f>sv=yswxPlIEb9dg8KNjNGxKXwQVs2FwPGcu7>Vx-jPJ~WA%gg#mw(?a zDJ_MHRQ6j!-n{XPN~|*2-Ngvw&J%eaAQ1@=!p#SR0X(ji-+oG;BXK!dZA(r`0lBd@ z$V+VJDmo$UVTo`Mwl7<3B)QgBzZUGig@?brwzjtJY&HU+FsR6sl$RScyTKM}MNZ~L zv5w+pXoZE_Ljf%RK;9_Ec$q7!D?L3u7?Z>?2?_CDq(t{O5>Vj*2dX4QF}~y*V6wq> zRJom9Qe52R!}I%KCJO9K;$j|3soeX=4TB`Y$H(@%x+BiNrv=5T+gzL21@6J&va!)n z;9U=nj+%22i?{d$1VEdc&%3+2`YAL*sKP2Mm1O~0w23)dyX~P@tE(5Dr!iClPwaJL zV@gMAd4+}25)u&-_NN_{W%gO6rAu>jg^SYctgN~}tk5Rz=u?7w^Y-?hU@#bB4i3)V zVaxsFoy1pC>2i}hjug?!Ke)mO_5)*56NI<+r z)BM`GMCIHB{P!xVzrPpoFSAyH;|C4+`}7ZUWZbIK5eN)~!o2ib;jQfwcNwTq)eH?C zOigX<>?HE8b@lXOr>jW~czE3N^UbTP`>V&p@tq~&^Y8CNrV4-aI$|xi`|pB9fL%F@OmzCY(=;e0>wS=RK-Gq?37^)E@ zZtnPzzP^Hj5HhoTtrChd5K)1c%kRE;t@>mCSjGd(+XLJ|u$AXZCow3cUtV0qhKIvo zAIgi1-}V*iQsM9k2=MXEuQj{NcLp{TD5hDMn80oTu^0(RacQZmn_CYlj-Fn=;E#fW z*`A&+gVVIj?1Y4bC4rXWn(FEw9UPRr%d%A=LfmiQi|kuP^u2-z3|b z<@q)29mFuSAMWpi*!kNx^zdAAUE!X-K3(P{Q@WCTHKw5K)+)Pe9ouhkTmRnxxUE*) zxTysu4|jJoEUZ4}AVsLKJ%}Tg^}Sc-hvdB30*{x(`N*&$dl%dNeL>YOL$J%_FjjGJ zFj=ci=VX07>r7*>zc=IY_IE4}A{yr7(J(uX6^Kjq+|$dHE#;YI%8YZ$=uL-%AaH8LeYV&XT>|)>F$klbFkR_z7C&y8ByOGP%)u zjZT53rH8$d-4XbVUIoWbH_0Fy=SWddS5GL{Yaj$UA*f9uxBCI(lb$)j~?(=mb*hcT7f^$SBG<>PE2c}jLklE%hBRdt31th^$`xsjkv^(BrWtHx~ru+}LN!{Yg3ytUN!HR;Bu)eojnW zcCfNSMMt0f^($-28r0J92G>W^yl;Kvu5NBz`G9*jfeg8%-9qZHk+n6b6lr00c9Xfh zih)7KLFvz4$&@&8yUw4VeyF1dsEK9^QAB}qO$&G+~B!osC6dm4ZLHZW;1 z0c7zy8!`flPhAKqUU2Bxe{H}s3c&@VtR{9LaHiPZoP@2fmy0}}kq3+IpPUE-!r=o} zpu(V{qceZn!C<@{esdi8`eO+4ASYLClm2QT!h{C1T<(O!>WIAD*FX5sxHudVlKAhp z4V|5V&(EE?(jBk&*I>zVka!7+BMfY8Kwcq{(oa^bx%zpxblMJ>UfDL-y<(^WMo7#lJM!? z_VZ%A8hAA6pL7PHfLgY%@54s7v}S0cwVmDl<3$k|f5z3y-rk?T6K^j7d(-#)G;uo}#>vI?^!(s; zB*o3m-2=*1_pi&3^?^g5t-lbjAaxOYq?{kjdmTt zN2$Jj+YiEx48fZv08C&V)|eP!zHeU4bwlIh?2L?$`-PO?sMAtYU0qyI`+qOgqAk@A zo{oVEpAuAPA@`1a@G4kw~nCtD~WZB4Q zNo(tUyl^+zW-!>BmzUFs*y%s!mEYzcMTgD%Z;0h6&;zBVz7!M`vK+`J+7*qiySiySPO1htDs0qnpRXqgoLNAu5LMcE+a1=9uc8s zJjt=ta&rOZ-ou0A-pR>5tRD(eqpVr-%RSZYWFrbR^hGkV)@Zr>)z%cx$Htn^Vs=Nc zHeY}StlVwo<;OkYeAw7`?6{>h9G)jeYHDwK`vNjjQ$rll5MKoTm4z#r!Xf$rW%lPM z=|ZkBE0U_YpCG?aQDBQN(yZgH%*|EI&ido^Uh8$Vg>lx}#00;a9e!^k<8M%R05O-Q z=M%bVZNEHikho`H4r4H9nc3(1`gchJC%{afI)L;xCAH(x<@#q}P%a<075D4NUK@LR za&nrhOE+JidoXQg#-GP)KfwWD`5GAT_v-GDX8nehP(1VN7svfJdE~rZ{Wrpky9=Mm z0xNV}gkcvxGUlYk>FLYZQ&>{3F-KVH4%DwE;M^?Dprz|9B`lL7R4$*JC;GbwK$kG6 z!%AM=7|Dl(fZ$)OQS#1mWM$*Be~TP(=8r8yhF5Dxk1X&mNl9(eVfBQu%7a%5;W13f$SK)=}i$o++!WO#Xq9 zBF1ca2}!#%DhIWt*4D?dv6wL>m<)z&zS;~hr8zS-CCHujC}L#lyjy4cA@9f4AK>ut zkRkhuMbF3xy7<7DBo}J22ud$e`P^L{O2f%OmAU+{?$D&CCj=}23+r&Ud~jfZ3?2LZ z`}c@ni+Rw4#qOEhJw0PD6J45+foE?hF-uVky0x`)kSf$0qcjw!~mgCQ2pF@G%l(aNZBpe(YBP^XdTk%{g-Kh=yhbMNt z!32c3f7`_xJpUI^oD3EtamNKUOb(7J-eH-zpsw-&dq*BrL?1<@F9`o-T!~H^;I;(m z>e|_J$PI66#~+rpwdA+@@C3;s`tZpA_b19f76W%8*GI%i##D$oj4~&{8kK_Df_(?- zskYCTjra!mQFwoNph8~he3VJ{E3IsroC`7kisK9yl-62R#P?<@RSE~$NyoG2>33(` zYjO7MwLg(Oq;IqHoIU0S)xKZ_YY8QJ(2a8?dm6$J9=~_L#zYS4Jj)KnA?)Tz4^Qvj zD6c5>Lx6*8#M0F4yY--VQIj*6n{iJd0ymn}=eSENBAN2JfOxL7(Z%aSAAfZMYIg8W z0n3*v!?hpB9}ORI`TZ%OP|vi{jIjxR0qNa6vW#3;x0rukD73nYp{9+lILux9CwmsU z9%KqZm7+rL{MuI>`e9FALOVR*CioZb(Sz&jsele&?}gnJ<%`fzcJJr>l9Gpqaq|y1 z{tEj}?<>K73_RENLK4kief0D3c|3l(tG2Zz!hYaWr~f80hwp+5^wYr;^P2Mvj46YqKcz~cmg9Q)Hpg{(Ahae%iyW8vU zp7Y*4_x*o+&#Kk4*7Q_Y)vo=Mn)rd9`~&?{4YJk|Ao8Qg&hU?2STpq}^HWS@$hXtxt?=S&0QIY7sI4&+Wapc^*w^Y8Z z&u9fxJHOXTcOOC4oKWGTN~ny$34$Mac1y=lJxe6`(ip==Pm(n0ODJ8Q$-i&?qv-Nz zWNEFr=TkT#a&mH{(HAv`JhmOSwL9mFq|qDs>G`~AJ$=AL`iI8IChOFWII3WR+ziL5 z#N8&YA^r1CruG-(KZChhXmAweJ70{))(3OTO=nD4&Z;gga7aUy>RI^r*z-YzQ9-_lkxQO0%YXa zyL|qc0VJdm3S6`9hI0pM+@87i07s6OAD-u+2hyZX_Grwwp<&RmY@(1eA>Rn%G|xt94T8jIU|%O zPoD}32-wyNLY_pEkqDb~cvjDSpR#p!#D_vNg(+hN$rWI8_H`;&PAd37SDr#bU|^sU zy$cZU?e$wUzg{+Xd*qIDVNvADmr^@SHaA>4;9f2ViWYEollh?WG-zpRD%#*T1~#^< z2Sd?~t6)phZ)m$UxBXIcQCV48Z7t4>9}7A8mS%wxak#X%pXy`N_4T!nxA(&B+)bmi zb*z`S_Zd#_OgZ%4K|Ty<-dfooGHCcH;Zi+rTZ@VIKpZ(`&D{ux$I)Ict(;}iv9fCC zYv5ed=d|L#AV?lc<2fzNViVpP$jj>O?HwIe%hw1e_-FMvOn>UxM=zpmLqq4&WxtV;1JlwvsQ}25ZeW+GvqZa2LZ5kM3SbCqpq#{ ziQ{*ra#?Yc4&7a=D<*eSh`7|hiw(staRqTQPY&9e_)N!FfqDZU! zLqe!aUCKL)exS|)GjZqg>IyAb4R+H1imO2L(pHIHvrK1m(+Tdnee%VJCwMenzy*BJ z`FzI-okmwTxwLdp7wVN?RK)n^O{8s;mrJJfyULdP%TGw{?Lolr+7e7Y3Thr69u}5K z`?}qQ5Ugl8oa~k`gyrqqj~)zDwzcBaKgyTteJ-uO0@q#LY%#G$?{mk9Sw0_ZZ@;>U z@ne^h>p3~WTU=aR_FR2zY-~)=5U{YwZx|)E=e-?ZZ<}wq_LS*O@6lh=UFI+o2I2dYb5=h_+2+W+3>o*9A`*_E?NE z^QPPj@=7^Vl~#LVnVFf(Dk^lDzHz;qR!*ZW`td_vg>oL_GXCq=$qB9_`MPS&CSwWi zB`HcuN+aLnneJ}-_%$6Joyn;wH&&B5@7w`}0HJ_)t;M&~p?L9wJ9Jb+zZRnD*wX!w{xJDV!Ab z)iTmuz`*)D;z+pl*lRdsHOdK`-GoG~U2?e82f#C75x%5=NDnlq51tt6i&aXkY*^5Xa`z7eO0ciw^Y&oUe1owi_?@ zf2(E`gocI=53A&G;FHX!@-UD8{_O(}jo%~^EYJPx??{JC`rQ`_0{AjIEgBtI+9Lqa7Z`%x#uzYc}iTRO3qsqMaA>;_0>ix z3>+K>>#M$*F&!dv3kya<#PP9WP*BkL z#LVAIaRLUF(QQ`;hYQ9SKRg>3aKC}jDIg?t30$*$IKg!c$kVWKJww@H83n9s8X7#3 z*i}11kd9y{P8Y+%S zy~Zpzvzsz4bl;i&&P9cWoPQd)XQ3UZER9cuO|3UA{slk&GJ*WyiKO_Rkdu&E)^dUv zYy{2+hYJLpij2UJVMt&A|1{k}=}}Qp8yl7gt|znnFUiPswY1)n6kfZzxfvP;O&mY? zZ@2hN)z#IlIb+&6Ix@1dg4m5mDJ-rEJ_hyK$1{J-9vm9V&29TW_L&N}BMCKOP|Zvi z{?IOPaS_jz@t@i7K5G4buiK3%M5OjR{tYis+up3oAo8>lF4R$O1HsjcPV=03Uh ztuiw46>Y1m4A+-W>N8JFOsuQ(c*+sTPI*W~>J%vX2MNnTwSfxQ(f zrB)dh0UxR@<3wgF&3cMHiHnGcaB*o8<|Tjq8vMeh`$e=Y7A~$R@F3|@P3o>O?26OU z(lVSO6l`;G>UEOziILT>FvhcJUmk~Sak8Nn%A^aPgmCie51AJs|sw% za#dk4SR1>$WN+Dv=881O^!4W%8E0;9Zd5Yw#)pSvgjGyNGDW2H zpt*$ouWolrRyAyZ_x)}-IMG{M6#xrMXcE)gzI7PL%Rx}eq;!@+P@w&;t;O5frP9@5 z>Xx!tr{LxQ(@Qm-f7pCnQ$xeemH&60Lk5=>@AbQs$Vf%!<7}?evij!EPBEXeEfanH z(4e3{vz6?l8iD!wbd5T_1X%pm$pP<9j**_gL=d?a`A@K|mW!|dT?)8#~c7%=MS9j19jN_xR0!yAPUgSxr8 z#^dP8$q8w%1icdxF|5?v+t>hEfu*Twv&Ug3Fj3ehW5e_nJrEsZ4hn%p2{VjLRaq!L zdz$C$e4Lb&#AMhD5~;(*l2$*T!3u4JPWjpS#f1hj!%sF=R*52sUVQ^#dS>QLedyUr znB6PvZocJodNM79`8z2oDGkjE&Bz*5?`n5;bF(maAGG<(K`;ZT zblFr1mtNVkwX-v76Fs^)=kDsPtgKW}(Cy2u6!KsKQzy(sPc@cb*!R-s@V=vr9m@5M z4G{REW4@*FIUNK(IlVj-<4=zO@-?GMjf{rRc5xZA!rPlx8`)7pV-U0vOQ^%LNJ z7ZAPK8u{>H?(If(Wo6$o;9$Gsb|!RwZs>gJsI4`Lyp9E3hI38+`t`}10u&)vo(R=U z6)QBfQX#h;J3`Eq%uN4_JrAe0dmRWw7=DNzj`&-mTfz8D{Nbn;^=R!A!!u!lKYhrm zxSp_I38zh`YuR%C{(!&IAYyMXJv*D4aGfrB00X7aT`dX;g{s7S?h5wtBQ^T`c)sWs zP4a-JFk=1!EJa?zP-*Fg!a{~`WtvcEjz)GAH9E-TwkNWvwrP_G&`rDW>CjOSLh2d^ zTz?71tc!2eK2FWdBqt>uU;K{f<+QV7rW~QCqYK%i($bs&5(WncqoM|bgS#Fc9!5v; zp9V>|<3>=wziqcZQ+S^k&E2OxSkc&+KG*h zj#A$e)mbS149B<}&FM7n^nAy|1B!zCyHj@acd3wa%V%sey#oWgch^|vou}IB>Z#xb zPS<)r5kNvhA|_^3^=$Vrd3?KAS9V+2+11^hzFsST5c3vGVl;+IEN8sca;UhW;VOKR zpt|0pcZ6L!IQI>D_f>s}Lom7Z}wTxx0Y>Ab)5-QRzrt*y-j9MEHTdJ1P&S4So#(a&-GXl_O!RNLIx z(1$=^x9;JQkyxM#gTXx2dn>D}n}D{^8)-hrg}h=Ne*W29TF#v`r7TejDp5?i)Rq=% z28M478W7oM&z`~TNi&uag!j`D!=Q z5pd(+6vW{6EG%G)=o=WQmz9At5i2?muEx|L?}yK}RL#xJXO~1Rp@19gq!zSiK+@skOZl{~_KC=bXwK+K^_V(m+ z9GQA78%Le$aTvsr3Ph=%G%cqwrrB(etI(t*9TSr{t>OaQs;UDSc?hIABg0TjYi+LD zYH(1COYZ@AxI9c^Iez7*?6~muFANTf(1QcVdPgD>l4Y2C=E~aI+c=A-{{sJAhQegyvop!~^l2#5X-7na(9O+Lt%KTvf&y`4BPdh?xF{*EyJ!dL zB_-wQ#&E{zYOGlN&<-8(Wleqk_D=EBHqZh3OpVUWT=Kju541vpJ9-Wd4$M(E4h}+L z-$NsNs&TNfsp~nU-+)wA!cj5#rujVSGKL~Y%W=xZp`UQTW}-kw_8>B9q2AdHKh(s; zr0GWe zrKQ!P>%i|ho+=@7Og}ilSV>oVOAZD8Ch9`t2G*ma-@Is~cN|D&7xKGt)tfzVY8A*x zOjOQ|#(0W>5#Hnh)VXd)fS6rdFQqN?v#C)Uq_yC9`N=Z?P)UB{bDE!<6A%<6CL&Vq z5_ty!YnN~)>v`nQ+I4kD*_@vyYdkCUH8oMEQc}86J!H&VaUxU-g#-mP;M}VQ zets>vt7@{*;>N0?G8h7guXqFkt-UJt^EJApq=c1`sHpFV?RujNa=FH;=#T{dBLD$* zv9-_Ai;j+L?d|P9-p!VN-~w4L4CXJXc?WXFGJ|@sB7Ede2X8P#I^yHUkD-p(itpc_ z9e?<{we{mn>!qw55Mr7d7$_>>$$>f4IXI|f@h(+w z-u?{>3kx&_)DxMstcpOK2ePv{_&uvDIg^Jhoj`us(o**GW3!JlEd#^t6-&FbsV6@U zox{FDLgL?J`}=z_kk69s-{6NgmXz384!ibf3CcJ53iw~25MzmW4Nqehmk;v7zf3V=ACj&Y#$qaDFyv-u7cQ zf`~bp;vGi?1q}G@m$ehlG>1Ov766lq` zPizZkD*N791c*O3D=HZno1WHKsX1m9Yx~o3xx~!YN|(^r*eDH3kd|iQ9;RKsn3Ize zketPQM)z=Y%MiJ(#UbNW6HZM@IU5!_%+6*9{Uk9lpe;D~`8fyv?Ahhzx3skH^JL-S z;jOLAY6#%~@uP>^!x&L%>XfCiF$rXI0BD1-6+$Ht57#4lc(HI061u?|*dEWXw!(vp z8ybc!NMvMWfD{JQfR!f&H4Vwh$w5I@IASx!PuOS>$@yXP}TR8T~DMTJu- zaQP%aoEqP{<_}f3;uW40Tk_$;D`N7y?NlzH#~H#x+=D=9zS_UNJjBDnX>nzIn#h2Q z7wb8;Zf~?_>VwEw-8Gz^uCBc$3Strx=EMs{+VMAX5A)L(iXAIo z2O3dLOjerQL%I=+Di0e&24Q%QiIA5-X22?U4TtYqZybe?WZNTjCAR@+<7L^at`x>F z--Zw3kcl?J=p-C=h8N&2V1WwSaEYZ9!NiOGQ2{8q<&M7JZ5ia$_~QD z2hzhnb9Z-lNDk&)uvwCxJyKa8g(O9K*RLc)Qm^yoTA<7M#My)?I97Tht1qTNt4ODGy zk72HYBClVk+y5HHQE(N-JcfH#SxX znkvmLEu)O;CR!mOfQ*~l`SEcwJGA+1(Zh-j+U&a4Yf_}uZ(3PZUCj_{k0*$#r3o|2M6&W|5%!Y_djGI(t! z=iD~z%@HF|TTtTT|Jkg6$Hunw>(`&I@YStL*!H+sIbhVLav=0pOE_&oWAa5*IK07X z#7fenv(Zh;Umi48ESfwHp{>4%z$cXJ>qMYLm09fp4W+Iw*M^>}NcNbJ;9y9jt2$HS z#o3v(42AePYGkC7Q7d`9OW}8zA7~wUd(&$h-YmdR!77W1eRg^LL8TQNgrxv+GtBxL za)1TJ&fI*^yy}PbtDnig!17XCTl;%{UUp<-Zcl+<*M=PEJNnho??2AE{mgxM36or=4{UG$0XqBj|7V9Ibtw^z!U8y+(<# z@0c|=0m169%Gj1C9IOH12vhE;RHcHP90?zvO{Xi+Kx8TQCJT~Keg7UC10&xyCTH2q zz~J^Mii8L>CIYE{P=E&H+4*^<==}!=``_UNJ!WQR)m5Q81lca)nQT=O_rP`w=*d4k zfF7@|w>O)9!^f42eVW_{%t~+`62rj5vG;H>DMZnn$Qw-GAV>FyY6<^2wxM!wG=$rIMOSSJx4FcJ1KmZOOv7(u= zu{g^Cvg+`_VIOjUID0BN1;tDS-rBMK*UOfc+w&Qq33wEpyC%cxaW+~e0`68#rKCEC zhlc@BbGF^@pH;xmkTS{tQ>Crror+H6#Y9}%LSY^~hkA%vJ zEz@rP=ociRuzqmUdE`$QeN?G#j0|iF!s49@XcgjV9hmx%1s0vAxK!h7eq^=W+9K85MazN-`#2K!$Ly(RAiT?J%ji@XC~=i$9H$@eNmB+8c;&SpT9+s$wns(c&&yO@vkpRbZQUX*PKX5 X*?T*i{Ju9}4@e3!D$?bW#)1C_M=Cl) diff --git a/internal/guard/testdata/screens/recipe-on-a-preset.xml b/internal/guard/testdata/screens/recipe-on-a-preset.xml index d0366a61..1a2afac7 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 471 B) · 27 formats · will go to /tfg/out + 30 files · 10.0 MB (10 521 721 B) · 27 formats · will go to /tfg/out From 219ca57d211f506edd3ec5aed2a80332b34fc6b1 Mon Sep 17 00:00:00 2001 From: DonislawDev Date: Tue, 6 Oct 2026 19:27:02 +0200 Subject: [PATCH 3/4] text: change_interval and the moving picture in the catalogues, the site, the README and the changelog Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 49 +++++++++++-------- README.md | 2 +- internal/gui/text/locale/registry/en.json | 10 ++++ internal/gui/text/locale/registry/pl.json | 2 + internal/gui/text/locale/said/en.json | 30 ++++++++---- internal/gui/text/locale/said/pl.json | 8 +-- web/public/ar/formats/index.html | 7 ++- .../ar/presets/empty-and-minimal/index.html | 2 +- web/public/cs/formaty/index.html | 7 ++- .../cs/predvolby/empty-and-minimal/index.html | 2 +- web/public/de/formate/index.html | 7 ++- .../de/presets/empty-and-minimal/index.html | 2 +- web/public/es/formatos/index.html | 7 ++- .../es/presets/empty-and-minimal/index.html | 2 +- web/public/formats/index.html | 7 ++- web/public/fr/formats/index.html | 7 ++- .../prereglages/empty-and-minimal/index.html | 2 +- web/public/hi/formats/index.html | 7 ++- .../hi/presets/empty-and-minimal/index.html | 2 +- web/public/id/format/index.html | 7 ++- .../id/preset/empty-and-minimal/index.html | 2 +- web/public/it/formati/index.html | 7 ++- .../it/preset/empty-and-minimal/index.html | 2 +- web/public/ja/formats/index.html | 7 ++- .../ja/presets/empty-and-minimal/index.html | 2 +- web/public/ko/formats/index.html | 7 ++- .../ko/presets/empty-and-minimal/index.html | 2 +- web/public/nl/formaten/index.html | 7 ++- .../nl/presets/empty-and-minimal/index.html | 2 +- web/public/pl/formaty/index.html | 7 ++- .../pl/presety/empty-and-minimal/index.html | 2 +- .../presets/empty-and-minimal/index.html | 2 +- web/public/pt-br/formatos/index.html | 7 ++- .../presets/empty-and-minimal/index.html | 2 +- web/public/ro/formate/index.html | 7 ++- .../ro/presetari/empty-and-minimal/index.html | 2 +- web/public/ru/formats/index.html | 7 ++- .../ru/presets/empty-and-minimal/index.html | 2 +- web/public/th/formats/index.html | 7 ++- .../th/presets/empty-and-minimal/index.html | 2 +- web/public/tr/bicimler/index.html | 7 ++- .../empty-and-minimal/index.html | 2 +- web/public/uk/formats/index.html | 7 ++- .../uk/presets/empty-and-minimal/index.html | 2 +- web/public/vi/dinh-dang/index.html | 7 ++- .../vi/preset/empty-and-minimal/index.html | 2 +- web/public/zh-hans/formats/index.html | 7 ++- .../presets/empty-and-minimal/index.html | 2 +- web/public/zh-hant/formats/index.html | 7 ++- .../presets/empty-and-minimal/index.html | 2 +- 50 files changed, 221 insertions(+), 78 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6f52e29a..badfbd6f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,26 +18,35 @@ because it turns other people's test suites red. - **Films: the `webm` format.** A WebM video with an AV1 picture and no sound, at the exact size you ask for, like every other format. Its length - is a setting of its own, independent of the size: an hour at thirty frames - a second fits in less than a megabyte (977 128 B at the smallest), because - a film that shows one picture costs a few bytes for every frame after the - first. Set `duration`, - `frame_rate` (whole rates from 1 to 60), `keyframe_interval` - how far - apart the frames a player can start from are - `width`, `height` and - `quality`. The picture is the gradient with the self describing label the - image formats draw, and it stays the same for the whole film. 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 test can check: `duration_ms`, `frame_count`, `frame_rate`, - `keyframe_count`, `width`, `height`, `compression: av1` and `audio: false`. - A length that does not end on a frame is refused with the two nearest that - do - at 30 frames a second lengths go in steps of 100ms. Below the - smallest film the settings allow, the refusal says how small it can be and - which settings make it smaller. AV1 plays in current browsers, and an older - player or a pipeline that expects H.264 may refuse it - which is a test - worth having. The window lists it under Video. -- **A length of time as a setting.** `duration` and `keyframe_interval` take + is a setting of its own, independent of the size. The picture moves: every + `change_interval`, one second unless you say otherwise, the clock in it + moves on and a square takes a step across it, so a player that plays it + 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 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 + or longer keeps one picture throughout (977 434 B for that hour). Set + `duration`, `change_interval`, `frame_rate` (whole rates from 1 to 60), + `keyframe_interval` - how far apart the frames a player can start from + 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 + 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 + frame is refused with the two nearest that do - at 30 frames a second + lengths go in steps of 100ms. Below the smallest film the settings allow, + the refusal says how small it can be and which settings make it smaller. + AV1 plays in current browsers, and an older player or a pipeline that + expects H.264 may refuse it - which is a test worth having. The window + lists it under Video. +- **A length of time as a setting.** `duration`, `change_interval` and `keyframe_interval` take `10s`, `1m30s`, `1h`, `500ms` or `59.9s`, to the millisecond. A bare number is refused rather than read as seconds, and so is a length that does not land on a whole millisecond. diff --git a/README.md b/README.md index 0d88429c..87dbefe9 100644 --- a/README.md +++ b/README.md @@ -606,7 +606,7 @@ recipe. `tfg formats ` prints the allowed range or list for each: | `avif`, `jpg`, `jxl` | `width`, `height`, `quality` | | `ico` | `width`, `height`, `embed` | | `wav` | `sample_rate`, `bit_depth`, `channels`, `content` | -| `webm` | `width`, `height`, `duration`, `frame_rate`, `keyframe_interval`, `quality` | +| `webm` | `width`, `height`, `duration`, `change_interval`, `frame_rate`, `keyframe_interval`, `quality` | | `zip` | `entries`, `entry_format`, `entry_size`, `compression`, `depth`, `directory_entries`, `password`, `encryption` | | `targz` | `entries`, `entry_format`, `entry_size`, `compression`, `depth`, `directory_entries`, `entry_mode`, `entry_owner` | | `docx` | `paragraphs` | diff --git a/internal/gui/text/locale/registry/en.json b/internal/gui/text/locale/registry/en.json index b51d27f0..a85e9a65 100644 --- a/internal/gui/text/locale/registry/en.json +++ b/internal/gui/text/locale/registry/en.json @@ -1224,6 +1224,11 @@ "hash": "sha256-71e4eb77e129", "other": "How many samples a second the signal carries." }, + "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." + }, "Detail.format/webm.duration": { "description": "The sentence under the duration setting of the webm format, after what it takes.", "hash": "sha256-adacce8ce234", @@ -1639,6 +1644,11 @@ "hash": "sha256-4d8000301fcc", "other": "Bytes" }, + "Label.change_interval": { + "description": "The name beside the box of a setting a recipe writes as change_interval.", + "hash": "sha256-e99a12a7d82d", + "other": "Change interval" + }, "Label.channels": { "description": "The name beside the box of a setting a recipe writes as channels.", "hash": "sha256-4c8906cf76f5", diff --git a/internal/gui/text/locale/registry/pl.json b/internal/gui/text/locale/registry/pl.json index bf1c748c..86da57af 100644 --- a/internal/gui/text/locale/registry/pl.json +++ b/internal/gui/text/locale/registry/pl.json @@ -244,6 +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.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." }, @@ -327,6 +328,7 @@ "Label.bom": { "hash": "sha256-e39338101951", "other": "BOM" }, "Label.bulk": { "hash": "sha256-3140100df131", "other": "Przesyłanie masowe" }, "Label.bytes": { "hash": "sha256-4d8000301fcc", "other": "Bajty" }, + "Label.change_interval": { "hash": "sha256-e99a12a7d82d", "other": "Odstęp zmian obrazu" }, "Label.channels": { "hash": "sha256-4c8906cf76f5", "other": "Kanały" }, "Label.checksum_file": { "hash": "sha256-6413f83e729a", "other": "Plik sum kontrolnych" }, "Label.columns": { "hash": "sha256-53aade77cd69", "other": "Kolumny" }, diff --git a/internal/gui/text/locale/said/en.json b/internal/gui/text/locale/said/en.json index afb9d192..e1a2c504 100644 --- a/internal/gui/text/locale/said/en.json +++ b/internal/gui/text/locale/said/en.json @@ -3146,6 +3146,11 @@ "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", + "other": "The picture is {{.Width}}x{{.Height}} and the clock needs more room, so this film shows no clock. The square still steps across it where it has room to move." + }, "video.NotOnAFrame": { "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 {{.FPS}}, {{.Step}}, {{.Below}}, {{.Above}}, written the way the program writes them - keep each spelled exactly that way.", "hash": "sha256-4471642f4d67", @@ -3156,6 +3161,11 @@ "hash": "sha256-e6b01f8c0aeb", "other": "at {{.FPS}} frames a second a length has to end on a frame, and lengths at that rate go in steps of {{.Step}}. Ask for {{.Above}}, the shortest film at this rate" }, + "video.TooManyChanges": { + "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 {{.Duration}}, {{.Interval}}, {{.Pictures}}, {{.Most}}, {{.Shortest}}, written the way the program writes them - keep each spelled exactly that way.", + "hash": "sha256-79ad4cf6e19b", + "other": "a film of {{.Duration}} with a new picture every {{.Interval}} shows {{.Pictures}} pictures, and a film can show at most {{.Most}}. Ask for {{.Shortest}} or longer, or a shorter film" + }, "video.TooManyKeyFrames": { "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 {{.Duration}}, {{.Interval}}, {{.Keys}}, {{.Most}}, {{.Shortest}}, written the way the program writes them - keep each spelled exactly that way.", "hash": "sha256-fab3e045a284", @@ -3166,20 +3176,20 @@ "hash": "sha256-b8bfddcdab7d", "other": ", or drop the label" }, - "webm.AskForBOrMore": { + "webm.AskForBOrMoreFilmPictures": { "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 {{.Floor}}, written the way the program writes them - keep each spelled exactly that way.", - "hash": "sha256-dcb48388a704", - "other": "Ask for {{.Floor}} B or more, or a shorter film, fewer frames a second, key frames further apart or a smaller picture" + "hash": "sha256-faee56eaa68b", + "other": "Ask for {{.Floor}} B or more, or a shorter film, fewer frames a second, key frames further apart or a new picture less often" }, - "webm.AskForBOrMoreFilm": { + "webm.AskForBOrMorePictures": { "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 {{.Floor}}, written the way the program writes them - keep each spelled exactly that way.", - "hash": "sha256-030aa26d50a8", - "other": "Ask for {{.Floor}} B or more, or a shorter film, fewer frames a second or key frames further apart" + "hash": "sha256-504e7380dc2f", + "other": "Ask for {{.Floor}} B or more, or a shorter film, fewer frames a second, key frames further apart, a new picture less often or a smaller picture" }, - "webm.MinimumReason": { - "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 {{.Duration}}, {{.FPS}}, {{.Interval}}, {{.Width}}, {{.Height}}, {{.Bytes}}, {{.Void}}, written the way the program writes them - keep each spelled exactly that way.", - "hash": "sha256-736b751cfd83", - "other": "a film of {{.Duration}} at {{.FPS}} frames a second with a key frame every {{.Interval}} and a {{.Width}}x{{.Height}} picture takes {{.Bytes}} B, and the file always carries a Void element, which costs {{.Void}} B even when it holds nothing" + "webm.MinimumReasonPictures": { + "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 {{.Duration}}, {{.FPS}}, {{.Interval}}, {{.Width}}, {{.Height}}, {{.Change}}, {{.Bytes}}, {{.Void}}, written the way the program writes them - keep each spelled exactly that way.", + "hash": "sha256-613f54fd8e25", + "other": "a film of {{.Duration}} at {{.FPS}} frames a second with a key frame every {{.Interval}} and a new {{.Width}}x{{.Height}} picture every {{.Change}} takes {{.Bytes}} B, and the file always carries a Void element, which costs {{.Void}} B even when it holds nothing" }, "xlsx.Columns": { "description": "Said by internal/format/xlsx - a refusal under the box it is about or at the foot of the form, or a note after a run. Carries {{.Count}}, written the way the program writes them - keep each spelled exactly that way.", diff --git a/internal/gui/text/locale/said/pl.json b/internal/gui/text/locale/said/pl.json index 5dad1c25..a9fb5748 100644 --- a/internal/gui/text/locale/said/pl.json +++ b/internal/gui/text/locale/said/pl.json @@ -626,13 +626,15 @@ "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" }, + "video.TooManyChanges": { "hash": "sha256-79ad4cf6e19b", "other": "liczba obrazów w filmie o długości {{.Duration}} z nowym obrazem co {{.Interval}} to {{.Pictures}}, a film może pokazać najwyżej {{.Most}}. Poproś o {{.Shortest}} albo dłużej albo o krótszy film" }, "video.TooManyKeyFrames": { "hash": "sha256-fab3e045a284", "other": "film o długości {{.Duration}} z klatką kluczową co {{.Interval}} ma ich {{.Keys}}, a film może mieć najwyżej {{.Most}}. Poproś o {{.Shortest}} albo dłużej albo o krótszy film" }, "wav.DropTheLabel": { "hash": "sha256-b8bfddcdab7d", "other": " albo usuń etykietę" }, - "webm.AskForBOrMore": { "hash": "sha256-dcb48388a704", "other": "Poproś o {{.Floor}} B albo więcej albo o krótszy film, mniej klatek na sekundę, rzadsze klatki kluczowe albo mniejszy obraz" }, - "webm.AskForBOrMoreFilm": { "hash": "sha256-030aa26d50a8", "other": "Poproś o {{.Floor}} B albo więcej albo o krótszy film, mniej klatek na sekundę albo rzadsze klatki kluczowe" }, - "webm.MinimumReason": { "hash": "sha256-736b751cfd83", "other": "film o długości {{.Duration}} przy {{.FPS}} klatkach na sekundę z klatką kluczową co {{.Interval}} i obrazem {{.Width}}x{{.Height}} zajmuje {{.Bytes}} B, a plik zawsze niesie element Void, który kosztuje {{.Void}} B nawet pusty" }, + "webm.AskForBOrMoreFilmPictures": { "hash": "sha256-faee56eaa68b", "other": "Poproś o {{.Floor}} B albo więcej albo o krótszy film, mniej klatek na sekundę, rzadsze klatki kluczowe albo rzadszą zmianę obrazu" }, + "webm.AskForBOrMorePictures": { "hash": "sha256-504e7380dc2f", "other": "Poproś o {{.Floor}} B albo więcej albo o krótszy film, mniej klatek na sekundę, rzadsze klatki kluczowe, rzadszą zmianę obrazu albo mniejszy obraz" }, + "webm.MinimumReasonPictures": { "hash": "sha256-613f54fd8e25", "other": "film o długości {{.Duration}} przy {{.FPS}} klatkach na sekundę z klatką kluczową co {{.Interval}} i nowym obrazem {{.Width}}x{{.Height}} co {{.Change}} zajmuje {{.Bytes}} B, a plik zawsze niesie element Void, który kosztuje {{.Void}} B nawet pusty" }, "xlsx.Columns": { "hash": "sha256-5a53b3c96cb2", "one": "{{.Count}} kolumnę", "few": "{{.Count}} kolumny", "many": "{{.Count}} kolumn", "other": "{{.Count}} kolumny" }, "xlsx.JointFewer": { "hash": "sha256-15c3bae806db", "other": "{{.Why}}. Poproś o mniej wierszy albo mniej kolumn" }, "xlsx.Rows": { "hash": "sha256-244cc1fe9311", "one": "{{.Count}} wiersz", "few": "{{.Count}} wiersze", "many": "{{.Count}} wierszy", "other": "{{.Count}} wiersza" }, diff --git a/web/public/ar/formats/index.html b/web/public/ar/formats/index.html index 1858bf22..59c5c172 100644 --- a/web/public/ar/formats/index.html +++ b/web/public/ar/formats/index.html @@ -335,7 +335,7 @@

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

webm WebM video .webm - 3044 + 3294 full libaom @@ -792,6 +792,11 @@

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

duration مدة مثل 10s + + + change_interval + مدة مثل 10s + frame_rate diff --git a/web/public/ar/presets/empty-and-minimal/index.html b/web/public/ar/presets/empty-and-minimal/index.html index 82418252..c20c20b8 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 711 B + الحجم الإجمالي35 961 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 e60305b8..4b18bc9d 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 - 3044 + 3294 full libaom @@ -793,6 +793,11 @@

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

duration délka, například 10s + + + change_interval + délka, například 10s + frame_rate diff --git a/web/public/cs/predvolby/empty-and-minimal/index.html b/web/public/cs/predvolby/empty-and-minimal/index.html index 2b1b17be..f481ec0e 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 711 B + Celková velikost35 961 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 e55702ab..094b32c5 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 - 3044 + 3294 full libaom @@ -795,6 +795,11 @@

Einstellungen, die jedes Format kennt

duration eine Dauer wie 10s + + + change_interval + eine Dauer wie 10s + frame_rate diff --git a/web/public/de/presets/empty-and-minimal/index.html b/web/public/de/presets/empty-and-minimal/index.html index 978943c1..4a2f4b01 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 711 B + Gesamtgröße35 961 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 9ea06517..327a776b 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 - 3044 + 3294 full libaom @@ -796,6 +796,11 @@

Ajustes que admite cada formato

duration una duración como 10s + + + change_interval + una duración como 10s + frame_rate diff --git a/web/public/es/presets/empty-and-minimal/index.html b/web/public/es/presets/empty-and-minimal/index.html index 907cedfc..566ce537 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 711 B + Tamaño total35 961 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 317a6054..47b473af 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 - 3044 + 3294 full libaom @@ -794,6 +794,11 @@

Settings each format accepts

duration a length of time such as 10s + + + change_interval + a length of time such as 10s + frame_rate diff --git a/web/public/fr/formats/index.html b/web/public/fr/formats/index.html index 66a433c3..c8272a68 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 - 3044 + 3294 full libaom @@ -796,6 +796,11 @@

Réglages que chaque format accepte

duration une durée comme 10s + + + change_interval + une durée comme 10s + frame_rate diff --git a/web/public/fr/prereglages/empty-and-minimal/index.html b/web/public/fr/prereglages/empty-and-minimal/index.html index 88bfca8d..751f12ed 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 711 B + Taille totale35 961 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 fdc58ed2..f80733e6 100644 --- a/web/public/hi/formats/index.html +++ b/web/public/hi/formats/index.html @@ -336,7 +336,7 @@

27 फ़ाइल फ़ॉर्मैट, हर एक सटीक आ webm WebM video .webm - 3044 + 3294 full libaom @@ -794,6 +794,11 @@

हर फ़ॉर्मैट की स्वीकार की जा duration 10s जैसी अवधि + + + change_interval + 10s जैसी अवधि + frame_rate diff --git a/web/public/hi/presets/empty-and-minimal/index.html b/web/public/hi/presets/empty-and-minimal/index.html index f9f18137..19c94b3d 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 711 B + कुल आकार35 961 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 6b580e3a..3824b975 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 - 3044 + 3294 full libaom @@ -794,6 +794,11 @@

Pengaturan yang diterima setiap format

duration durasi seperti 10s + + + change_interval + durasi seperti 10s + frame_rate diff --git a/web/public/id/preset/empty-and-minimal/index.html b/web/public/id/preset/empty-and-minimal/index.html index 3d9d551f..678f699f 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 711 B + Ukuran total35 961 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 5161887f..86e63199 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 - 3044 + 3294 full libaom @@ -796,6 +796,11 @@

Impostazioni che ogni formato accetta

duration una durata come 10s + + + change_interval + una durata come 10s + frame_rate diff --git a/web/public/it/preset/empty-and-minimal/index.html b/web/public/it/preset/empty-and-minimal/index.html index 8f3d652b..d382fd82 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 711 B + Dimensione totale35 961 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 4a3dd34d..65c3cb77 100644 --- a/web/public/ja/formats/index.html +++ b/web/public/ja/formats/index.html @@ -334,7 +334,7 @@

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

webm WebM video .webm - 3044 + 3294 full libaom @@ -786,6 +786,11 @@

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

duration 10sのような長さ + + + change_interval + 10sのような長さ + frame_rate diff --git a/web/public/ja/presets/empty-and-minimal/index.html b/web/public/ja/presets/empty-and-minimal/index.html index 92d1bd7e..85210205 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 711 B + 合計サイズ35 961 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 15ea4812..a68a3d58 100644 --- a/web/public/ko/formats/index.html +++ b/web/public/ko/formats/index.html @@ -334,7 +334,7 @@

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

webm WebM video .webm - 3044 + 3294 full libaom @@ -787,6 +787,11 @@

각 형식이 받는 설정

duration 10s와 같은 길이 + + + change_interval + 10s와 같은 길이 + frame_rate diff --git a/web/public/ko/presets/empty-and-minimal/index.html b/web/public/ko/presets/empty-and-minimal/index.html index 39dd26e1..51af8c71 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 711 B + 총 크기35 961 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 12f1fea1..304edce2 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 - 3044 + 3294 full libaom @@ -796,6 +796,11 @@

Instellingen die elk formaat accepteert

duration een duur zoals 10s + + + change_interval + een duur zoals 10s + frame_rate diff --git a/web/public/nl/presets/empty-and-minimal/index.html b/web/public/nl/presets/empty-and-minimal/index.html index ca3a7d35..4420362d 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 711 B + Totale grootte35 961 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 920adcd2..f2eec7b0 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 - 3044 + 3294 full libaom @@ -794,6 +794,11 @@

Ustawienia, które przyjmuje każdy format

duration czas trwania, na przykład 10s + + + change_interval + czas trwania, na przykład 10s + frame_rate diff --git a/web/public/pl/presety/empty-and-minimal/index.html b/web/public/pl/presety/empty-and-minimal/index.html index e65ad4fa..7297afe6 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 711 B + Łączny rozmiar35 961 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 9010f51a..9898ddb2 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 711 B + Total size35 961 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 34aac237..3f72d3f3 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 - 3044 + 3294 full libaom @@ -795,6 +795,11 @@

Configurações que cada formato aceita

duration uma duração como 10s + + + change_interval + uma duração como 10s + frame_rate 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 132cf2f5..23c01837 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 711 B + Tamanho total35 961 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 ed2ace86..8f022da0 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 - 3044 + 3294 full libaom @@ -795,6 +795,11 @@

Setările pe care le acceptă fiecare format

duration o durată precum 10s + + + change_interval + o durată precum 10s + frame_rate diff --git a/web/public/ro/presetari/empty-and-minimal/index.html b/web/public/ro/presetari/empty-and-minimal/index.html index 5b6eea4b..86615cf9 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 711 B + Dimensiune totală35 961 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 8202b530..a098fc7b 100644 --- a/web/public/ru/formats/index.html +++ b/web/public/ru/formats/index.html @@ -336,7 +336,7 @@

27 форматов файлов, каждый создаётся точно webm WebM video .webm - 3044 + 3294 full libaom @@ -794,6 +794,11 @@

Настройки, которые принимает каждый форм duration длительность, например 10s + + + change_interval + длительность, например 10s + frame_rate diff --git a/web/public/ru/presets/empty-and-minimal/index.html b/web/public/ru/presets/empty-and-minimal/index.html index deca1e9b..3d38b658 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 711 B + Общий размер35 961 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 2d88661b..85bafd0a 100644 --- a/web/public/th/formats/index.html +++ b/web/public/th/formats/index.html @@ -335,7 +335,7 @@

27 รูปแบบไฟล์ แต่ละรูปแบบสร้ webm WebM video .webm - 3044 + 3294 full libaom @@ -792,6 +792,11 @@

การตั้งค่าที่แต่ละรูปแบบร duration ระยะเวลา เช่น 10s + + + change_interval + ระยะเวลา เช่น 10s + frame_rate diff --git a/web/public/th/presets/empty-and-minimal/index.html b/web/public/th/presets/empty-and-minimal/index.html index 9a9194b0..4bd03b9e 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 711 B + ขนาดรวม35 961 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 7852c859..13d29766 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 - 3044 + 3294 full libaom @@ -794,6 +794,11 @@

Her biçimin kabul ettiği ayarlar

duration 10s gibi bir süre + + + change_interval + 10s gibi bir süre + frame_rate 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 0a80ff30..059aceb6 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 711 B + Toplam boyut35 961 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 738eab96..7bcfad1d 100644 --- a/web/public/uk/formats/index.html +++ b/web/public/uk/formats/index.html @@ -336,7 +336,7 @@

27 форматів файлів, кожен створюється точн webm WebM video .webm - 3044 + 3294 full libaom @@ -794,6 +794,11 @@

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

duration тривалість, наприклад 10s + + + change_interval + тривалість, наприклад 10s + frame_rate diff --git a/web/public/uk/presets/empty-and-minimal/index.html b/web/public/uk/presets/empty-and-minimal/index.html index 00813eb1..14657443 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 711 B + Загальний розмір35 961 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 e72ea350..f656605b 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 - 3044 + 3294 full libaom @@ -792,6 +792,11 @@

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

duration thời lượng như 10s + + + change_interval + thời lượng như 10s + frame_rate diff --git a/web/public/vi/preset/empty-and-minimal/index.html b/web/public/vi/preset/empty-and-minimal/index.html index dfb3efa4..94556532 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 711 B + Tổng kích thước35 961 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 937fce74..10d16362 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 - 3044 + 3294 full libaom @@ -786,6 +786,11 @@

每种格式接受的设置

duration 形如 10s 的时长 + + + change_interval + 形如 10s 的时长 + frame_rate 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 71d60832..f4f8fdbe 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 711 B + 总大小35 961 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 fa111092..40ff9907 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 - 3044 + 3294 full libaom @@ -786,6 +786,11 @@

每種格式接受的設定

duration 形如 10s 的時長 + + + change_interval + 形如 10s 的時長 + frame_rate 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 bdf6b6bb..ba4a970a 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 711 B + 總大小35 961 B 格式avif, bmp, csv, docx, gif, html, ico, jpg, json, jxl, log, md, pdf, png, pptx, svg, targz, tiff, toml, txt, wav, webm, webp, xlsx, xml, yaml, zip From 838d69673394ce90a15733715ea48b6f15af330c Mon Sep 17 00:00:00 2001 From: DonislawDev Date: Tue, 6 Oct 2026 19:39:20 +0200 Subject: [PATCH 4/4] review #166: the picture-change oracle wraps the error it returns, and two words of the changelog golangci-lint's errorlint: a failed ffmpeg run is wrapped with %w, and kept apart from what ffmpeg says on standard error, which is the verdict. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 4 ++-- internal/oracle/oracle.go | 9 +++++++-- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index badfbd6f..88b64247 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -35,13 +35,13 @@ because it turns other people's test suites red. 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 + 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 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 frame is refused with the two nearest that do - at 30 frames a second - lengths go in steps of 100ms. Below the smallest film the settings allow, + lengths go in steps of `100ms`. Below the smallest film the settings allow, the refusal says how small it can be and which settings make it smaller. AV1 plays in current browsers, and an older player or a pipeline that expects H.264 may refuse it - which is a test worth having. The window diff --git a/internal/oracle/oracle.go b/internal/oracle/oracle.go index 4efa240a..e73e169f 100644 --- a/internal/oracle/oracle.go +++ b/internal/oracle/oracle.go @@ -394,8 +394,13 @@ func PictureChanges(path string) ([]int64, Result) { var out, errOut strings.Builder cmd.Stdout = &out cmd.Stderr = &errOut - if err := cmd.Run(); err != nil || strings.TrimSpace(errOut.String()) != "" { - res.Err = fmt.Errorf("libaom complained: %s %v", strings.TrimSpace(errOut.String()), err) + runErr := cmd.Run() + if said := strings.TrimSpace(errOut.String()); said != "" { + res.Err = fmt.Errorf("libaom complained: %s", said) + return nil, res + } + if runErr != nil { + res.Err = fmt.Errorf("ffmpeg did not decode the film: %w", runErr) return nil, res } var changes []int64