Files
Privatae e4c9b69b46
ci / personal-data (push) Successful in 5s
ci / test (push) Successful in 3m4s
ci / fuzz (push) Successful in 3m45s
bastion 0.53.2
2026-10-20 04:42:14 +00:00

653 lines
27 KiB
Go

package main
// contextframes.go — a clip becomes FRAMES, on the user's own machine.
//
// This finishes the video half the picture lane left open. Everything else in the
// extraction lane turns bytes into TEXT; this one does not, and that difference
// is the whole design problem. contextextract.go's reply carries a `text`
// field, the host chunks it and files it as ordinary context rows, and the text
// embedding lane picks them up. None of that works for a picture.
//
// So the reply grew a SECOND shape — `frames`, each one a downscaled jpeg and
// the millisecond offset it was taken at — and the host files those into the
// MEDIA lane (its media_parent_id / media_offset_ms columns), never the
// text one. Extracted text counts as text, frames count as media, and neither
// borrows the other's denominator.
//
// ── WHY KEYFRAMES, AND NOT SCENE DETECTION ──────────────────────────────────
//
// MEASURED on a 10-minute 1080p30 clip (18,000 frames), ffmpeg 9.0:
// scene detection cost 96.6 CPU-s and found 49 frames; taking the keyframes
// cost 2.1 CPU-s and found 120. On one core that is 115.5 s against 2.2 s.
// Scene detection costs 45x the CPU AND finds FEWER frames, because x264
// already ran scene detection when the video was encoded and marked each cut as
// a keyframe — re-deriving them decodes all 18,000 frames to recompute what the
// container already records. That supersedes the picture lane's original "sample
// at scene cuts" recommendation, which was written before anything was measured.
//
// RE-CONFIRMED HERE on ffmpeg 8.0.1 / libx264 (the version this was built
// against, not the 9.0 of the original measurement) with a four-scene clip cut
// at 0/6/12/18 s: the encoder's keyframes landed at exactly 0.000000, 6.000000,
// 12.000000 and 18.000000. The premise holds on this build.
//
// One caveat that is NOT a contradiction and is worth writing down, because it
// shapes the acceptance test: x264's scenecut is a RATE decision, not a pixel
// diff. A cut between two flat colour fields is cheap to inter-predict, so the
// encoder does not spend an IDR on it and the cut gets no keyframe. Textured
// content cuts do. A test built on solid colours would "prove" keyframes miss
// scene changes and would be measuring the test's own synthetic input.
//
// ── WHAT STOPS A TWO-HOUR FILM FROM ARRIVING WHOLE ──────────────────────────
//
// A clip's keyframe count is the encoder's choice and scales with length: the
// measured 10-minute clip had 120, a feature film has thousands. Two separate
// walls bound it, and which one binds is content-dependent:
//
// COUNT — ctxFramesMaxCount, a policy number with production precedent.
// BYTES — ctxFramesMaxBytes, the transport, and the same 8 MiB wall the
// request side already lives behind.
//
// When a clip exceeds either, the frames that survive are spread ACROSS THE
// TIMELINE rather than taken from the front. First-N would index the opening
// titles of every film and nothing else.
//
// ── THE DECODE IS THE USER'S, THE VECTOR IS THE HOST'S ──────────────────────
//
// This hands back frames, never vectors. Vectors would need the bastion to
// hold the media model too, which forks the code the moment someone's decoder
// is at home and their model is at a provider. So the box decodes and the
// host embeds, through the media lane it already has.
//
// Nothing here keeps a frame either. The host stores the VECTOR and the
// TIMESTAMP and drops the bytes — measured at 416 kB against
// 4.97 MB per clip, 12x — because the clip itself is already stored and any
// frame reproduces by seeking to its offset in about 150 ms.
import (
"context"
"encoding/base64"
"encoding/json"
"fmt"
"math"
"os"
"os/exec"
"path/filepath"
"sort"
"strconv"
"strings"
"time"
"github.com/gorilla/websocket"
)
// ctxFramesTool is the job name the host asks for. A job, not a program — the
// same rule the rest of the table follows, so swapping ffmpeg later is not a
// host-visible change.
const ctxFramesTool = "video-frames"
// ctxFramesWidth is the long edge frames are scaled to before they cross the
// wire. 384 px because that is what CLIP-family image encoders consume anyway,
// so a larger frame is bytes the model throws away. Measured: the
// difference is 2.61 MB for 4 full-resolution frames against 265 kB downscaled,
// about 10x.
const ctxFramesWidth = 384
// ctxFramesJpegQ is ffmpeg's -q:v for mjpeg, where 2 is best and 31 worst.
// 3 measured at 6.5 kB per 384 px frame on synthetic test content here and is
// consistent with the 28 kB per frame measured on real footage. The budget below
// is enforced on the ACTUAL bytes, so this number sets typical size and never
// decides whether the reply fits.
const ctxFramesJpegQ = "3"
// ctxFramesMaxCount is the most frames ONE job returns, and it is the number
// this file exists to justify.
//
// PRECEDENT, read from the vendors' own interface documents rather than from
// reputation. Production video indexing does not keep every frame; it keeps one
// embedding per interval, and the interval is a small band:
//
// - TwelveLabs Marengo Embed 2.7 (AWS Bedrock, "TwelveLabs Marengo Embed 2.7
// request parameters"): useFixedLengthSec takes 2-10 seconds per clip, and
// minClipSec defaults to 4 with a range of 1-5. Left alone it segments by
// shot boundary — the same thing the encoder's keyframes already record.
// - Google Vertex AI multimodal embeddings: the default videoSegmentConfig is
// {startOffsetSec: 0, endOffsetSec: 120, intervalSec: 16}, one embedding
// per 16 seconds, and one request covers at most 120 seconds of video.
//
// So the production band is one frame per 4-16 seconds. 240 frames is 64
// minutes at Vertex's 16 s default and 16 minutes at Marengo's 4 s floor — it
// covers an ordinary upload whole, and a feature film gets the sparse end of
// the same band rather than a refusal.
//
// It is a ceiling on ONE JOB, never on the user: a clip past it is indexed
// across its whole length at lower density, and re-running against a future
// build with a bigger number costs one decode of a file that is still stored.
const ctxFramesMaxCount = 240
// ctxFramesMaxBytes is the transport wall, and it is deliberately the SAME
// number the inline request path uses. A management job is one WebSocket frame
// and uvicorn's 16 MiB ws_max_size is reached at just under 12 MiB of
// plaintext, so 8 MiB of jpeg is ~10.7 MiB of base64 and leaves the JSON
// envelope the room ctxExtractMaxBytes was chosen to leave it.
//
// At the measured 28 kB per 384 px frame, 240 frames is 6.7 MB and the
// COUNT binds first. On unusually detailed footage the bytes bind first. Which
// one bound is reported, so a short reply is never a mystery.
const ctxFramesMaxBytes = ctxExtractMaxBytes
// ctxFramesTargetGapS is how thin is TOO THIN.
//
// Keyframe cadence is the encoder's choice: the measured clip ran one per 5 s,
// but a long static screen recording can go a minute between them and would be
// indexed at one frame a minute. Past this gap the extractor seeks at fixed
// offsets to fill it in — measured at 60 ms per seek on this box and ~150 ms on
// real footage, bounded, nothing like a full decode.
//
// 16 seconds is Vertex AI's default intervalSec, quoted above. Taking a
// production system's DEFAULT rather than its floor is deliberate: every
// topped-up frame costs a seek, and the floor (Marengo's 4 s) would quadruple
// that cost on exactly the content that needs it least.
const ctxFramesTargetGapS = 16.0
// ctxFramesTopUpBudget bounds the seeking, in TIME rather than in a count,
// because the cost of a seek varies with the file and the disk. Whatever fits
// in this is added; the reply says how many were added and whether the budget
// stopped it early.
const ctxFramesTopUpBudget = 30 * time.Second
// ctxFramesTimeout is this job's own ceiling, and it is not ctxExtractTimeout.
// A text extractor reads one document; this decodes a file that can be hours
// long and then seeks through the thin parts of it. 20 s is right for poppler
// and would kill a film before its keyframe pass finished.
//
// IT IS NOT MIRRORED ON THE HOST, and it is reached through exactly one
// mapping: ctxExtractTimeoutFor. That function fills prepToolReport.CeilingS,
// the host reads the number off the advertisement, waits it plus a round trip,
// and grants the same number back as extract_budget — which ctxExtractBudget
// then clamps DOWN to here. A copy of this constant in the host's source would
// be right until the first build that changed it, and then the host would give
// up first and call a working machine unresponsive.
const ctxFramesTimeout = 120 * time.Second
// ctxFramesProbeTimeout bounds the container read that lists the keyframes. It
// decodes nothing, so it is fast even on a long file; this exists so a
// pathological or truncated container cannot hold a worker.
const ctxFramesProbeTimeout = 30 * time.Second
// ctxFrame is one frame as the host receives it: where it came from in the
// clip, and the picture.
type ctxFrame struct {
// OffsetMs is the frame's position in the clip. It lands in
// the host's media_offset_ms column, which exists for exactly
// this, and it is what lets a search hit say "4:32 into the clip" and
// reproduce the frame by seeking rather than by storing it.
OffsetMs int64 `json:"offset_ms"`
// B64 is the jpeg. The host embeds it and drops it.
B64 string `json:"b64"`
// Bytes is the jpeg's true length, so the host does not have to decode the
// base64 to know what it is holding.
Bytes int `json:"bytes"`
// Source is "keyframe" or "topup" — whether the encoder marked this moment
// or the extractor filled a thin stretch. Carried because they are
// different facts about the clip, and a clip that is mostly top-up is a
// clip whose encoder barely marked anything.
Source string `json:"source"`
}
// ctxFramesReport is the honesty half: what the clip HAD against what came
// back, and which wall cut it. Without this a 240-frame reply from a two-hour
// film is indistinguishable from a complete one.
type ctxFramesReport struct {
DurationMs int64 `json:"duration_ms"`
// Keyframes is how many the container records — the number before any
// decimation, top-up or budget.
Keyframes int `json:"keyframes"`
// Stride is 1 when every keyframe survived, n when every nth did.
Stride int `json:"stride"`
// ToppedUp is how many fixed-offset seeks filled thin stretches.
ToppedUp int `json:"topped_up"`
// TopUpCutShort is true when ctxFramesTopUpBudget ran out with gaps left.
TopUpCutShort bool `json:"topup_cut_short,omitempty"`
// Returned is how many frames are in the reply.
Returned int `json:"returned"`
// Bytes is their combined jpeg size.
Bytes int `json:"bytes"`
// LimitedBy is "", "count" or "bytes" — which wall decided Returned. Said
// out loud so nobody has to infer it from the numbers.
LimitedBy string `json:"limited_by,omitempty"`
Width int `json:"width"`
}
// ctxFramesResp is the reply. Deliberately NOT ctxExtractResp with a frames
// field bolted on: a reader of either struct should be able to tell what kind
// of answer it is holding without checking whether a string is empty.
type ctxFramesResp struct {
Tool string `json:"tool"`
Source string `json:"source,omitempty"`
Mime string `json:"mime"`
Frames []ctxFrame `json:"frames"`
Report ctxFramesReport `json:"report"`
}
// frameTools are the jobs whose answer is FRAMES rather than text. A tool is in
// exactly one runner: extractArgv (text) or here. The drift gate in
// contextprep_test.go checks both directions, so advertising a job with no
// runner — the bug that comment in prepToolTable records — still fails the
// build, and so does a runner nothing advertises.
var frameTools = map[string]bool{ctxFramesTool: true}
// ── the plan ────────────────────────────────────────────────────────────────
// framePlan is what will be extracted, decided BEFORE anything is decoded.
// Deciding first is what bounds the work: the alternative — write every
// keyframe and then throw most away — is unbounded disk on the user's machine
// for a long recording (a six-hour capture at one keyframe a second is 21,600
// files) and the reason the keyframe list is read from the container first.
type framePlan struct {
Stride int
KeyIdx []int // indices into the keyframe list that will be written
TopUps []float64 // seconds, the thin stretches to seek
LimitedBy string
}
// planFrames decides which moments of the clip come back.
//
// keys is every keyframe time the container records, in seconds, ascending.
// durS is the clip's duration, used only to know whether the tail after the
// last keyframe is a thin stretch worth filling.
func planFrames(keys []float64, durS float64) framePlan {
p := framePlan{Stride: 1}
if len(keys) == 0 {
return p
}
// DECIMATE FIRST, EVENLY. stride = ceil(n / max) keeps every nth keyframe,
// which spreads the survivors over the whole clip. It is even in KEYFRAME
// INDEX rather than in wall-clock time — the two are the same to the extent
// the encoder's cadence is regular, and where it is not, the top-up pass
// below is what covers the thin stretches. What matters is that it is never
// first-N: the opening titles are not the film.
if len(keys) > ctxFramesMaxCount {
p.Stride = (len(keys) + ctxFramesMaxCount - 1) / ctxFramesMaxCount
p.LimitedBy = "count"
}
for i := 0; i < len(keys); i += p.Stride {
p.KeyIdx = append(p.KeyIdx, i)
}
// TOP UP THE THIN STRETCHES. Only if there is room left under the ceiling —
// a clip that already filled it is dense by definition and has no thin
// stretch worth a seek.
room := ctxFramesMaxCount - len(p.KeyIdx)
if room <= 0 {
return p
}
// Gaps between consecutive SELECTED frames, plus the head (a clip whose
// first keyframe is not at zero) and the tail (after the last one).
bounds := make([]float64, 0, len(p.KeyIdx)+2)
if keys[p.KeyIdx[0]] > ctxFramesTargetGapS {
bounds = append(bounds, 0)
}
for _, i := range p.KeyIdx {
bounds = append(bounds, keys[i])
}
if durS > bounds[len(bounds)-1] {
bounds = append(bounds, durS)
}
for i := 0; i+1 < len(bounds) && room > 0; i++ {
gap := bounds[i+1] - bounds[i]
if gap <= ctxFramesTargetGapS {
continue
}
// n evenly spaced points strictly inside the gap, at about the target
// spacing. ceil(gap/target)-1 of them, so 40 s at a 16 s target gets
// two and lands three frames 13.3 s apart.
n := int(math.Ceil(gap/ctxFramesTargetGapS)) - 1
if n > room {
n = room
}
step := gap / float64(n+1)
for j := 1; j <= n; j++ {
p.TopUps = append(p.TopUps, bounds[i]+step*float64(j))
room--
}
}
return p
}
// ── reading the container ───────────────────────────────────────────────────
// ffprobeKeyframes lists every keyframe time and the clip's duration, WITHOUT
// decoding a single frame.
//
// It reads packet flags, which the container records, so the cost is a file
// scan and not a decode — the same distinction that makes keyframes 45x cheaper
// than scene detection. -select_streams v:0 keeps it to the video track.
//
// Output is capped: a long file has one line per packet (a two-hour clip is
// ~216,000 of them, a few MB) and this runs on the user's machine.
func ffprobeKeyframes(bin, path string) (keys []float64, durS float64, err error) {
ctx, cancel := context.WithTimeout(context.Background(), ctxFramesProbeTimeout)
defer cancel()
cmd := exec.CommandContext(ctx, bin,
"-v", "error",
"-select_streams", "v:0",
"-show_entries", "packet=pts_time,flags",
"-show_entries", "format=duration",
"-of", "csv=p=0",
path)
hideConsole(cmd) // console_windows.go
out := &capWriter{max: 32 << 20}
errb := &capWriter{max: 8 << 10}
cmd.Stdout, cmd.Stderr = out, errb
runErr := cmd.Run()
if ctx.Err() == context.DeadlineExceeded {
return nil, 0, fmt.Errorf("reading this clip's structure took longer than %s "+
"and was stopped — the file was not rejected, it did not finish in the "+
"time this lane allows", ctxFramesProbeTimeout)
}
if runErr != nil {
msg := strings.TrimSpace(errb.buf.String())
if len(msg) > 400 {
msg = msg[:400]
}
return nil, 0, fmt.Errorf("%s could not read this clip%s", bin, ifNonEmpty(msg, ": "+msg))
}
if out.hit {
return nil, 0, fmt.Errorf("this clip's packet index is larger than this lane " +
"reads in one job — nothing was extracted")
}
for _, line := range strings.Split(out.buf.String(), "\n") {
line = strings.TrimSpace(line)
if line == "" {
continue
}
f := strings.Split(line, ",")
// A format=duration row is a single bare number; a packet row is
// "<pts_time>,<flags>". Telling them apart by field count rather than
// by position, because ffprobe emits the format section after the
// packets and an empty pts_time is legal.
if len(f) == 1 {
if d, e := strconv.ParseFloat(f[0], 64); e == nil && d > durS {
durS = d
}
continue
}
if len(f) < 2 || !strings.Contains(f[1], "K") {
continue
}
t, e := strconv.ParseFloat(f[0], 64)
if e != nil || t < 0 {
// A packet with no pts is not a moment anyone can seek back to, so
// it cannot become a frame offset. Skipped, not guessed at.
continue
}
keys = append(keys, t)
}
sort.Float64s(keys)
if durS <= 0 && len(keys) > 0 {
durS = keys[len(keys)-1]
}
return keys, durS, nil
}
// ── extracting ──────────────────────────────────────────────────────────────
// extractKeyframes writes the planned keyframes as downscaled jpegs into dir
// and returns their paths in clip order.
//
// ONE PASS, with -skip_frame nokey, so ffmpeg decodes only keyframes — this is
// the 2.1 CPU-s path from the measurement. select='not(mod(n,stride))' then
// keeps every nth of THOSE, so the n the filter counts is the keyframe index
// and output i is keys[i*stride].
//
// VERIFIED rather than assumed, on a clip cut at 0/6/12/18 s: the pass wrote
// frames at PTS 0/180/360/540 (frame numbers at 30 fps = 0/6/12/18 s), and with
// stride 2 it wrote 0 and 360 — keys[0] and keys[2]. The acceptance test
// re-checks the mapping against known cut times rather than trusting it here.
func extractKeyframes(bin, in, dir string, stride int, deadline time.Duration) ([]string, error) {
ctx, cancel := context.WithTimeout(context.Background(), deadline)
defer cancel()
vf := fmt.Sprintf("scale=%d:-2", ctxFramesWidth)
if stride > 1 {
vf = fmt.Sprintf("select='not(mod(n\\,%d))',%s", stride, vf)
}
cmd := exec.CommandContext(ctx, bin,
"-v", "error",
"-skip_frame", "nokey",
"-i", in,
"-an", "-sn", "-dn",
"-fps_mode", "passthrough",
"-vf", vf,
"-q:v", ctxFramesJpegQ,
"-f", "image2",
filepath.Join(dir, "k_%06d.jpg"))
hideConsole(cmd) // console_windows.go
errb := &capWriter{max: 8 << 10}
cmd.Stdout, cmd.Stderr = &capWriter{max: 8 << 10}, errb
err := cmd.Run()
if ctx.Err() == context.DeadlineExceeded {
return nil, fmt.Errorf("this clip was still decoding after %s and was stopped "+
"— it was not rejected, it did not finish in the time this lane allows",
deadline)
}
if err != nil {
msg := strings.TrimSpace(errb.buf.String())
if len(msg) > 400 {
msg = msg[:400]
}
return nil, fmt.Errorf("%s could not decode this clip%s", bin, ifNonEmpty(msg, ": "+msg))
}
// Sorted by name, which the %06d pattern makes clip order.
paths, err := filepath.Glob(filepath.Join(dir, "k_*.jpg"))
if err != nil {
return nil, err
}
sort.Strings(paths)
return paths, nil
}
// seekFrame writes ONE frame from the middle of a thin stretch.
//
// -ss BEFORE -i is the fast seek: ffmpeg jumps to the preceding keyframe and
// decodes forward, rather than decoding from zero. Measured at ~60 ms here and
// ~150 ms on real footage, which is what makes topping up affordable at all.
func seekFrame(bin, in, out string, atS float64, deadline time.Duration) error {
ctx, cancel := context.WithTimeout(context.Background(), deadline)
defer cancel()
cmd := exec.CommandContext(ctx, bin,
"-v", "error",
"-ss", strconv.FormatFloat(atS, 'f', 3, 64),
"-i", in,
"-an", "-sn", "-dn",
"-frames:v", "1",
"-vf", fmt.Sprintf("scale=%d:-2", ctxFramesWidth),
"-q:v", ctxFramesJpegQ,
"-y", out)
hideConsole(cmd) // console_windows.go
cmd.Stdout, cmd.Stderr = &capWriter{max: 4 << 10}, &capWriter{max: 8 << 10}
return cmd.Run()
}
// ── the job ─────────────────────────────────────────────────────────────────
// handleCtxFrames runs the whole video job against a scratch file that is
// already on disk, and REPLIES. It owns `dir` and removes it before the reply
// goes out, for the reason contextextract.go gives: the guarantee this lane
// makes is that the user's bytes are gone by the time the host is told the job
// finished, and a deferred remove does not provide it.
func handleCtxFrames(ws *websocket.Conn, root, scratch string, tool prepTool,
source string, limit time.Duration) {
dir, err := os.MkdirTemp(root, "frames-*")
if err != nil {
ctxExtractFail(ws, "ctx_extract: cannot open scratch space: "+err.Error())
return
}
frames, report, ferr := runFrameJob(tool, scratch, dir, limit)
_ = os.RemoveAll(dir)
_ = os.Remove(scratch)
if ferr != nil {
ctxExtractFail(ws, "ctx_extract: "+ferr.Error())
return
}
resp := ctxFramesResp{Tool: ctxFramesTool, Source: source, Mime: "image/jpeg",
Frames: frames, Report: report}
b, err := json.Marshal(resp)
if err != nil {
ctxExtractFail(ws, "ctx_extract: cannot encode the result: "+err.Error())
return
}
sendMgmt(ws, 'O', b)
sendMgmtExit(ws, 0)
}
// runFrameJob is handleCtxFrames without the websocket, so the acceptance test
// can drive the real ffmpeg against a real clip and check the timestamps rather
// than checking an argv string.
func runFrameJob(tool prepTool, in, dir string, limit time.Duration) ([]ctxFrame, ctxFramesReport, error) {
started := time.Now()
probeBin := ctxFramesProbeBin(tool)
keys, durS, err := ffprobeKeyframes(probeBin, in)
if err != nil {
return nil, ctxFramesReport{}, err
}
rep := ctxFramesReport{
DurationMs: int64(durS * 1000),
Keyframes: len(keys),
Stride: 1,
Width: ctxFramesWidth,
}
if len(keys) == 0 {
// An honest empty answer, not an error: a file with no video keyframes
// is a file this job has nothing to say about, and calling it a failure
// would send the user to look at their decoder.
return []ctxFrame{}, rep, nil
}
plan := planFrames(keys, durS)
rep.Stride = plan.Stride
rep.LimitedBy = plan.LimitedBy
// THE BUDGET IS THE CALLER'S, not a constant read here. handleCtxExtract
// resolves it with ctxExtractBudget: this box's own ceiling for the job
// (ctxExtractTimeoutFor, which is where the number lives) clamped DOWN by
// whatever the host granted. Reading a constant here instead would be a
// second enforcement point that could disagree with the advertised one.
ceiling := limit
if ceiling <= 0 {
ceiling = ctxExtractTimeoutFor(ctxFramesTool)
}
left := ceiling - time.Since(started)
if left <= 0 {
return nil, rep, fmt.Errorf("this clip's structure alone took the whole %s this "+
"lane allows — nothing was decoded", ceiling)
}
paths, err := extractKeyframes(tool.Bin, in, dir, plan.Stride, left)
if err != nil {
return nil, rep, err
}
// THE MAPPING IS CHECKED, NOT ASSUMED. Output i is keys[i*stride] only if
// the pass wrote what the plan expected; a mismatch means the filter
// selected differently than planned and every offset after it would be a
// confident lie about where in the clip the picture came from.
if len(paths) != len(plan.KeyIdx) {
return nil, rep, fmt.Errorf(
"this clip's keyframe pass wrote %d frames where its packet index said %d "+
"— the offsets could not be trusted, so nothing was returned",
len(paths), len(plan.KeyIdx))
}
frames := make([]ctxFrame, 0, len(paths)+len(plan.TopUps))
for i, p := range paths {
b, rerr := os.ReadFile(p)
if rerr != nil || len(b) == 0 {
continue
}
frames = append(frames, ctxFrame{
OffsetMs: int64(keys[plan.KeyIdx[i]] * 1000),
B64: base64.StdEncoding.EncodeToString(b),
Bytes: len(b),
Source: "keyframe",
})
}
// TOP UP, bounded by the clock rather than by a count.
topDeadline := started.Add(ctxFramesTopUpBudget)
for _, at := range plan.TopUps {
if time.Now().After(topDeadline) || time.Since(started) > ceiling {
rep.TopUpCutShort = true
break
}
out := filepath.Join(dir, fmt.Sprintf("t_%d.jpg", int64(at*1000)))
if err := seekFrame(tool.Bin, in, out, at, ctxFramesTopUpBudget); err != nil {
// A seek that fails is a thin stretch that stays thin. It is not a
// failed job: the keyframes are real and already extracted.
continue
}
b, rerr := os.ReadFile(out)
if rerr != nil || len(b) == 0 {
continue
}
frames = append(frames, ctxFrame{
OffsetMs: int64(at * 1000),
B64: base64.StdEncoding.EncodeToString(b),
Bytes: len(b),
Source: "topup",
})
rep.ToppedUp++
}
sort.Slice(frames, func(a, b int) bool { return frames[a].OffsetMs < frames[b].OffsetMs })
// THE BYTE WALL, applied last because it is the only one that needs the
// real sizes. Dropping evenly keeps the timeline covered; dropping the tail
// would silently turn a film into its first half.
frames, dropped := fitFrameBudget(frames)
if dropped {
rep.LimitedBy = "bytes"
}
rep.Returned = len(frames)
for _, f := range frames {
rep.Bytes += f.Bytes
}
return frames, rep, nil
}
// fitFrameBudget thins an over-budget set EVENLY until it fits.
//
// Keeps every mth frame, raising m until the total is under the wall. Even
// thinning is the same rule the count ceiling follows and for the same reason:
// the first frames of a clip are not a summary of it.
func fitFrameBudget(in []ctxFrame) ([]ctxFrame, bool) {
total := 0
for _, f := range in {
total += f.Bytes
}
if total <= ctxFramesMaxBytes || len(in) == 0 {
return in, false
}
for m := 2; m <= len(in); m++ {
out := make([]ctxFrame, 0, len(in)/m+1)
sum := 0
for i := 0; i < len(in); i += m {
out = append(out, in[i])
sum += in[i].Bytes
}
if sum <= ctxFramesMaxBytes {
return out, true
}
}
// Every subset of two or more was still too big, which means one frame is
// past the wall on its own. Return the single middle frame rather than
// nothing: one picture from the middle of the clip is worth more than a
// refusal, and the report says the bytes were what cut it.
return in[len(in)/2 : len(in)/2+1], true
}
// ctxFramesProbeBin is the container reader that goes with this tool's decoder.
// It comes from the tool's own Needs list rather than from a constant here, so
// the binary that is PROBED at heartbeat time is the binary that is RUN — the
// drift the prepToolTable comment records, pointed at the second binary.
func ctxFramesProbeBin(t prepTool) string {
for _, a := range t.Needs {
if a.Bin != "" {
return a.Bin
}
}
return "ffprobe"
}