diff --git a/CHANGELOG.md b/CHANGELOG.md index 34457f0..71b2b1c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/docs/API.md b/docs/API.md index 2e87db8..688a192 100644 --- a/docs/API.md +++ b/docs/API.md @@ -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`. diff --git a/docs/API_zh.md b/docs/API_zh.md index ff285dd..e266f8e 100644 --- a/docs/API_zh.md +++ b/docs/API_zh.md @@ -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`。 diff --git a/go.mod b/go.mod index 9019dbe..9df7e94 100644 --- a/go.mod +++ b/go.mod @@ -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 ( @@ -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 ) diff --git a/go.sum b/go.sum index ac7d8ba..d3d4dfd 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/internal/stagingstats/recompute.go b/internal/stagingstats/recompute.go index 5ec5430..899bd56 100644 --- a/internal/stagingstats/recompute.go +++ b/internal/stagingstats/recompute.go @@ -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) { diff --git a/internal/stats/options.go b/internal/stats/options.go index bb57e58..e81136c 100644 --- a/internal/stats/options.go +++ b/internal/stats/options.go @@ -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 } diff --git a/internal/stats/overlay.go b/internal/stats/overlay.go index dd4bf92..24bec7f 100644 --- a/internal/stats/overlay.go +++ b/internal/stats/overlay.go @@ -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 { diff --git a/internal/stats/stats.go b/internal/stats/stats.go index d559e65..ff2ff2e 100644 --- a/internal/stats/stats.go +++ b/internal/stats/stats.go @@ -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] diff --git a/internal/v21/integrity.go b/internal/v21/integrity.go index 6296d22..76f8406 100644 --- a/internal/v21/integrity.go +++ b/internal/v21/integrity.go @@ -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 { @@ -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) { diff --git a/internal/v21/merge.go b/internal/v21/merge.go index c152a4d..d429238 100644 --- a/internal/v21/merge.go +++ b/internal/v21/merge.go @@ -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 { @@ -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 diff --git a/internal/v30/integrity.go b/internal/v30/integrity.go index e600aed..bfb6a67 100644 --- a/internal/v30/integrity.go +++ b/internal/v30/integrity.go @@ -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 @@ -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) { diff --git a/internal/v30/merge.go b/internal/v30/merge.go index 8b97870..f59892a 100644 --- a/internal/v30/merge.go +++ b/internal/v30/merge.go @@ -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 { @@ -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) diff --git a/internal/v30/merge_external_videos_test.go b/internal/v30/merge_external_videos_test.go index 09ce57c..21cf6c8 100644 --- a/internal/v30/merge_external_videos_test.go +++ b/internal/v30/merge_external_videos_test.go @@ -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") + } +} diff --git a/lerobot/config.go b/lerobot/config.go index c205f2d..0bdb50e 100644 --- a/lerobot/config.go +++ b/lerobot/config.go @@ -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).