@lumy-pack/scene-sieve 0.2.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (29) hide show
  1. package/README.md +88 -25
  2. package/dist/cli/commands/Sieve.d.ts +5 -0
  3. package/dist/cli/options/parse-options.d.ts +5 -1
  4. package/dist/cli.mjs +749 -237
  5. package/dist/constants/package-version.d.ts +2 -0
  6. package/dist/constants/pipeline-defaults.d.ts +20 -0
  7. package/dist/core/index.d.ts +1 -0
  8. package/dist/core/orchestrator/orchestrator.d.ts +6 -0
  9. package/dist/core/utils/metadata/build-edge-metadata.d.ts +11 -0
  10. package/dist/core/utils/metadata/build-frame-metadata.d.ts +20 -0
  11. package/dist/core/utils/metadata/build-sieve-metadata.d.ts +14 -0
  12. package/dist/core/utils/metadata/build-tool-metadata.d.ts +8 -0
  13. package/dist/core/utils/metadata/change/build-frame-change.d.ts +20 -0
  14. package/dist/core/utils/metadata/change/select-regions.d.ts +8 -0
  15. package/dist/core/utils/metadata/change/union-area/y-coverage-tree.d.ts +26 -0
  16. package/dist/core/utils/metadata/change/union-area.d.ts +7 -0
  17. package/dist/core/utils/metadata/scale-bounding-box.d.ts +11 -0
  18. package/dist/core/utils/output/finalize-selection.d.ts +15 -0
  19. package/dist/core/utils/sheet/build-tile-label-svg.d.ts +8 -0
  20. package/dist/core/utils/sheet/format-tile-label.d.ts +7 -0
  21. package/dist/core/utils/sheet/render-contact-sheet.d.ts +19 -0
  22. package/dist/core/utils/sheet/sample-tile-frames.d.ts +10 -0
  23. package/dist/core/workspace/workspace.d.ts +11 -2
  24. package/dist/index.cjs +701 -205
  25. package/dist/index.d.ts +1 -1
  26. package/dist/index.mjs +700 -204
  27. package/dist/pipeline-worker.mjs +640 -188
  28. package/dist/types/index.d.ts +148 -0
  29. package/package.json +14 -11
@@ -4,12 +4,13 @@ import { randomUUID } from "node:crypto";
4
4
  import { filter, map } from "@winglet/common-utils";
5
5
  import pc from "picocolors";
6
6
  import sharp from "sharp";
7
+ import { existsSync } from "node:fs";
7
8
  import { mkdir, readdir, rename, rm, stat, writeFile } from "node:fs/promises";
8
9
  import { basename, extname, join, resolve } from "node:path";
10
+ import { homedir, tmpdir } from "node:os";
9
11
  import { path } from "@ffprobe-installer/ffprobe";
10
12
  import { execa } from "execa";
11
13
  import ffmpegPath from "ffmpeg-static";
12
- import { homedir, tmpdir } from "node:os";
13
14
 
14
15
  //#region src/logging/logger.ts
15
16
  let debugMode = false;
@@ -36,8 +37,10 @@ const logger = {
36
37
  console.error(`${pc.red("error")} ${message}`);
37
38
  },
38
39
  debug(message) {
39
- if (debugMode) if (jsonMode) process.stderr.write(`${pc.gray(`[${timestamp()}] debug`)} ${message}\n`);
40
- else console.log(`${pc.gray(`[${timestamp()}] debug`)} ${message}`);
40
+ if (debugMode) {
41
+ if (jsonMode) process.stderr.write(`${pc.gray(`[${timestamp()}] debug`)} ${message}\n`);
42
+ else console.log(`${pc.gray(`[${timestamp()}] debug`)} ${message}`);
43
+ }
41
44
  }
42
45
  };
43
46
 
@@ -45,6 +48,12 @@ const logger = {
45
48
  //#region src/constants/pipeline-defaults.ts
46
49
  const DEFAULT_THRESHOLD = .5;
47
50
  const IOU_THRESHOLD = .9;
51
+ /** Tile-height fraction used for label font size. */
52
+ const SHEET_LABEL_HEIGHT_RATIO = .07;
53
+ /** Fixed contact sheet output name. */
54
+ const SHEET_FILE_NAME = "sheet.jpg";
55
+ /** Fixed metadata document output name. */
56
+ const METADATA_FILE_NAME = ".metadata.json";
48
57
 
49
58
  //#endregion
50
59
  //#region src/core/analyzer/constants/vision-tuning.ts
@@ -467,7 +476,8 @@ async function analyzeBatch(cvLib, akaze, frames, carry, scale, tracker, pairOff
467
476
  try {
468
477
  prev ??= computeFrameFeatures(cvLib, akaze, preprocessed[i]);
469
478
  next = computeFrameFeatures(cvLib, akaze, preprocessed[i + 1]);
470
- let dbscanResult = dbscan(computeNewPoints(cvLib, prev, next), imageWidth, imageHeight);
479
+ const sNew = computeNewPoints(cvLib, prev, next);
480
+ let dbscanResult = dbscan(sNew, imageWidth, imageHeight);
471
481
  let clusters = dbscanResult.boundingBoxes;
472
482
  if (clusters.length === 0) {
473
483
  const pixelDiffPoints = computePixelDiff(cvLib, preprocessed[i], preprocessed[i + 1]);
@@ -480,13 +490,18 @@ async function analyzeBatch(cvLib, akaze, frames, carry, scale, tracker, pairOff
480
490
  const clusterPointCounts = new Array(clusters.length).fill(0);
481
491
  for (const label of dbscanResult.labels) if (label >= 0) clusterPointCounts[label]++;
482
492
  const animationIndices = tracker.update(clusters, pairIndex);
493
+ const change = {
494
+ regions: filter(clusters, (_, ci) => !animationIndices.has(ci)),
495
+ animatedRegions: filter(clusters, (_, ci) => animationIndices.has(ci))
496
+ };
483
497
  const animationWeights = map(clusters, (_, ci) => animationIndices.has(ci) ? tracker.getAnimationWeight(ci, clusters) : 0);
484
498
  const score = computeInformationGain(clusters, clusterPointCounts, imageArea, animationIndices, animationWeights);
485
499
  logger.debug(`Edge ${frames[i].id}->${frames[i + 1].id} G(t)=${score.toFixed(6)}`);
486
500
  edges.push({
487
501
  sourceId: frames[i].id,
488
502
  targetId: frames[i + 1].id,
489
- score
503
+ score,
504
+ change
490
505
  });
491
506
  } catch (err) {
492
507
  logger.warn(`Frame pair analysis failed: ${String(err)}`);
@@ -584,50 +599,11 @@ async function analyzeFrames(ctx) {
584
599
  }
585
600
 
586
601
  //#endregion
587
- //#region src/core/utils/metadata/build-video-metadata.ts
588
- /**
589
- * Read output dimensions and build consistent video and animation metadata.
590
- * JPEG finalization does not resize, so the source dimensions match the output.
591
- * @param ctx Pipeline state with source duration, effective FPS and analysis-space animations.
592
- * @param selected Selected frames in output order; the first candidate is the fallback.
593
- * @param analysisResolution Analysis dimensions; absent or zero dimensions imply no scaling.
594
- * @returns Video metadata and new output-space animations, retaining zero-based frame IDs.
595
- * @throws If sharp cannot read the selected or fallback image. Empty input performs no image I/O.
596
- */
597
- async function buildVideoMetadata(ctx, selected, analysisResolution) {
598
- const firstFrame = selected[0] ?? ctx.frames[0];
599
- const dimensions = firstFrame ? await sharp(firstFrame.extractPath).metadata() : void 0;
600
- const width = dimensions?.width ?? 0;
601
- const height = dimensions?.height ?? 0;
602
- const sx = analysisResolution?.width ? width / analysisResolution.width : 1;
603
- const sy = analysisResolution?.height ? height / analysisResolution.height : 1;
604
- const lastTimestamp = ctx.frames[ctx.frames.length - 1]?.timestamp ?? 0;
605
- const duration = ctx.options.mode === "frames" ? lastTimestamp : ctx.sourceDurationSec ?? lastTimestamp;
606
- return {
607
- video: {
608
- originalDurationMs: Math.round(duration * 1e3),
609
- fps: ctx.options.mode === "frames" ? 1 : ctx.effectiveFps ?? ctx.options.fps,
610
- resolution: {
611
- width,
612
- height
613
- }
614
- },
615
- animations: (ctx.animations ?? []).map((animation) => {
616
- const box = animation.boundingBox;
617
- const x = Math.max(0, Math.min(width, Math.round(box.x * sx)));
618
- const y = Math.max(0, Math.min(height, Math.round(box.y * sy)));
619
- return {
620
- ...animation,
621
- boundingBox: {
622
- x,
623
- y,
624
- width: Math.max(0, Math.min(width - x, Math.round(box.width * sx))),
625
- height: Math.max(0, Math.min(height - y, Math.round(box.height * sy)))
626
- }
627
- };
628
- })
629
- };
630
- }
602
+ //#region src/constants/package-version.ts
603
+ /** Source constants are two levels below the manifest; runtime bundles are one level below. */
604
+ const manifestPath = existsSync(new URL("../package.json", import.meta.url)) ? "../package.json" : "../../package.json";
605
+ /** Runtime manifest version shared by CLI responses and metadata documents. */
606
+ const PACKAGE_VERSION = createRequire(import.meta.url)(manifestPath).version;
631
607
 
632
608
  //#endregion
633
609
  //#region src/core/constants/workspace-layout.ts
@@ -678,7 +654,564 @@ function resolveAbsolute(p) {
678
654
  * e.g., /path/to/video.mp4 -> /path/to/video_scenes
679
655
  */
680
656
  function deriveOutputPath(inputPath) {
681
- return resolve(resolve(inputPath, ".."), `${basename(inputPath, extname(inputPath))}_scenes`);
657
+ const dir = resolve(inputPath, "..");
658
+ const name = basename(inputPath, extname(inputPath));
659
+ return resolve(dir, `${name}_scenes`);
660
+ }
661
+
662
+ //#endregion
663
+ //#region src/core/workspace/workspace.ts
664
+ async function createWorkspace(sessionId) {
665
+ const workspacePath = getTempWorkspaceDir(sessionId);
666
+ await ensureDir(join(workspacePath, "frames"));
667
+ await ensureDir(join(workspacePath, "output"));
668
+ return workspacePath;
669
+ }
670
+ /**
671
+ * Write selected JPEGs and the supplied document before replacing the output directory.
672
+ * @param ctx - Workspace, quality and destination settings.
673
+ * @param selectedFrames - Frames paired by position with document.frames.
674
+ * @param document - Complete metadata, including the output file names.
675
+ * @param sheetBuffer - Optional JPEG contact sheet bytes to persist unchanged.
676
+ * @returns Selected JPEG paths, optional sheet path and finally the metadata path.
677
+ * @throws Rejects mismatched frame counts and propagates image or filesystem errors.
678
+ */
679
+ async function finalizeOutput(ctx, selectedFrames, document, sheetBuffer) {
680
+ if (document.frames.length !== selectedFrames.length) throw new Error("metadata frame count must match selected frame count");
681
+ const stagingDir = join(ctx.workspacePath, "output");
682
+ const outputPath = ctx.options.outputPath;
683
+ const quality = ctx.options.quality;
684
+ const outputFiles = [];
685
+ for (let i = 0; i < selectedFrames.length; i++) {
686
+ const frame = selectedFrames[i];
687
+ const fileName = document.frames[i].fileName;
688
+ const destPath = join(stagingDir, fileName);
689
+ await sharp(frame.extractPath).jpeg({
690
+ quality,
691
+ mozjpeg: true
692
+ }).toFile(destPath);
693
+ outputFiles.push(join(outputPath, fileName));
694
+ }
695
+ if (sheetBuffer) {
696
+ await writeFile(join(stagingDir, SHEET_FILE_NAME), sheetBuffer);
697
+ outputFiles.push(join(outputPath, SHEET_FILE_NAME));
698
+ }
699
+ const metadataPath = join(stagingDir, METADATA_FILE_NAME);
700
+ await writeFile(metadataPath, JSON.stringify(document, null, 2));
701
+ outputFiles.push(join(outputPath, METADATA_FILE_NAME));
702
+ await ensureDir(join(outputPath, ".."));
703
+ await rm(outputPath, {
704
+ recursive: true,
705
+ force: true
706
+ });
707
+ await rename(stagingDir, outputPath);
708
+ return outputFiles;
709
+ }
710
+ async function createSegmentWorkspace(parentWorkspacePath, segmentIndex) {
711
+ const segmentPath = join(parentWorkspacePath, "segments", String(segmentIndex));
712
+ await ensureDir(join(segmentPath, "frames"));
713
+ return segmentPath;
714
+ }
715
+ async function cleanupWorkspace(workspacePath) {
716
+ if (!workspacePath) return;
717
+ try {
718
+ await rm(workspacePath, {
719
+ recursive: true,
720
+ force: true
721
+ });
722
+ } catch {}
723
+ }
724
+ /**
725
+ * Write a video buffer to a temp file in the workspace and return the path.
726
+ * Used by 'buffer' input mode.
727
+ */
728
+ async function writeInputBuffer(buffer, workspacePath) {
729
+ const inputDir = join(workspacePath, "input");
730
+ await ensureDir(inputDir);
731
+ const tempPath = join(inputDir, "input.mp4");
732
+ await writeFile(tempPath, buffer);
733
+ return tempPath;
734
+ }
735
+ /**
736
+ * Write an array of frame Buffers as JPG files and return FrameNode[].
737
+ * Used by 'frames' input mode.
738
+ */
739
+ async function writeInputFrames(frames, workspacePath) {
740
+ const framesDir = join(workspacePath, "frames");
741
+ await ensureDir(framesDir);
742
+ const frameNodes = [];
743
+ for (let i = 0; i < frames.length; i++) {
744
+ const filename = `frame_${String(i).padStart(6, "0")}${FRAME_OUTPUT_EXTENSION}`;
745
+ const extractPath = join(framesDir, filename);
746
+ await writeFile(extractPath, frames[i]);
747
+ frameNodes.push({
748
+ id: i,
749
+ timestamp: i,
750
+ extractPath
751
+ });
752
+ }
753
+ return frameNodes;
754
+ }
755
+ /**
756
+ * Read selected FrameNode files as Buffers with JPEG compression.
757
+ * Used to return output buffers in 'buffer' and 'frames' modes.
758
+ */
759
+ async function readFramesAsBuffers(frameNodes, quality) {
760
+ return Promise.all(map(frameNodes, (f) => sharp(f.extractPath).jpeg({
761
+ quality,
762
+ mozjpeg: true
763
+ }).toBuffer()));
764
+ }
765
+
766
+ //#endregion
767
+ //#region src/core/utils/metadata/change/union-area/y-coverage-tree.ts
768
+ /** Internal sweep helper retaining cover counts and lengths between sorted y bounds. */
769
+ var YCoverageTree = class {
770
+ bounds;
771
+ /** Number of whole-node covering intervals, independent of descendants. */
772
+ counts;
773
+ /** Covered geometric length for each node, including partially covered children. */
774
+ lengths;
775
+ /**
776
+ * Allocate linear storage for the elementary intervals between coordinates.
777
+ * @param bounds - At least two sorted unique finite y endpoints.
778
+ */
779
+ constructor(bounds) {
780
+ this.bounds = bounds;
781
+ this.counts = Array(4 * bounds.length).fill(0);
782
+ this.lengths = Array(4 * bounds.length).fill(0);
783
+ }
784
+ /** Total active covered y length, in the input coordinate system. */
785
+ get coveredLength() {
786
+ return this.lengths[1];
787
+ }
788
+ /**
789
+ * Adjust a nonempty half-open interval and refresh its ancestors' lengths.
790
+ * @param start - Inclusive endpoint index; 0 <= start < end.
791
+ * @param end - Exclusive endpoint index; end < bounds.length.
792
+ * @param delta - One on entry, minus one for the matching departure.
793
+ * @param node - Internal tree slot; callers use the root default.
794
+ * @param left - Inclusive endpoint index of this node.
795
+ * @param right - Exclusive endpoint index of this node.
796
+ * @returns Nothing; mutates this tree's coverage in O(log n).
797
+ */
798
+ update(start, end, delta, node = 1, left = 0, right = this.bounds.length - 1) {
799
+ if (start <= left && right <= end) this.counts[node] += delta;
800
+ else {
801
+ const middle = Math.floor((left + right) / 2);
802
+ if (start < middle) this.update(start, end, delta, node * 2, left, middle);
803
+ if (end > middle) this.update(start, end, delta, node * 2 + 1, middle, right);
804
+ }
805
+ this.lengths[node] = this.counts[node] > 0 ? this.bounds[right] - this.bounds[left] : right - left === 1 ? 0 : this.lengths[node * 2] + this.lengths[node * 2 + 1];
806
+ }
807
+ };
808
+
809
+ //#endregion
810
+ //#region src/core/utils/metadata/change/union-area.ts
811
+ /**
812
+ * Measure rectangle union with an x sweep in O(n log n) time and O(n) space.
813
+ * @param boxes - Finite rectangles; nonpositive dimensions are ignored.
814
+ * @returns Covered area in the input coordinate system, or zero for empty input.
815
+ */
816
+ function unionArea(boxes) {
817
+ const events = [];
818
+ const endpoints = [];
819
+ for (const box of boxes) {
820
+ if (!(box.width > 0 && box.height > 0)) continue;
821
+ const end = box.y + box.height;
822
+ if (end === box.y) continue;
823
+ events.push({
824
+ x: box.x,
825
+ start: box.y,
826
+ end,
827
+ delta: 1
828
+ });
829
+ events.push({
830
+ x: box.x + box.width,
831
+ start: box.y,
832
+ end,
833
+ delta: -1
834
+ });
835
+ endpoints.push(box.y, end);
836
+ }
837
+ if (events.length === 0) return 0;
838
+ events.sort((a, b) => a.x - b.x);
839
+ endpoints.sort((a, b) => a - b);
840
+ const bounds = endpoints.filter((value, index) => index === 0 || value !== endpoints[index - 1]);
841
+ const indices = new Map(bounds.map((value, index) => [value, index]));
842
+ const coverage = new YCoverageTree(bounds);
843
+ let area = 0;
844
+ let previousX = events[0].x;
845
+ for (const event of events) {
846
+ area += (event.x - previousX) * coverage.coveredLength;
847
+ coverage.update(indices.get(event.start), indices.get(event.end), event.delta);
848
+ previousX = event.x;
849
+ }
850
+ return area;
851
+ }
852
+
853
+ //#endregion
854
+ //#region src/core/utils/metadata/build-edge-metadata.ts
855
+ /**
856
+ * Serialize raw candidate edges in graph order.
857
+ * @param graph - Adjacent-pair edges, optionally carrying tracker partitions.
858
+ * @param analysisResolution - Analysis dimensions used for both area fractions.
859
+ * @returns One-based IDs with six-decimal scores and four-decimal clamped ratios.
860
+ */
861
+ function buildEdgeMetadata(graph, analysisResolution) {
862
+ const area = analysisResolution.width * analysisResolution.height;
863
+ return map(graph, (edge) => ({
864
+ sourceFrameId: edge.sourceId + 1,
865
+ targetFrameId: edge.targetId + 1,
866
+ score: Math.round(edge.score * 1e6) / 1e6,
867
+ areaRatio: area > 0 ? Math.round(Math.max(0, Math.min(1, unionArea(edge.change?.regions ?? []) / area)) * 1e4) / 1e4 : 0,
868
+ animatedAreaRatio: area > 0 ? Math.round(Math.max(0, Math.min(1, unionArea(edge.change?.animatedRegions ?? []) / area)) * 1e4) / 1e4 : 0
869
+ }));
870
+ }
871
+
872
+ //#endregion
873
+ //#region src/core/utils/metadata/scale-bounding-box.ts
874
+ /**
875
+ * Convert an analysis box to clamped integer output pixels.
876
+ * @param box - Analysis-space rectangle; the input remains unchanged.
877
+ * @param sx - Horizontal output-to-analysis scale.
878
+ * @param sy - Vertical output-to-analysis scale.
879
+ * @param width - Nonnegative output image width.
880
+ * @param height - Nonnegative output image height.
881
+ * @returns A rectangle contained within the output dimensions.
882
+ */
883
+ function scaleBoundingBox(box, sx, sy, width, height) {
884
+ const x = Math.max(0, Math.min(width, Math.round(box.x * sx)));
885
+ const y = Math.max(0, Math.min(height, Math.round(box.y * sy)));
886
+ return {
887
+ x,
888
+ y,
889
+ width: Math.max(0, Math.min(width - x, Math.round(box.width * sx))),
890
+ height: Math.max(0, Math.min(height - y, Math.round(box.height * sy)))
891
+ };
892
+ }
893
+
894
+ //#endregion
895
+ //#region src/core/utils/metadata/change/select-regions.ts
896
+ /**
897
+ * Select distinct positive-area boxes with deterministic area and coordinate ties.
898
+ * @param boxes - Already scaled output rectangles; the input is not mutated.
899
+ * @param limit - Nonnegative maximum number of regions.
900
+ * @returns Largest boxes ordered by area descending, then y, x and width ascending.
901
+ */
902
+ function selectRegions(boxes, limit) {
903
+ return filter(boxes, (box, index) => box.width > 0 && box.height > 0 && boxes.findIndex((other) => other.x === box.x && other.y === box.y && other.width === box.width && other.height === box.height) === index).sort((a, b) => b.width * b.height - a.width * a.height || a.y - b.y || a.x - b.x || a.width - b.width).slice(0, limit);
904
+ }
905
+
906
+ //#endregion
907
+ //#region src/core/utils/metadata/change/build-frame-change.ts
908
+ /**
909
+ * Aggregate adjacent-pair evidence across one selected-frame span.
910
+ * @param input - Ordered raw edges, one-based previous ID, skipped count and dimensions.
911
+ * @returns Rounded raw scores, analysis-space union ratio and output-space regions.
912
+ */
913
+ function buildFrameChange(input) {
914
+ const { spanEdges, fromFrameId, skippedCandidates, analysisResolution, outputResolution, regionLimit } = input;
915
+ const boxes = spanEdges.flatMap((edge) => edge.change?.regions ?? []);
916
+ const area = analysisResolution.width * analysisResolution.height;
917
+ const peakScore = spanEdges.reduce((peak, edge) => Math.max(peak, edge.score), 0);
918
+ const sumScore = spanEdges.reduce((sum, edge) => sum + edge.score, 0);
919
+ const areaRatio = area > 0 ? Math.max(0, Math.min(1, unionArea(boxes) / area)) : 0;
920
+ const regions = area > 0 ? selectRegions(map(boxes, (box) => scaleBoundingBox(box, outputResolution.width / analysisResolution.width, outputResolution.height / analysisResolution.height, outputResolution.width, outputResolution.height)), regionLimit) : [];
921
+ return {
922
+ fromFrameId,
923
+ skippedCandidates,
924
+ peakScore: Math.round(peakScore * 1e6) / 1e6,
925
+ sumScore: Math.round(sumScore * 1e6) / 1e6,
926
+ areaRatio: Math.round(areaRatio * 1e4) / 1e4,
927
+ regions
928
+ };
929
+ }
930
+
931
+ //#endregion
932
+ //#region src/core/utils/metadata/build-frame-metadata.ts
933
+ /**
934
+ * Describe selected frames using candidate adjacency rather than synthetic pruning edges.
935
+ * @param input - Chronological candidates and selections, raw graph and output dimensions.
936
+ * @returns One-based frame summaries with rounded timestamps and nonnegative holds.
937
+ */
938
+ function buildFrameMetadata(input) {
939
+ const { frames, graph, selected, originalDurationMs, analysisResolution, outputResolution } = input;
940
+ const edgesByPair = new Map(map(graph, (edge) => [`${edge.sourceId}:${edge.targetId}`, edge]));
941
+ const candidateIndices = new Map(map(frames, (frame, index) => [frame.id, index]));
942
+ const padding = Math.max(4, String(frames.length).length);
943
+ return map(selected, (frame, index) => {
944
+ const timestampMs = Math.round(frame.timestamp * 1e3);
945
+ const nextTimestampMs = index + 1 < selected.length ? Math.round(selected[index + 1].timestamp * 1e3) : originalDurationMs;
946
+ const previous = selected[index - 1];
947
+ const previousIndex = previous ? candidateIndices.get(previous.id) : 0;
948
+ const currentIndex = candidateIndices.get(frame.id);
949
+ const spanEdges = [];
950
+ if (previous) for (let i = previousIndex; i < currentIndex; i++) {
951
+ const edge = edgesByPair.get(`${frames[i].id}:${frames[i + 1].id}`);
952
+ if (edge) spanEdges.push(edge);
953
+ }
954
+ return {
955
+ step: index + 1,
956
+ fileName: `frame_${String(frame.id + 1).padStart(padding, "0")}.jpg`,
957
+ frameId: frame.id + 1,
958
+ timestampMs,
959
+ holdsMs: Math.max(0, nextTimestampMs - timestampMs),
960
+ change: previous ? buildFrameChange({
961
+ spanEdges,
962
+ fromFrameId: previous.id + 1,
963
+ skippedCandidates: currentIndex - previousIndex - 1,
964
+ analysisResolution,
965
+ outputResolution,
966
+ regionLimit: 5
967
+ }) : null
968
+ };
969
+ });
970
+ }
971
+
972
+ //#endregion
973
+ //#region src/core/utils/metadata/build-tool-metadata.ts
974
+ /**
975
+ * Record the tool and the nine selection and encoding settings in contract order.
976
+ * @param options - Validated pipeline settings; operational options are excluded.
977
+ * @param version - Runtime package version supplied by the I/O boundary.
978
+ * @returns Deterministic tool provenance without paths or execution details.
979
+ */
980
+ function buildToolMetadata(options, version) {
981
+ return {
982
+ name: "@lumy-pack/scene-sieve",
983
+ version,
984
+ params: {
985
+ fps: options.fps,
986
+ count: options.count,
987
+ threshold: options.threshold,
988
+ scale: options.scale,
989
+ quality: options.quality,
990
+ maxFrames: options.maxFrames,
991
+ iouThreshold: options.iouThreshold,
992
+ animationThreshold: options.animationThreshold,
993
+ maxSegmentDuration: options.maxSegmentDuration
994
+ }
995
+ };
996
+ }
997
+
998
+ //#endregion
999
+ //#region src/core/utils/metadata/build-sieve-metadata.ts
1000
+ /**
1001
+ * Assemble the complete v2 document without I/O or mutation.
1002
+ * @param input - Pipeline state, selections, output-space video and zero-based animations.
1003
+ * @returns Contract-ordered metadata, omitting unrequested optional keys entirely.
1004
+ */
1005
+ function buildSieveMetadata(input) {
1006
+ const { ctx, selected, video, animations, version, sheet } = input;
1007
+ const analysisResolution = ctx.analysisResolution ?? {
1008
+ width: 0,
1009
+ height: 0
1010
+ };
1011
+ return {
1012
+ metadataVersion: 2,
1013
+ tool: buildToolMetadata(ctx.options, version),
1014
+ video,
1015
+ frames: buildFrameMetadata({
1016
+ frames: ctx.frames,
1017
+ graph: ctx.graph,
1018
+ selected,
1019
+ originalDurationMs: video.originalDurationMs,
1020
+ analysisResolution,
1021
+ outputResolution: video.resolution
1022
+ }),
1023
+ animations: map(animations, (animation) => ({
1024
+ ...animation,
1025
+ startFrameId: animation.startFrameId + 1,
1026
+ endFrameId: animation.endFrameId + 1,
1027
+ durationMs: Math.round(animation.durationMs)
1028
+ })),
1029
+ ...sheet ? { sheet } : {},
1030
+ ...ctx.options.includeEdges ? { edges: buildEdgeMetadata(ctx.graph, analysisResolution) } : {}
1031
+ };
1032
+ }
1033
+
1034
+ //#endregion
1035
+ //#region src/core/utils/metadata/build-video-metadata.ts
1036
+ /**
1037
+ * Read output dimensions and build consistent video and animation metadata.
1038
+ * JPEG finalization does not resize, so the source dimensions match the output.
1039
+ * @param ctx Pipeline state with source duration, effective FPS and analysis-space animations.
1040
+ * @param selected Selected frames in output order; the first candidate is the fallback.
1041
+ * @param analysisResolution Analysis dimensions; absent or zero dimensions imply no scaling.
1042
+ * @returns Video metadata and new output-space animations, retaining zero-based frame IDs.
1043
+ * @throws If sharp cannot read the selected or fallback image. Empty input performs no image I/O.
1044
+ */
1045
+ async function buildVideoMetadata(ctx, selected, analysisResolution) {
1046
+ const firstFrame = selected[0] ?? ctx.frames[0];
1047
+ const dimensions = firstFrame ? await sharp(firstFrame.extractPath).metadata() : void 0;
1048
+ const width = dimensions?.width ?? 0;
1049
+ const height = dimensions?.height ?? 0;
1050
+ const sx = analysisResolution?.width ? width / analysisResolution.width : 1;
1051
+ const sy = analysisResolution?.height ? height / analysisResolution.height : 1;
1052
+ const lastTimestamp = ctx.frames[ctx.frames.length - 1]?.timestamp ?? 0;
1053
+ const duration = ctx.options.mode === "frames" ? lastTimestamp : ctx.sourceDurationSec ?? lastTimestamp;
1054
+ return {
1055
+ video: {
1056
+ originalDurationMs: Math.round(duration * 1e3),
1057
+ fps: ctx.options.mode === "frames" ? 1 : ctx.effectiveFps ?? ctx.options.fps,
1058
+ resolution: {
1059
+ width,
1060
+ height
1061
+ },
1062
+ candidatesCount: ctx.frames.length,
1063
+ selectedCount: selected.length,
1064
+ source: {
1065
+ fileName: ctx.options.mode === "file" && ctx.options.inputPath ? basename(ctx.options.inputPath) : null,
1066
+ mode: ctx.options.mode
1067
+ }
1068
+ },
1069
+ animations: (ctx.animations ?? []).map((animation) => ({
1070
+ ...animation,
1071
+ boundingBox: scaleBoundingBox(animation.boundingBox, sx, sy, width, height)
1072
+ }))
1073
+ };
1074
+ }
1075
+
1076
+ //#endregion
1077
+ //#region src/core/utils/sheet/build-tile-label-svg.ts
1078
+ /**
1079
+ * Build a tile-sized SVG overlay with a translucent badge and an explicit text baseline.
1080
+ * @param text - A formatTileLabel result containing only digits, #, spaces, colons and periods.
1081
+ * @param tileWidth - Positive tile canvas width in pixels.
1082
+ * @param tileHeight - Positive tile canvas height in pixels.
1083
+ * @returns Encoded SVG bytes for a top-left sharp composite overlay.
1084
+ */
1085
+ function buildTileLabelSvg(text, tileWidth, tileHeight) {
1086
+ const fontSize = Math.max(8, Math.round(tileHeight * SHEET_LABEL_HEIGHT_RATIO));
1087
+ const padX = Math.round(fontSize * .4);
1088
+ const padY = Math.round(fontSize * .2);
1089
+ const badgeWidth = Math.min(tileWidth, Math.ceil(text.length * fontSize * .6) + 2 * padX);
1090
+ return Buffer.from(`<svg xmlns="http://www.w3.org/2000/svg" width="${tileWidth}" height="${tileHeight}"><rect x="0" y="0" width="${badgeWidth}" height="${fontSize + 2 * padY}" fill="black" fill-opacity="0.5"/><text x="${padX}" y="${padY + Math.round(fontSize * .8)}" font-family="sans-serif" font-size="${fontSize}" fill="white">${text}</text></svg>`);
1091
+ }
1092
+
1093
+ //#endregion
1094
+ //#region src/core/utils/sheet/format-tile-label.ts
1095
+ /**
1096
+ * Format a contact sheet label without rounding into the next tenth of a second.
1097
+ * @param frameId - One-based candidate frame ID.
1098
+ * @param timestampMs - Nonnegative candidate time in milliseconds.
1099
+ * @returns A label in the form "#<id> mm:ss.s", allowing minutes beyond two digits.
1100
+ */
1101
+ function formatTileLabel(frameId, timestampMs) {
1102
+ const tenths = Math.floor(timestampMs / 100);
1103
+ return `#${frameId} ${String(Math.floor(tenths / 600)).padStart(2, "0")}:${String(Math.floor(tenths / 10) % 60).padStart(2, "0")}.${tenths % 10}`;
1104
+ }
1105
+
1106
+ //#endregion
1107
+ //#region src/core/utils/sheet/sample-tile-frames.ts
1108
+ /**
1109
+ * Sample a sequence uniformly while always retaining both endpoints.
1110
+ * @param items - Items in temporal order.
1111
+ * @param maxTiles - Validated integer limit of at least two.
1112
+ * @returns The original sequence when within the limit, otherwise evenly sampled items.
1113
+ */
1114
+ function sampleTileFrames(items, maxTiles) {
1115
+ if (items.length <= maxTiles) return {
1116
+ items,
1117
+ sampled: false
1118
+ };
1119
+ return {
1120
+ items: Array.from({ length: maxTiles }, (_, i) => items[Math.round(i * (items.length - 1) / (maxTiles - 1))]),
1121
+ sampled: true
1122
+ };
1123
+ }
1124
+
1125
+ //#endregion
1126
+ //#region src/core/utils/sheet/render-contact-sheet.ts
1127
+ /**
1128
+ * Read selected frame images and render a row-major contact sheet.
1129
+ * @param input - Nonempty ordered selections, positive dimensions and validated sheet settings.
1130
+ * @returns JPEG bytes and the effective tile layout with one-based frame IDs.
1131
+ * @throws Propagates sharp image reading, compositing or encoding errors.
1132
+ */
1133
+ async function renderContactSheet(input) {
1134
+ const { selected, resolution, options, quality } = input;
1135
+ const { items, sampled } = sampleTileFrames(selected, options.maxTiles);
1136
+ const columns = Math.min(options.columns, items.length);
1137
+ const rows = Math.ceil(items.length / columns);
1138
+ const tileWidth = options.tileWidth;
1139
+ const tileHeight = Math.max(1, Math.round(tileWidth * resolution.height / resolution.width));
1140
+ const gap = 4;
1141
+ const tiles = await Promise.all(map(items, async (frame, index) => {
1142
+ const tile = sharp(frame.extractPath).resize(tileWidth, tileHeight, { fit: "fill" });
1143
+ if (options.label) {
1144
+ const text = formatTileLabel(frame.id + 1, Math.round(frame.timestamp * 1e3));
1145
+ tile.composite([{
1146
+ input: buildTileLabelSvg(text, tileWidth, tileHeight),
1147
+ top: 0,
1148
+ left: 0
1149
+ }]);
1150
+ }
1151
+ return {
1152
+ input: await tile.png().toBuffer(),
1153
+ top: gap + Math.floor(index / columns) * (tileHeight + gap),
1154
+ left: gap + index % columns * (tileWidth + gap)
1155
+ };
1156
+ }));
1157
+ return {
1158
+ buffer: await sharp({ create: {
1159
+ width: columns * tileWidth + (columns + 1) * gap,
1160
+ height: rows * tileHeight + (rows + 1) * gap,
1161
+ channels: 3,
1162
+ background: "white"
1163
+ } }).composite(tiles).jpeg({
1164
+ quality,
1165
+ mozjpeg: true
1166
+ }).toBuffer(),
1167
+ metadata: {
1168
+ fileName: SHEET_FILE_NAME,
1169
+ columns,
1170
+ tileWidth,
1171
+ tileHeight,
1172
+ frameIds: map(items, (frame) => frame.id + 1),
1173
+ sampled
1174
+ }
1175
+ };
1176
+ }
1177
+
1178
+ //#endregion
1179
+ //#region src/core/utils/output/finalize-selection.ts
1180
+ /**
1181
+ * Read output dimensions once, render optional sheets and finalize the shared v2 document.
1182
+ * @param ctx - Pipeline state owned by the orchestrator; this function does not mutate it.
1183
+ * @param selected - Selected frames in temporal order.
1184
+ * @returns Mode-specific output, the document and zero-based API animations.
1185
+ * @throws Propagates image, rendering and output I/O errors to the orchestrator.
1186
+ */
1187
+ async function finalizeSelection(ctx, selected) {
1188
+ const { video, animations } = await buildVideoMetadata(ctx, selected, ctx.analysisResolution);
1189
+ const sheet = ctx.options.sheet && selected.length > 0 && video.resolution.width > 0 && video.resolution.height > 0 ? await renderContactSheet({
1190
+ selected,
1191
+ resolution: video.resolution,
1192
+ options: ctx.options.sheet,
1193
+ quality: ctx.options.quality
1194
+ }) : void 0;
1195
+ const document = buildSieveMetadata({
1196
+ ctx,
1197
+ selected,
1198
+ video,
1199
+ animations,
1200
+ version: PACKAGE_VERSION,
1201
+ ...sheet ? { sheet: sheet.metadata } : {}
1202
+ });
1203
+ if (ctx.options.mode === "file") return {
1204
+ outputFiles: await finalizeOutput(ctx, selected, document, sheet?.buffer),
1205
+ document,
1206
+ animations
1207
+ };
1208
+ return {
1209
+ outputFiles: [],
1210
+ outputBuffers: await readFramesAsBuffers(selected, ctx.options.quality),
1211
+ document,
1212
+ animations,
1213
+ ...sheet ? { sheetBuffer: sheet.buffer } : {}
1214
+ };
682
1215
  }
683
1216
 
684
1217
  //#endregion
@@ -765,7 +1298,8 @@ async function getVideoMetadata(inputPath) {
765
1298
  * @returns Zero-based candidates without a segment seek offset.
766
1299
  */
767
1300
  async function buildFrameList(framesDir, effectiveFps) {
768
- const jpgFiles = filter(await readdir(framesDir), (f) => f.endsWith(".jpg")).sort();
1301
+ const files = await readdir(framesDir);
1302
+ const jpgFiles = filter(files, (f) => f.endsWith(".jpg")).sort();
769
1303
  if (jpgFiles.length === 0) return [];
770
1304
  return map(jpgFiles, (file, index) => ({
771
1305
  id: index,
@@ -901,114 +1435,19 @@ function validateOptions(options) {
901
1435
  if (value === void 0) continue;
902
1436
  if (!Number.isFinite(value) || integer && !Number.isInteger(value) || (exclusiveMin ? value <= min : value < min) || value > max) throw new Error(`${name} must be ${requirement}, received: ${value}`);
903
1437
  }
904
- }
905
-
906
- //#endregion
907
- //#region src/core/workspace/workspace.ts
908
- async function createWorkspace(sessionId) {
909
- const workspacePath = getTempWorkspaceDir(sessionId);
910
- await ensureDir(join(workspacePath, "frames"));
911
- await ensureDir(join(workspacePath, "output"));
912
- return workspacePath;
913
- }
914
- async function finalizeOutput(ctx, selectedFrames) {
915
- const stagingDir = join(ctx.workspacePath, "output");
916
- const outputPath = ctx.options.outputPath;
917
- const quality = ctx.options.quality;
918
- const outputFiles = [];
919
- const framesMetadata = [];
920
- const totalFramesCount = ctx.frames.length;
921
- const padding = Math.max(4, String(totalFramesCount).length);
922
- for (let i = 0; i < selectedFrames.length; i++) {
923
- const frame = selectedFrames[i];
924
- const fileName = `frame_${String(frame.id + 1).padStart(padding, "0")}.jpg`;
925
- const destPath = join(stagingDir, fileName);
926
- await sharp(frame.extractPath).jpeg({
927
- quality,
928
- mozjpeg: true
929
- }).toFile(destPath);
930
- outputFiles.push(join(outputPath, fileName));
931
- framesMetadata.push({
932
- step: i + 1,
933
- fileName,
934
- frameId: frame.id + 1,
935
- timestampMs: Math.round(frame.timestamp * 1e3)
936
- });
937
- }
938
- const { video, animations } = await buildVideoMetadata(ctx, selectedFrames, ctx.analysisResolution);
939
- const metadata = {
940
- video,
941
- frames: framesMetadata,
942
- animations: map(animations, (anim) => ({
943
- ...anim,
944
- startFrameId: anim.startFrameId + 1,
945
- endFrameId: anim.endFrameId + 1,
946
- durationMs: Math.round(anim.durationMs)
947
- }))
948
- };
949
- await writeFile(join(stagingDir, ".metadata.json"), JSON.stringify(metadata, null, 2));
950
- outputFiles.push(join(outputPath, ".metadata.json"));
951
- await ensureDir(join(outputPath, ".."));
952
- await rm(outputPath, {
953
- recursive: true,
954
- force: true
955
- });
956
- await rename(stagingDir, outputPath);
957
- return outputFiles;
958
- }
959
- async function createSegmentWorkspace(parentWorkspacePath, segmentIndex) {
960
- const segmentPath = join(parentWorkspacePath, "segments", String(segmentIndex));
961
- await ensureDir(join(segmentPath, "frames"));
962
- return segmentPath;
963
- }
964
- async function cleanupWorkspace(workspacePath) {
965
- if (!workspacePath) return;
966
- try {
967
- await rm(workspacePath, {
968
- recursive: true,
969
- force: true
970
- });
971
- } catch {}
972
- }
973
- /**
974
- * Write a video buffer to a temp file in the workspace and return the path.
975
- * Used by 'buffer' input mode.
976
- */
977
- async function writeInputBuffer(buffer, workspacePath) {
978
- const inputDir = join(workspacePath, "input");
979
- await ensureDir(inputDir);
980
- const tempPath = join(inputDir, "input.mp4");
981
- await writeFile(tempPath, buffer);
982
- return tempPath;
983
- }
984
- /**
985
- * Write an array of frame Buffers as JPG files and return FrameNode[].
986
- * Used by 'frames' input mode.
987
- */
988
- async function writeInputFrames(frames, workspacePath) {
989
- const framesDir = join(workspacePath, "frames");
990
- await ensureDir(framesDir);
991
- const frameNodes = [];
992
- for (let i = 0; i < frames.length; i++) {
993
- const extractPath = join(framesDir, `frame_${String(i).padStart(6, "0")}${FRAME_OUTPUT_EXTENSION}`);
994
- await writeFile(extractPath, frames[i]);
995
- frameNodes.push({
996
- id: i,
997
- timestamp: i,
998
- extractPath
999
- });
1438
+ const sheet = options.sheet;
1439
+ if (sheet === void 0 || typeof sheet === "boolean") return;
1440
+ if (sheet === null || typeof sheet !== "object" || Array.isArray(sheet)) throw new Error(`sheet must be a boolean or an object, received: ${Array.isArray(sheet) ? "array" : String(sheet)}`);
1441
+ for (const [name, min] of [
1442
+ ["columns", 1],
1443
+ ["tileWidth", 16],
1444
+ ["maxTiles", 2]
1445
+ ]) {
1446
+ const value = sheet[name];
1447
+ if (value === void 0) continue;
1448
+ if (!Number.isInteger(value) || value < min) throw new Error(`sheet.${name} must be an integer >= ${min}, received: ${value}`);
1000
1449
  }
1001
- return frameNodes;
1002
- }
1003
- /**
1004
- * Read selected FrameNode files as Buffers with JPEG compression.
1005
- * Used to return output buffers in 'buffer' and 'frames' modes.
1006
- */
1007
- async function readFramesAsBuffers(frameNodes, quality) {
1008
- return Promise.all(map(frameNodes, (f) => sharp(f.extractPath).jpeg({
1009
- quality,
1010
- mozjpeg: true
1011
- }).toBuffer()));
1450
+ if (sheet.label !== void 0 && typeof sheet.label !== "boolean") throw new Error(`sheet.label must be a boolean, received: ${sheet.label}`);
1012
1451
  }
1013
1452
 
1014
1453
  //#endregion
@@ -1025,12 +1464,14 @@ function resolveOptions(options) {
1025
1464
  const inputPath = mode === "file" ? resolveAbsolute(options.inputPath) : void 0;
1026
1465
  const outputPath = options.outputPath ?? (inputPath ? deriveOutputPath(inputPath) : join(process.cwd(), "scene-sieve-output"));
1027
1466
  const threshold = options.threshold ?? .5;
1467
+ const pruneMode = "threshold-with-cap";
1468
+ const sheet = typeof options.sheet === "object" ? options.sheet : {};
1028
1469
  return {
1029
1470
  mode,
1030
1471
  inputPath,
1031
1472
  count: options.count ?? 20,
1032
1473
  threshold,
1033
- pruneMode: "threshold-with-cap",
1474
+ pruneMode,
1034
1475
  outputPath,
1035
1476
  fps: options.fps ?? 5,
1036
1477
  maxFrames: options.maxFrames ?? 300,
@@ -1040,7 +1481,14 @@ function resolveOptions(options) {
1040
1481
  animationThreshold: options.animationThreshold ?? 5,
1041
1482
  debug: options.debug ?? false,
1042
1483
  maxSegmentDuration: options.maxSegmentDuration ?? 300,
1043
- concurrency: options.concurrency ?? 2
1484
+ concurrency: options.concurrency ?? 2,
1485
+ sheet: options.sheet ? {
1486
+ columns: sheet.columns ?? 4,
1487
+ tileWidth: sheet.tileWidth ?? 320,
1488
+ maxTiles: sheet.maxTiles ?? 40,
1489
+ label: sheet.label ?? true
1490
+ } : null,
1491
+ includeEdges: options.includeEdges ?? false
1044
1492
  };
1045
1493
  }
1046
1494
  /**
@@ -1145,7 +1593,7 @@ function normalizeScores(items) {
1145
1593
  if (s <= 0) return 0;
1146
1594
  return lowerBound(sorted, s) / sorted.length;
1147
1595
  });
1148
- return map(logisticZ, (z, i) => z * (1 - NORMALIZATION_ALPHA) + cdf[i] * NORMALIZATION_ALPHA);
1596
+ return map(logisticZ, (z, i) => z * .6 + cdf[i] * NORMALIZATION_ALPHA);
1149
1597
  }
1150
1598
 
1151
1599
  //#endregion
@@ -1554,16 +2002,16 @@ function remapEdges(segmentResults, globalIdMap) {
1554
2002
  const existingIdx = edgeMap.get(edgeKey);
1555
2003
  if (existingIdx !== void 0) {
1556
2004
  if (edges[existingIdx].score < edge.score) edges[existingIdx] = {
2005
+ ...edge,
1557
2006
  sourceId: newSourceId,
1558
- targetId: newTargetId,
1559
- score: edge.score
2007
+ targetId: newTargetId
1560
2008
  };
1561
2009
  } else {
1562
2010
  edgeMap.set(edgeKey, edges.length);
1563
2011
  edges.push({
2012
+ ...edge,
1564
2013
  sourceId: newSourceId,
1565
- targetId: newTargetId,
1566
- score: edge.score
2014
+ targetId: newTargetId
1567
2015
  });
1568
2016
  }
1569
2017
  }
@@ -1638,7 +2086,8 @@ function buildSegmentContext(segment, frames, segmentWorkspacePath, resolvedOpti
1638
2086
  * Each segment uses an isolated workspace directory.
1639
2087
  */
1640
2088
  async function processSegment(inputPath, segment, workspacePath, resolvedOptions, onProgress) {
1641
- const frames = await extractFramesForRange(inputPath, join(workspacePath, "frames"), segment.effectiveFps, resolvedOptions.scale, segment.extractStartTime, segment.extractDuration, segment.allocatedFrames);
2089
+ const framesDir = join(workspacePath, "frames");
2090
+ const frames = await extractFramesForRange(inputPath, framesDir, segment.effectiveFps, resolvedOptions.scale, segment.extractStartTime, segment.extractDuration, segment.allocatedFrames);
1642
2091
  if (frames.length < 2) return {
1643
2092
  segment,
1644
2093
  frames,
@@ -1649,7 +2098,8 @@ async function processSegment(inputPath, segment, workspacePath, resolvedOptions
1649
2098
  height: 0
1650
2099
  }
1651
2100
  };
1652
- const { edges, animations, analysisResolution } = await analyzeFrames(buildSegmentContext(segment, frames, workspacePath, resolvedOptions, onProgress));
2101
+ const ctx = buildSegmentContext(segment, frames, workspacePath, resolvedOptions, onProgress);
2102
+ const { edges, animations, analysisResolution } = await analyzeFrames(ctx);
1653
2103
  return {
1654
2104
  segment,
1655
2105
  frames,
@@ -1714,20 +2164,20 @@ async function runSegmentedPipeline(options, resolvedOptions) {
1714
2164
  status: "FINALIZING",
1715
2165
  emitProgress: (percent) => options.onProgress?.("FINALIZING", percent)
1716
2166
  };
1717
- let outputFiles = [];
1718
- let outputBuffers;
1719
- if (resolvedOptions.mode === "buffer" || resolvedOptions.mode === "frames") outputBuffers = await readFramesAsBuffers(prunedFrames, resolvedOptions.quality);
1720
- else outputFiles = await finalizeOutput(ctx, prunedFrames);
2167
+ const finalized = await finalizeSelection(ctx, prunedFrames);
1721
2168
  options.onProgress?.("FINALIZING", 100);
1722
2169
  logger.success(`Segmented pipeline: ${prunedFrames.length} scenes from ${frames.length} frames (${segments.length} segments)`);
1723
- const outputMetadata = await buildVideoMetadata(ctx, prunedFrames, analysisResolution);
1724
2170
  return {
1725
2171
  success: true,
1726
2172
  originalFramesCount: frames.length,
1727
2173
  prunedFramesCount: prunedFrames.length,
1728
- outputFiles,
1729
- outputBuffers,
1730
- ...outputMetadata,
2174
+ outputFiles: finalized.outputFiles,
2175
+ outputBuffers: finalized.outputBuffers,
2176
+ video: finalized.document.video,
2177
+ animations: finalized.animations,
2178
+ frames: finalized.document.frames,
2179
+ ...finalized.document.sheet ? { sheet: finalized.document.sheet } : {},
2180
+ ...finalized.sheetBuffer ? { sheetBuffer: finalized.sheetBuffer } : {},
1731
2181
  executionTimeMs: Date.now() - pipelineStart
1732
2182
  };
1733
2183
  } catch (error) {
@@ -1742,6 +2192,12 @@ async function runSegmentedPipeline(options, resolvedOptions) {
1742
2192
 
1743
2193
  //#endregion
1744
2194
  //#region src/core/orchestrator/orchestrator.ts
2195
+ /**
2196
+ * Run the five pipeline stages, delegating segmented inputs to the segmenter.
2197
+ * @param options - Mode-specific input and optional pipeline settings.
2198
+ * @returns Selected outputs with v2 frame metadata and zero-based API animations.
2199
+ * @throws Propagates stage failures after cleaning the workspace unless debug is enabled.
2200
+ */
1745
2201
  async function runPipeline(options) {
1746
2202
  setDebugMode(options.debug ?? false);
1747
2203
  const resolvedOptions = resolveOptions(options);
@@ -1789,25 +2245,21 @@ async function runPipeline(options) {
1789
2245
  const prunedFrames = filter(ctx.frames, (f) => survivingIds.has(f.id));
1790
2246
  ctx.emitProgress(100);
1791
2247
  ctx.status = "FINALIZING";
1792
- let outputFiles = [];
1793
- let outputBuffers;
1794
- if (resolvedOptions.mode === "buffer" || resolvedOptions.mode === "frames") {
1795
- outputBuffers = await readFramesAsBuffers(prunedFrames, resolvedOptions.quality);
1796
- ctx.emitProgress(100);
1797
- } else {
1798
- outputFiles = await finalizeOutput(ctx, prunedFrames);
1799
- ctx.emitProgress(100);
1800
- }
2248
+ const finalized = await finalizeSelection(ctx, prunedFrames);
2249
+ ctx.emitProgress(100);
1801
2250
  ctx.status = "SUCCESS";
1802
2251
  logger.success(`Extracted ${prunedFrames.length} scenes from ${ctx.frames.length} frames`);
1803
- const metadata = await buildVideoMetadata(ctx, prunedFrames, analysisResolution);
1804
2252
  return {
1805
2253
  success: true,
1806
2254
  originalFramesCount: ctx.frames.length,
1807
2255
  prunedFramesCount: prunedFrames.length,
1808
- outputFiles,
1809
- outputBuffers,
1810
- ...metadata,
2256
+ outputFiles: finalized.outputFiles,
2257
+ outputBuffers: finalized.outputBuffers,
2258
+ video: finalized.document.video,
2259
+ animations: finalized.animations,
2260
+ frames: finalized.document.frames,
2261
+ ...finalized.document.sheet ? { sheet: finalized.document.sheet } : {},
2262
+ ...finalized.sheetBuffer ? { sheetBuffer: finalized.sheetBuffer } : {},
1811
2263
  executionTimeMs: Date.now() - startTime
1812
2264
  };
1813
2265
  } catch (error) {