Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,12 @@ All notable changes to this project are documented here.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

### Added

- `StatsExternalVideo` stats mode: staging and `Merge` skip decoding episode MP4s for video feature stats and stop requiring video keys in their coverage checks, for callers that compute video stats themselves (each mode otherwise decodes every high-res episode video twice)

## [1.2.5] - 2026-09-04

### Changed
Expand Down
1 change: 1 addition & 0 deletions docs/API.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ features := map[string]lerobot.FeatureSpec{

- `lerobot.StatsSampled` — official subsampling (default)
- `lerobot.StatsFull` — scan every frame for image/video stats
- `lerobot.StatsExternalVideo` — like `StatsSampled`, but video feature stats are left to the caller: staging and `Merge` do not decode episode MP4s for stats. Write the video stats into `meta/stats.json` and the episode metadata after `Merge`, then run `ValidateOutputIntegrity`

Set on `StagingConfig`, `MergeConfig`, and `CreateConfig`.

Expand Down
1 change: 1 addition & 0 deletions docs/API_zh.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ features := map[string]lerobot.FeatureSpec{

- `lerobot.StatsSampled` — 官方抽样(默认)
- `lerobot.StatsFull` — 扫描每一帧
- `lerobot.StatsExternalVideo` — 与 `StatsSampled` 相同,但视频特征的统计交给调用方:staging 与 `Merge` 不再为统计解码 episode MP4。调用方需在 `Merge` 之后把视频统计写入 `meta/stats.json` 和 episode 元数据,再运行 `ValidateOutputIntegrity`

用于 `StagingConfig`、`MergeConfig`、`CreateConfig`。

Expand Down
14 changes: 7 additions & 7 deletions go.mod
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
module github.com/ioai-tech/lerobot-go

go 1.26.6
go 1.26.10

require (
github.com/apache/arrow-go/v18 v18.6.0
github.com/spf13/cobra v1.10.2
golang.org/x/sync v0.20.0
golang.org/x/sync v0.23.0
)

require (
Expand All @@ -22,10 +22,10 @@ require (
github.com/spf13/pflag v1.0.9 // indirect
github.com/zeebo/xxh3 v1.1.0 // indirect
golang.org/x/exp v0.0.0-20260112195511-716be5621a96 // indirect
golang.org/x/net v0.53.0 // indirect
golang.org/x/sys v0.43.0 // indirect
golang.org/x/text v0.36.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478 // indirect
google.golang.org/grpc v1.82.1 // indirect
golang.org/x/net v0.60.0 // indirect
golang.org/x/sys v0.48.0 // indirect
golang.org/x/text v0.42.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa // indirect
google.golang.org/grpc v1.83.1 // indirect
google.golang.org/protobuf v1.36.11 // indirect
)
44 changes: 22 additions & 22 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -50,33 +50,33 @@ github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs=
github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I=
go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0=
go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM=
go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY=
go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg=
go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg=
go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw=
go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A=
go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A=
go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0=
go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU=
go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc=
go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc=
go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo=
go.opentelemetry.io/otel/sdk v1.44.0 h1:nHYwb9lK+fJPU/dnT6s7W7Z8itMWyqrnVfbheVYrZ58=
go.opentelemetry.io/otel/sdk v1.44.0/go.mod h1:Osuydd3Se74nqjAKxid74N5eC+jfEqfTegHRnq58oK0=
go.opentelemetry.io/otel/sdk/metric v1.44.0 h1:3LlKgI+VjbVsjNRFZJZAJ30WjXC5VkNRks6si09iEfI=
go.opentelemetry.io/otel/sdk/metric v1.44.0/go.mod h1:5B5pMARnXxKhltooO4xUuCBorl65a4EpnTalObqOigA=
go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk=
go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/exp v0.0.0-20260112195511-716be5621a96 h1:Z/6YuSHTLOHfNFdb8zVZomZr7cqNgTJvA8+Qz75D8gU=
golang.org/x/exp v0.0.0-20260112195511-716be5621a96/go.mod h1:nzimsREAkjBCIEFtHiYkrJyT+2uy9YZJB7H1k68CXZU=
golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA=
golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
golang.org/x/net v0.60.0 h1:79p50tfZlm0J9YfoDsSi639qSXNGVwEzOPLCxM2FsYU=
golang.org/x/net v0.60.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg=
golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk=
golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI=
golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E=
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478 h1:RmoJA1ujG+/lRGNfUnOMfhCy5EipVMyvUE+KNbPbTlw=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
google.golang.org/grpc v1.82.1 h1:NnAxzGRA0677vCa4BUkOAnO5+FfQqVl9iUXeD0IqcGE=
google.golang.org/grpc v1.82.1/go.mod h1:yzTZ1TB1Z3SG+LIYaI+WiE8D5+PZ3ArnrSp8zF3+/ZA=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa h1:mZHHdPZl0dbGHCflZgAq/Q468DWVFcU2whhB2KAo8fk=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
google.golang.org/grpc v1.83.1 h1:HIO0+BEtBP6soyqvqC8sNUjZ7bTs+0hFQuFF+RAy++Y=
google.golang.org/grpc v1.83.1/go.mod h1:kDyl6SKsiHKt0uylY5gtn5cEjkrIOhQOGDgIc4JGwzQ=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
Expand Down
5 changes: 0 additions & 5 deletions internal/stagingstats/recompute.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,6 @@ import (
"github.com/ioai-tech/lerobot-go/internal/stats"
)

// RecomputeEpisodeStats rebuilds episode stats from staging parquet + image paths.
func RecomputeEpisodeStats(ctx context.Context, stagingDir string, ep manifest.Episode, features map[string]meta.FeatureSpec, opts stats.Options) (stats.EpisodeStats, error) {
return RecomputeEpisodeStatsWithOptions(ctx, stagingDir, ep, features, opts, nil)
}

// RecomputeEpisodeStatsWithOptions rebuilds stats using staging parquet values after
// applying the same global index/task remapping used during merge.
func RecomputeEpisodeStatsWithOptions(ctx context.Context, stagingDir string, ep manifest.Episode, features map[string]meta.FeatureSpec, opts stats.Options, appendOpts *parquetx.AppendEpisodeOptions) (stats.EpisodeStats, error) {
Expand Down
24 changes: 22 additions & 2 deletions internal/stats/options.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,28 @@ const (

type Options struct {
Mode Mode
// ExternalVideo skips statistics for video features: the caller computes
// them and writes them into the merged metadata itself. Decoding every
// episode MP4 for stats otherwise dominates finalize time.
ExternalVideo bool
}

func (o Options) normalized() Options {
return o
// skips reports whether stats for a feature of dtype are left to the caller.
func (o Options) skips(dtype string) bool {
return o.ExternalVideo && dtype == "video"
}

// OwnedFeatures drops features whose statistics the caller owns, so coverage
// checks only require keys this package computes.
func (o Options) OwnedFeatures(features map[string]FeatureDesc) map[string]FeatureDesc {
if !o.ExternalVideo {
return features
}
out := make(map[string]FeatureDesc, len(features))
for k, v := range features {
if !o.skips(v.DType) {
out[k] = v
}
}
return out
}
2 changes: 1 addition & 1 deletion internal/stats/overlay.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ func OverlayFromVideos(existing EpisodeStats, files map[string]string, length in
need := make(map[string]string, len(files))
for key, path := range files {
spec, ok := features[key]
if !ok || (spec.DType != "image" && spec.DType != "video") {
if !ok || (spec.DType != "image" && spec.DType != "video") || opts.skips(spec.DType) {
continue
}
if existing[key] != nil {
Expand Down
4 changes: 3 additions & 1 deletion internal/stats/stats.go
Original file line number Diff line number Diff line change
Expand Up @@ -53,13 +53,15 @@ func ComputeEpisodeStatsErr(in EpisodeInput, features map[string]FeatureDesc, op
}

func computeEpisodeStats(in EpisodeInput, features map[string]FeatureDesc, opts Options) (EpisodeStats, error) {
opts = opts.normalized()
out := make(EpisodeStats)
for key, spec := range features {
switch spec.DType {
case "string", "language":
continue
case "image", "video":
if opts.skips(spec.DType) {
continue
}
paths := in.FramePaths[key]
frames := in.FrameBytes[key]
videoPath := in.VideoFiles[key]
Expand Down
6 changes: 5 additions & 1 deletion internal/v21/integrity.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,10 @@ func requiredVideoKeys(features map[string]meta.FeatureSpec) []string {

// ValidateOutputIntegrity checks merged v2.1 dataset has per-episode parquet and video files.
func ValidateOutputIntegrity(outputRoot string, info meta.DatasetInfo, features map[string]meta.FeatureSpec) error {
return validateOutputIntegrity(outputRoot, info, features, stats.Options{})
}

func validateOutputIntegrity(outputRoot string, info meta.DatasetInfo, features map[string]meta.FeatureSpec, opts stats.Options) error {
if info.TotalEpisodes <= 0 {
matches, err := filepath.Glob(filepath.Join(outputRoot, meta.DataDir, "*", "episode_*.parquet"))
if err != nil {
Expand Down Expand Up @@ -48,7 +52,7 @@ func ValidateOutputIntegrity(outputRoot string, info meta.DatasetInfo, features
}
}
}
if err := stats.CheckStatsFileVisualCoverage(filepath.Join(outputRoot, meta.StatsPath), featureDescs(features)); err != nil {
if err := stats.CheckStatsFileVisualCoverage(filepath.Join(outputRoot, meta.StatsPath), opts.OwnedFeatures(featureDescs(features))); err != nil {
return err
}
if !hasVideoFeatures(features) {
Expand Down
4 changes: 2 additions & 2 deletions internal/v21/merge.go
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,7 @@ func Merge(ctx context.Context, cfg MergeConfig) error {
return err
}
agg := stats.AggregateStats(allStats)
if err := stats.CheckAggregateVisualCoverage(agg, featureDescs(cfg.Features)); err != nil {
if err := stats.CheckAggregateVisualCoverage(agg, cfg.Stats.OwnedFeatures(featureDescs(cfg.Features))); err != nil {
return err
}
if err := meta.WriteStats(cfg.OutputRoot, stats.ToJSONSerializable(agg)); err != nil {
Expand All @@ -119,7 +119,7 @@ func Merge(ctx context.Context, cfg MergeConfig) error {
if err := meta.WriteInfo(cfg.OutputRoot, info); err != nil {
return err
}
if err := ValidateOutputIntegrity(cfg.OutputRoot, info, cfg.Features); err != nil {
if err := validateOutputIntegrity(cfg.OutputRoot, info, cfg.Features, cfg.Stats); err != nil {
return fmt.Errorf("merged output integrity: %w", err)
}
return nil
Expand Down
6 changes: 5 additions & 1 deletion internal/v30/integrity.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,10 @@ func requiredVideoKeys(features map[string]meta.FeatureSpec) []string {

// ValidateOutputIntegrity checks merged dataset has data parquet and video files.
func ValidateOutputIntegrity(outputRoot string, features map[string]meta.FeatureSpec) error {
return validateOutputIntegrity(outputRoot, features, stats.Options{})
}

func validateOutputIntegrity(outputRoot string, features map[string]meta.FeatureSpec, opts stats.Options) error {
matches, err := filepath.Glob(filepath.Join(outputRoot, "data", "chunk-*", "file-*.parquet"))
if err != nil {
return err
Expand All @@ -37,7 +41,7 @@ func ValidateOutputIntegrity(outputRoot string, features map[string]meta.Feature
return fmt.Errorf("data parquet empty: %s", p)
}
}
if err := stats.CheckStatsFileVisualCoverage(filepath.Join(outputRoot, meta.StatsPath), featureDescs(features)); err != nil {
if err := stats.CheckStatsFileVisualCoverage(filepath.Join(outputRoot, meta.StatsPath), opts.OwnedFeatures(featureDescs(features))); err != nil {
return err
}
if !hasVideoFeatures(features) {
Expand Down
4 changes: 2 additions & 2 deletions internal/v30/merge.go
Original file line number Diff line number Diff line change
Expand Up @@ -140,7 +140,7 @@ func Merge(ctx context.Context, cfg MergeConfig) error {
return err
}
agg := stats.AggregateStats(st.allEpisodeStats)
if err := stats.CheckAggregateVisualCoverage(agg, featureDescs(cfg.Features)); err != nil {
if err := stats.CheckAggregateVisualCoverage(agg, cfg.Stats.OwnedFeatures(featureDescs(cfg.Features))); err != nil {
return err
}
if err := meta.WriteStats(cfg.OutputRoot, stats.ToJSONSerializable(agg)); err != nil {
Expand All @@ -150,7 +150,7 @@ func Merge(ctx context.Context, cfg MergeConfig) error {
if err := meta.UpdateVideoFeaturesInfo(ctx, &st.info, cfg.OutputRoot, cfg.Locator); err != nil {
return err
}
if err := ValidateOutputIntegrity(cfg.OutputRoot, cfg.Features); err != nil {
if err := validateOutputIntegrity(cfg.OutputRoot, cfg.Features, cfg.Stats); err != nil {
return fmt.Errorf("merged output integrity: %w", err)
}
return meta.WriteInfo(cfg.OutputRoot, st.info)
Expand Down
88 changes: 88 additions & 0 deletions internal/v30/merge_external_videos_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -146,3 +146,91 @@ func keysOf(m map[string]map[string]any) []string {
}
return out
}

func TestMergeExternalVideoStatsLeftToCaller(t *testing.T) {
if _, err := exec.LookPath("ffmpeg"); err != nil {
t.Skip("ffmpeg not installed")
}
if _, err := exec.LookPath("ffprobe"); err != nil {
t.Skip("ffprobe not installed")
}

const (
key = "observation.images.camera_high"
frames = 8
fps = 10
)
dir := t.TempDir()
mp4 := filepath.Join(dir, "src.mp4")
writeSolidMP4(t, mp4, "red", frames, fps)

features := map[string]meta.FeatureSpec{
"observation.state": {DType: "float32", Shape: []int{2}},
"action": {DType: "float32", Shape: []int{2}},
key: {DType: "video", Shape: []int{64, 64, 3}},
}
opts := stats.Options{Mode: stats.ModeSampled, ExternalVideo: true}
ctx := context.Background()
w, err := v30.NewStagingWriter(v30.StagingConfig{
Dir: filepath.Join(dir, "ep_000000"),
Episode: 0,
FPS: fps,
Features: features,
UseVideos: true,
ExternalVideos: true,
Stats: opts,
})
if err != nil {
t.Fatal(err)
}
for i := 0; i < frames; i++ {
if err := w.AddFrame(ctx, map[string]any{
"task": "pick",
"observation.state": []float32{float32(i), 0},
"action": []float32{0.1, 0.2},
}); err != nil {
t.Fatal(err)
}
}
if err := w.SetVideoFiles(ctx, map[string]string{key: mp4}); err != nil {
t.Fatal(err)
}
ep, err := w.SaveEpisode(ctx)
if err != nil {
t.Fatalf("SaveEpisode: %v", err)
}
if ep.Stats[key] != nil {
t.Fatal("external video stats must not be computed during staging")
}
if ep.Stats["observation.state"] == nil {
t.Fatal("numeric stats must still be computed")
}

out := filepath.Join(dir, "dataset")
if err := v30.Merge(ctx, v30.MergeConfig{
StagingRoot: dir,
OutputRoot: out,
FPS: fps,
Features: features,
Stats: opts,
}); err != nil {
t.Fatalf("Merge: %v", err)
}
raw, err := os.ReadFile(filepath.Join(out, meta.StatsPath))
if err != nil {
t.Fatal(err)
}
var statsJSON map[string]map[string]any
if err := json.Unmarshal(raw, &statsJSON); err != nil {
t.Fatal(err)
}
if _, ok := statsJSON[key]; ok {
t.Fatalf("stats.json must leave %s to the caller", key)
}
if _, ok := statsJSON["observation.state"]; !ok {
t.Fatalf("stats.json missing numeric stats; keys=%v", keysOf(statsJSON))
}
if err := v30.ValidateOutputIntegrity(out, features); err == nil {
t.Fatal("full integrity check must still require video stats the caller has not written")
}
}
14 changes: 12 additions & 2 deletions lerobot/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,13 +39,23 @@ const (
StatsSampled StatsMode = iota
// StatsFull scans every frame when computing image/video stats.
StatsFull
// StatsExternalVideo samples image stats like StatsSampled but computes no
// stats for video features: staging and Merge skip decoding episode MP4s,
// and Merge's coverage checks stop requiring video keys. The caller must
// write video stats into meta/stats.json and the episode metadata after
// Merge, then run ValidateOutputIntegrity.
StatsExternalVideo
)

func (m StatsMode) toOptions() stats.Options {
if m == StatsFull {
switch m {
case StatsFull:
return stats.Options{Mode: stats.ModeFull}
case StatsExternalVideo:
return stats.Options{Mode: stats.ModeSampled, ExternalVideo: true}
default:
return stats.Options{Mode: stats.ModeSampled}
}
return stats.Options{Mode: stats.ModeSampled}
}

// FFmpegConfig selects ffmpeg and ffprobe binaries (empty paths use PATH lookup).
Expand Down
Loading