@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
package/dist/index.mjs CHANGED
@@ -3,12 +3,13 @@ import { randomUUID } from "node:crypto";
3
3
  import { filter, map } from "@winglet/common-utils";
4
4
  import pc from "picocolors";
5
5
  import sharp from "sharp";
6
+ import { existsSync } from "node:fs";
6
7
  import { mkdir, readdir, rename, rm, stat, writeFile } from "node:fs/promises";
7
8
  import { basename, extname, join, resolve } from "node:path";
9
+ import { homedir, tmpdir } from "node:os";
8
10
  import { path } from "@ffprobe-installer/ffprobe";
9
11
  import { execa } from "execa";
10
12
  import ffmpegPath from "ffmpeg-static";
11
- import { homedir, tmpdir } from "node:os";
12
13
 
13
14
  //#region \0rolldown/runtime.js
14
15
  var __esmMin = (fn, res, err) => () => {
@@ -48,18 +49,23 @@ var init_logger = __esmMin((() => {
48
49
  console.error(`${pc.red("error")} ${message}`);
49
50
  },
50
51
  debug(message) {
51
- if (debugMode) if (jsonMode) process.stderr.write(`${pc.gray(`[${timestamp()}] debug`)} ${message}\n`);
52
- else console.log(`${pc.gray(`[${timestamp()}] debug`)} ${message}`);
52
+ if (debugMode) {
53
+ if (jsonMode) process.stderr.write(`${pc.gray(`[${timestamp()}] debug`)} ${message}\n`);
54
+ else console.log(`${pc.gray(`[${timestamp()}] debug`)} ${message}`);
55
+ }
53
56
  }
54
57
  };
55
58
  }));
56
59
 
57
60
  //#endregion
58
61
  //#region src/constants/pipeline-defaults.ts
59
- var DEFAULT_THRESHOLD, IOU_THRESHOLD;
62
+ var DEFAULT_THRESHOLD, IOU_THRESHOLD, SHEET_LABEL_HEIGHT_RATIO, SHEET_FILE_NAME, METADATA_FILE_NAME;
60
63
  var init_pipeline_defaults = __esmMin((() => {
61
64
  DEFAULT_THRESHOLD = .5;
62
65
  IOU_THRESHOLD = .9;
66
+ SHEET_LABEL_HEIGHT_RATIO = .07;
67
+ SHEET_FILE_NAME = "sheet.jpg";
68
+ METADATA_FILE_NAME = ".metadata.json";
63
69
  }));
64
70
 
65
71
  //#endregion
@@ -410,7 +416,8 @@ async function analyzeBatch(cvLib, akaze, frames, carry, scale, tracker, pairOff
410
416
  try {
411
417
  prev ??= computeFrameFeatures(cvLib, akaze, preprocessed[i]);
412
418
  next = computeFrameFeatures(cvLib, akaze, preprocessed[i + 1]);
413
- let dbscanResult = dbscan(computeNewPoints(cvLib, prev, next), imageWidth, imageHeight);
419
+ const sNew = computeNewPoints(cvLib, prev, next);
420
+ let dbscanResult = dbscan(sNew, imageWidth, imageHeight);
414
421
  let clusters = dbscanResult.boundingBoxes;
415
422
  if (clusters.length === 0) {
416
423
  const pixelDiffPoints = computePixelDiff(cvLib, preprocessed[i], preprocessed[i + 1]);
@@ -423,13 +430,18 @@ async function analyzeBatch(cvLib, akaze, frames, carry, scale, tracker, pairOff
423
430
  const clusterPointCounts = new Array(clusters.length).fill(0);
424
431
  for (const label of dbscanResult.labels) if (label >= 0) clusterPointCounts[label]++;
425
432
  const animationIndices = tracker.update(clusters, pairIndex);
433
+ const change = {
434
+ regions: filter(clusters, (_, ci) => !animationIndices.has(ci)),
435
+ animatedRegions: filter(clusters, (_, ci) => animationIndices.has(ci))
436
+ };
426
437
  const animationWeights = map(clusters, (_, ci) => animationIndices.has(ci) ? tracker.getAnimationWeight(ci, clusters) : 0);
427
438
  const score = computeInformationGain(clusters, clusterPointCounts, imageArea, animationIndices, animationWeights);
428
439
  logger.debug(`Edge ${frames[i].id}->${frames[i + 1].id} G(t)=${score.toFixed(6)}`);
429
440
  edges.push({
430
441
  sourceId: frames[i].id,
431
442
  targetId: frames[i + 1].id,
432
- score
443
+ score,
444
+ change
433
445
  });
434
446
  } catch (err) {
435
447
  logger.warn(`Frame pair analysis failed: ${String(err)}`);
@@ -627,51 +639,12 @@ var init_analyzer = __esmMin((() => {
627
639
  }));
628
640
 
629
641
  //#endregion
630
- //#region src/core/utils/metadata/build-video-metadata.ts
631
- /**
632
- * Read output dimensions and build consistent video and animation metadata.
633
- * JPEG finalization does not resize, so the source dimensions match the output.
634
- * @param ctx Pipeline state with source duration, effective FPS and analysis-space animations.
635
- * @param selected Selected frames in output order; the first candidate is the fallback.
636
- * @param analysisResolution Analysis dimensions; absent or zero dimensions imply no scaling.
637
- * @returns Video metadata and new output-space animations, retaining zero-based frame IDs.
638
- * @throws If sharp cannot read the selected or fallback image. Empty input performs no image I/O.
639
- */
640
- async function buildVideoMetadata(ctx, selected, analysisResolution) {
641
- const firstFrame = selected[0] ?? ctx.frames[0];
642
- const dimensions = firstFrame ? await sharp(firstFrame.extractPath).metadata() : void 0;
643
- const width = dimensions?.width ?? 0;
644
- const height = dimensions?.height ?? 0;
645
- const sx = analysisResolution?.width ? width / analysisResolution.width : 1;
646
- const sy = analysisResolution?.height ? height / analysisResolution.height : 1;
647
- const lastTimestamp = ctx.frames[ctx.frames.length - 1]?.timestamp ?? 0;
648
- const duration = ctx.options.mode === "frames" ? lastTimestamp : ctx.sourceDurationSec ?? lastTimestamp;
649
- return {
650
- video: {
651
- originalDurationMs: Math.round(duration * 1e3),
652
- fps: ctx.options.mode === "frames" ? 1 : ctx.effectiveFps ?? ctx.options.fps,
653
- resolution: {
654
- width,
655
- height
656
- }
657
- },
658
- animations: (ctx.animations ?? []).map((animation) => {
659
- const box = animation.boundingBox;
660
- const x = Math.max(0, Math.min(width, Math.round(box.x * sx)));
661
- const y = Math.max(0, Math.min(height, Math.round(box.y * sy)));
662
- return {
663
- ...animation,
664
- boundingBox: {
665
- x,
666
- y,
667
- width: Math.max(0, Math.min(width - x, Math.round(box.width * sx))),
668
- height: Math.max(0, Math.min(height - y, Math.round(box.height * sy)))
669
- }
670
- };
671
- })
672
- };
673
- }
674
- var init_build_video_metadata = __esmMin((() => {}));
642
+ //#region src/constants/package-version.ts
643
+ var manifestPath, PACKAGE_VERSION;
644
+ var init_package_version = __esmMin((() => {
645
+ manifestPath = existsSync(new URL("../package.json", import.meta.url)) ? "../package.json" : "../../package.json";
646
+ PACKAGE_VERSION = createRequire(import.meta.url)(manifestPath).version;
647
+ }));
675
648
 
676
649
  //#endregion
677
650
  //#region src/core/constants/workspace-layout.ts
@@ -722,10 +695,625 @@ function resolveAbsolute(p) {
722
695
  * e.g., /path/to/video.mp4 -> /path/to/video_scenes
723
696
  */
724
697
  function deriveOutputPath(inputPath) {
725
- return resolve(resolve(inputPath, ".."), `${basename(inputPath, extname(inputPath))}_scenes`);
698
+ const dir = resolve(inputPath, "..");
699
+ const name = basename(inputPath, extname(inputPath));
700
+ return resolve(dir, `${name}_scenes`);
726
701
  }
727
702
  var init_paths = __esmMin((() => {}));
728
703
 
704
+ //#endregion
705
+ //#region src/core/workspace/workspace.ts
706
+ async function createWorkspace(sessionId) {
707
+ const workspacePath = getTempWorkspaceDir(sessionId);
708
+ await ensureDir(join(workspacePath, "frames"));
709
+ await ensureDir(join(workspacePath, "output"));
710
+ return workspacePath;
711
+ }
712
+ /**
713
+ * Write selected JPEGs and the supplied document before replacing the output directory.
714
+ * @param ctx - Workspace, quality and destination settings.
715
+ * @param selectedFrames - Frames paired by position with document.frames.
716
+ * @param document - Complete metadata, including the output file names.
717
+ * @param sheetBuffer - Optional JPEG contact sheet bytes to persist unchanged.
718
+ * @returns Selected JPEG paths, optional sheet path and finally the metadata path.
719
+ * @throws Rejects mismatched frame counts and propagates image or filesystem errors.
720
+ */
721
+ async function finalizeOutput(ctx, selectedFrames, document, sheetBuffer) {
722
+ if (document.frames.length !== selectedFrames.length) throw new Error("metadata frame count must match selected frame count");
723
+ const stagingDir = join(ctx.workspacePath, "output");
724
+ const outputPath = ctx.options.outputPath;
725
+ const quality = ctx.options.quality;
726
+ const outputFiles = [];
727
+ for (let i = 0; i < selectedFrames.length; i++) {
728
+ const frame = selectedFrames[i];
729
+ const fileName = document.frames[i].fileName;
730
+ const destPath = join(stagingDir, fileName);
731
+ await sharp(frame.extractPath).jpeg({
732
+ quality,
733
+ mozjpeg: true
734
+ }).toFile(destPath);
735
+ outputFiles.push(join(outputPath, fileName));
736
+ }
737
+ if (sheetBuffer) {
738
+ await writeFile(join(stagingDir, SHEET_FILE_NAME), sheetBuffer);
739
+ outputFiles.push(join(outputPath, SHEET_FILE_NAME));
740
+ }
741
+ const metadataPath = join(stagingDir, METADATA_FILE_NAME);
742
+ await writeFile(metadataPath, JSON.stringify(document, null, 2));
743
+ outputFiles.push(join(outputPath, METADATA_FILE_NAME));
744
+ await ensureDir(join(outputPath, ".."));
745
+ await rm(outputPath, {
746
+ recursive: true,
747
+ force: true
748
+ });
749
+ await rename(stagingDir, outputPath);
750
+ return outputFiles;
751
+ }
752
+ async function createSegmentWorkspace(parentWorkspacePath, segmentIndex) {
753
+ const segmentPath = join(parentWorkspacePath, "segments", String(segmentIndex));
754
+ await ensureDir(join(segmentPath, "frames"));
755
+ return segmentPath;
756
+ }
757
+ async function cleanupWorkspace(workspacePath) {
758
+ if (!workspacePath) return;
759
+ try {
760
+ await rm(workspacePath, {
761
+ recursive: true,
762
+ force: true
763
+ });
764
+ } catch {}
765
+ }
766
+ /**
767
+ * Write a video buffer to a temp file in the workspace and return the path.
768
+ * Used by 'buffer' input mode.
769
+ */
770
+ async function writeInputBuffer(buffer, workspacePath) {
771
+ const inputDir = join(workspacePath, "input");
772
+ await ensureDir(inputDir);
773
+ const tempPath = join(inputDir, "input.mp4");
774
+ await writeFile(tempPath, buffer);
775
+ return tempPath;
776
+ }
777
+ /**
778
+ * Write an array of frame Buffers as JPG files and return FrameNode[].
779
+ * Used by 'frames' input mode.
780
+ */
781
+ async function writeInputFrames(frames, workspacePath) {
782
+ const framesDir = join(workspacePath, "frames");
783
+ await ensureDir(framesDir);
784
+ const frameNodes = [];
785
+ for (let i = 0; i < frames.length; i++) {
786
+ const filename = `frame_${String(i).padStart(6, "0")}${FRAME_OUTPUT_EXTENSION}`;
787
+ const extractPath = join(framesDir, filename);
788
+ await writeFile(extractPath, frames[i]);
789
+ frameNodes.push({
790
+ id: i,
791
+ timestamp: i,
792
+ extractPath
793
+ });
794
+ }
795
+ return frameNodes;
796
+ }
797
+ /**
798
+ * Read selected FrameNode files as Buffers with JPEG compression.
799
+ * Used to return output buffers in 'buffer' and 'frames' modes.
800
+ */
801
+ async function readFramesAsBuffers(frameNodes, quality) {
802
+ return Promise.all(map(frameNodes, (f) => sharp(f.extractPath).jpeg({
803
+ quality,
804
+ mozjpeg: true
805
+ }).toBuffer()));
806
+ }
807
+ var init_workspace$1 = __esmMin((() => {
808
+ init_workspace_layout();
809
+ init_pipeline_defaults();
810
+ init_paths();
811
+ }));
812
+
813
+ //#endregion
814
+ //#region src/core/workspace/index.ts
815
+ var init_workspace = __esmMin((() => {
816
+ init_workspace$1();
817
+ }));
818
+
819
+ //#endregion
820
+ //#region src/core/utils/metadata/change/union-area/y-coverage-tree.ts
821
+ var YCoverageTree;
822
+ var init_y_coverage_tree = __esmMin((() => {
823
+ YCoverageTree = class {
824
+ bounds;
825
+ /** Number of whole-node covering intervals, independent of descendants. */
826
+ counts;
827
+ /** Covered geometric length for each node, including partially covered children. */
828
+ lengths;
829
+ /**
830
+ * Allocate linear storage for the elementary intervals between coordinates.
831
+ * @param bounds - At least two sorted unique finite y endpoints.
832
+ */
833
+ constructor(bounds) {
834
+ this.bounds = bounds;
835
+ this.counts = Array(4 * bounds.length).fill(0);
836
+ this.lengths = Array(4 * bounds.length).fill(0);
837
+ }
838
+ /** Total active covered y length, in the input coordinate system. */
839
+ get coveredLength() {
840
+ return this.lengths[1];
841
+ }
842
+ /**
843
+ * Adjust a nonempty half-open interval and refresh its ancestors' lengths.
844
+ * @param start - Inclusive endpoint index; 0 <= start < end.
845
+ * @param end - Exclusive endpoint index; end < bounds.length.
846
+ * @param delta - One on entry, minus one for the matching departure.
847
+ * @param node - Internal tree slot; callers use the root default.
848
+ * @param left - Inclusive endpoint index of this node.
849
+ * @param right - Exclusive endpoint index of this node.
850
+ * @returns Nothing; mutates this tree's coverage in O(log n).
851
+ */
852
+ update(start, end, delta, node = 1, left = 0, right = this.bounds.length - 1) {
853
+ if (start <= left && right <= end) this.counts[node] += delta;
854
+ else {
855
+ const middle = Math.floor((left + right) / 2);
856
+ if (start < middle) this.update(start, end, delta, node * 2, left, middle);
857
+ if (end > middle) this.update(start, end, delta, node * 2 + 1, middle, right);
858
+ }
859
+ 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];
860
+ }
861
+ };
862
+ }));
863
+
864
+ //#endregion
865
+ //#region src/core/utils/metadata/change/union-area.ts
866
+ /**
867
+ * Measure rectangle union with an x sweep in O(n log n) time and O(n) space.
868
+ * @param boxes - Finite rectangles; nonpositive dimensions are ignored.
869
+ * @returns Covered area in the input coordinate system, or zero for empty input.
870
+ */
871
+ function unionArea(boxes) {
872
+ const events = [];
873
+ const endpoints = [];
874
+ for (const box of boxes) {
875
+ if (!(box.width > 0 && box.height > 0)) continue;
876
+ const end = box.y + box.height;
877
+ if (end === box.y) continue;
878
+ events.push({
879
+ x: box.x,
880
+ start: box.y,
881
+ end,
882
+ delta: 1
883
+ });
884
+ events.push({
885
+ x: box.x + box.width,
886
+ start: box.y,
887
+ end,
888
+ delta: -1
889
+ });
890
+ endpoints.push(box.y, end);
891
+ }
892
+ if (events.length === 0) return 0;
893
+ events.sort((a, b) => a.x - b.x);
894
+ endpoints.sort((a, b) => a - b);
895
+ const bounds = endpoints.filter((value, index) => index === 0 || value !== endpoints[index - 1]);
896
+ const indices = new Map(bounds.map((value, index) => [value, index]));
897
+ const coverage = new YCoverageTree(bounds);
898
+ let area = 0;
899
+ let previousX = events[0].x;
900
+ for (const event of events) {
901
+ area += (event.x - previousX) * coverage.coveredLength;
902
+ coverage.update(indices.get(event.start), indices.get(event.end), event.delta);
903
+ previousX = event.x;
904
+ }
905
+ return area;
906
+ }
907
+ var init_union_area = __esmMin((() => {
908
+ init_y_coverage_tree();
909
+ }));
910
+
911
+ //#endregion
912
+ //#region src/core/utils/metadata/build-edge-metadata.ts
913
+ /**
914
+ * Serialize raw candidate edges in graph order.
915
+ * @param graph - Adjacent-pair edges, optionally carrying tracker partitions.
916
+ * @param analysisResolution - Analysis dimensions used for both area fractions.
917
+ * @returns One-based IDs with six-decimal scores and four-decimal clamped ratios.
918
+ */
919
+ function buildEdgeMetadata(graph, analysisResolution) {
920
+ const area = analysisResolution.width * analysisResolution.height;
921
+ return map(graph, (edge) => ({
922
+ sourceFrameId: edge.sourceId + 1,
923
+ targetFrameId: edge.targetId + 1,
924
+ score: Math.round(edge.score * 1e6) / 1e6,
925
+ areaRatio: area > 0 ? Math.round(Math.max(0, Math.min(1, unionArea(edge.change?.regions ?? []) / area)) * 1e4) / 1e4 : 0,
926
+ animatedAreaRatio: area > 0 ? Math.round(Math.max(0, Math.min(1, unionArea(edge.change?.animatedRegions ?? []) / area)) * 1e4) / 1e4 : 0
927
+ }));
928
+ }
929
+ var init_build_edge_metadata = __esmMin((() => {
930
+ init_union_area();
931
+ }));
932
+
933
+ //#endregion
934
+ //#region src/core/utils/metadata/scale-bounding-box.ts
935
+ /**
936
+ * Convert an analysis box to clamped integer output pixels.
937
+ * @param box - Analysis-space rectangle; the input remains unchanged.
938
+ * @param sx - Horizontal output-to-analysis scale.
939
+ * @param sy - Vertical output-to-analysis scale.
940
+ * @param width - Nonnegative output image width.
941
+ * @param height - Nonnegative output image height.
942
+ * @returns A rectangle contained within the output dimensions.
943
+ */
944
+ function scaleBoundingBox(box, sx, sy, width, height) {
945
+ const x = Math.max(0, Math.min(width, Math.round(box.x * sx)));
946
+ const y = Math.max(0, Math.min(height, Math.round(box.y * sy)));
947
+ return {
948
+ x,
949
+ y,
950
+ width: Math.max(0, Math.min(width - x, Math.round(box.width * sx))),
951
+ height: Math.max(0, Math.min(height - y, Math.round(box.height * sy)))
952
+ };
953
+ }
954
+ var init_scale_bounding_box = __esmMin((() => {}));
955
+
956
+ //#endregion
957
+ //#region src/core/utils/metadata/change/select-regions.ts
958
+ /**
959
+ * Select distinct positive-area boxes with deterministic area and coordinate ties.
960
+ * @param boxes - Already scaled output rectangles; the input is not mutated.
961
+ * @param limit - Nonnegative maximum number of regions.
962
+ * @returns Largest boxes ordered by area descending, then y, x and width ascending.
963
+ */
964
+ function selectRegions(boxes, limit) {
965
+ 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);
966
+ }
967
+ var init_select_regions = __esmMin((() => {}));
968
+
969
+ //#endregion
970
+ //#region src/core/utils/metadata/change/build-frame-change.ts
971
+ /**
972
+ * Aggregate adjacent-pair evidence across one selected-frame span.
973
+ * @param input - Ordered raw edges, one-based previous ID, skipped count and dimensions.
974
+ * @returns Rounded raw scores, analysis-space union ratio and output-space regions.
975
+ */
976
+ function buildFrameChange(input) {
977
+ const { spanEdges, fromFrameId, skippedCandidates, analysisResolution, outputResolution, regionLimit } = input;
978
+ const boxes = spanEdges.flatMap((edge) => edge.change?.regions ?? []);
979
+ const area = analysisResolution.width * analysisResolution.height;
980
+ const peakScore = spanEdges.reduce((peak, edge) => Math.max(peak, edge.score), 0);
981
+ const sumScore = spanEdges.reduce((sum, edge) => sum + edge.score, 0);
982
+ const areaRatio = area > 0 ? Math.max(0, Math.min(1, unionArea(boxes) / area)) : 0;
983
+ const regions = area > 0 ? selectRegions(map(boxes, (box) => scaleBoundingBox(box, outputResolution.width / analysisResolution.width, outputResolution.height / analysisResolution.height, outputResolution.width, outputResolution.height)), regionLimit) : [];
984
+ return {
985
+ fromFrameId,
986
+ skippedCandidates,
987
+ peakScore: Math.round(peakScore * 1e6) / 1e6,
988
+ sumScore: Math.round(sumScore * 1e6) / 1e6,
989
+ areaRatio: Math.round(areaRatio * 1e4) / 1e4,
990
+ regions
991
+ };
992
+ }
993
+ var init_build_frame_change = __esmMin((() => {
994
+ init_scale_bounding_box();
995
+ init_select_regions();
996
+ init_union_area();
997
+ }));
998
+
999
+ //#endregion
1000
+ //#region src/core/utils/metadata/build-frame-metadata.ts
1001
+ /**
1002
+ * Describe selected frames using candidate adjacency rather than synthetic pruning edges.
1003
+ * @param input - Chronological candidates and selections, raw graph and output dimensions.
1004
+ * @returns One-based frame summaries with rounded timestamps and nonnegative holds.
1005
+ */
1006
+ function buildFrameMetadata(input) {
1007
+ const { frames, graph, selected, originalDurationMs, analysisResolution, outputResolution } = input;
1008
+ const edgesByPair = new Map(map(graph, (edge) => [`${edge.sourceId}:${edge.targetId}`, edge]));
1009
+ const candidateIndices = new Map(map(frames, (frame, index) => [frame.id, index]));
1010
+ const padding = Math.max(4, String(frames.length).length);
1011
+ return map(selected, (frame, index) => {
1012
+ const timestampMs = Math.round(frame.timestamp * 1e3);
1013
+ const nextTimestampMs = index + 1 < selected.length ? Math.round(selected[index + 1].timestamp * 1e3) : originalDurationMs;
1014
+ const previous = selected[index - 1];
1015
+ const previousIndex = previous ? candidateIndices.get(previous.id) : 0;
1016
+ const currentIndex = candidateIndices.get(frame.id);
1017
+ const spanEdges = [];
1018
+ if (previous) for (let i = previousIndex; i < currentIndex; i++) {
1019
+ const edge = edgesByPair.get(`${frames[i].id}:${frames[i + 1].id}`);
1020
+ if (edge) spanEdges.push(edge);
1021
+ }
1022
+ return {
1023
+ step: index + 1,
1024
+ fileName: `frame_${String(frame.id + 1).padStart(padding, "0")}.jpg`,
1025
+ frameId: frame.id + 1,
1026
+ timestampMs,
1027
+ holdsMs: Math.max(0, nextTimestampMs - timestampMs),
1028
+ change: previous ? buildFrameChange({
1029
+ spanEdges,
1030
+ fromFrameId: previous.id + 1,
1031
+ skippedCandidates: currentIndex - previousIndex - 1,
1032
+ analysisResolution,
1033
+ outputResolution,
1034
+ regionLimit: 5
1035
+ }) : null
1036
+ };
1037
+ });
1038
+ }
1039
+ var init_build_frame_metadata = __esmMin((() => {
1040
+ init_pipeline_defaults();
1041
+ init_build_frame_change();
1042
+ }));
1043
+
1044
+ //#endregion
1045
+ //#region src/core/utils/metadata/build-tool-metadata.ts
1046
+ /**
1047
+ * Record the tool and the nine selection and encoding settings in contract order.
1048
+ * @param options - Validated pipeline settings; operational options are excluded.
1049
+ * @param version - Runtime package version supplied by the I/O boundary.
1050
+ * @returns Deterministic tool provenance without paths or execution details.
1051
+ */
1052
+ function buildToolMetadata(options, version) {
1053
+ return {
1054
+ name: "@lumy-pack/scene-sieve",
1055
+ version,
1056
+ params: {
1057
+ fps: options.fps,
1058
+ count: options.count,
1059
+ threshold: options.threshold,
1060
+ scale: options.scale,
1061
+ quality: options.quality,
1062
+ maxFrames: options.maxFrames,
1063
+ iouThreshold: options.iouThreshold,
1064
+ animationThreshold: options.animationThreshold,
1065
+ maxSegmentDuration: options.maxSegmentDuration
1066
+ }
1067
+ };
1068
+ }
1069
+ var init_build_tool_metadata = __esmMin((() => {}));
1070
+
1071
+ //#endregion
1072
+ //#region src/core/utils/metadata/build-sieve-metadata.ts
1073
+ /**
1074
+ * Assemble the complete v2 document without I/O or mutation.
1075
+ * @param input - Pipeline state, selections, output-space video and zero-based animations.
1076
+ * @returns Contract-ordered metadata, omitting unrequested optional keys entirely.
1077
+ */
1078
+ function buildSieveMetadata(input) {
1079
+ const { ctx, selected, video, animations, version, sheet } = input;
1080
+ const analysisResolution = ctx.analysisResolution ?? {
1081
+ width: 0,
1082
+ height: 0
1083
+ };
1084
+ return {
1085
+ metadataVersion: 2,
1086
+ tool: buildToolMetadata(ctx.options, version),
1087
+ video,
1088
+ frames: buildFrameMetadata({
1089
+ frames: ctx.frames,
1090
+ graph: ctx.graph,
1091
+ selected,
1092
+ originalDurationMs: video.originalDurationMs,
1093
+ analysisResolution,
1094
+ outputResolution: video.resolution
1095
+ }),
1096
+ animations: map(animations, (animation) => ({
1097
+ ...animation,
1098
+ startFrameId: animation.startFrameId + 1,
1099
+ endFrameId: animation.endFrameId + 1,
1100
+ durationMs: Math.round(animation.durationMs)
1101
+ })),
1102
+ ...sheet ? { sheet } : {},
1103
+ ...ctx.options.includeEdges ? { edges: buildEdgeMetadata(ctx.graph, analysisResolution) } : {}
1104
+ };
1105
+ }
1106
+ var init_build_sieve_metadata = __esmMin((() => {
1107
+ init_pipeline_defaults();
1108
+ init_build_edge_metadata();
1109
+ init_build_frame_metadata();
1110
+ init_build_tool_metadata();
1111
+ }));
1112
+
1113
+ //#endregion
1114
+ //#region src/core/utils/metadata/build-video-metadata.ts
1115
+ /**
1116
+ * Read output dimensions and build consistent video and animation metadata.
1117
+ * JPEG finalization does not resize, so the source dimensions match the output.
1118
+ * @param ctx Pipeline state with source duration, effective FPS and analysis-space animations.
1119
+ * @param selected Selected frames in output order; the first candidate is the fallback.
1120
+ * @param analysisResolution Analysis dimensions; absent or zero dimensions imply no scaling.
1121
+ * @returns Video metadata and new output-space animations, retaining zero-based frame IDs.
1122
+ * @throws If sharp cannot read the selected or fallback image. Empty input performs no image I/O.
1123
+ */
1124
+ async function buildVideoMetadata(ctx, selected, analysisResolution) {
1125
+ const firstFrame = selected[0] ?? ctx.frames[0];
1126
+ const dimensions = firstFrame ? await sharp(firstFrame.extractPath).metadata() : void 0;
1127
+ const width = dimensions?.width ?? 0;
1128
+ const height = dimensions?.height ?? 0;
1129
+ const sx = analysisResolution?.width ? width / analysisResolution.width : 1;
1130
+ const sy = analysisResolution?.height ? height / analysisResolution.height : 1;
1131
+ const lastTimestamp = ctx.frames[ctx.frames.length - 1]?.timestamp ?? 0;
1132
+ const duration = ctx.options.mode === "frames" ? lastTimestamp : ctx.sourceDurationSec ?? lastTimestamp;
1133
+ return {
1134
+ video: {
1135
+ originalDurationMs: Math.round(duration * 1e3),
1136
+ fps: ctx.options.mode === "frames" ? 1 : ctx.effectiveFps ?? ctx.options.fps,
1137
+ resolution: {
1138
+ width,
1139
+ height
1140
+ },
1141
+ candidatesCount: ctx.frames.length,
1142
+ selectedCount: selected.length,
1143
+ source: {
1144
+ fileName: ctx.options.mode === "file" && ctx.options.inputPath ? basename(ctx.options.inputPath) : null,
1145
+ mode: ctx.options.mode
1146
+ }
1147
+ },
1148
+ animations: (ctx.animations ?? []).map((animation) => ({
1149
+ ...animation,
1150
+ boundingBox: scaleBoundingBox(animation.boundingBox, sx, sy, width, height)
1151
+ }))
1152
+ };
1153
+ }
1154
+ var init_build_video_metadata = __esmMin((() => {
1155
+ init_scale_bounding_box();
1156
+ }));
1157
+
1158
+ //#endregion
1159
+ //#region src/core/utils/sheet/build-tile-label-svg.ts
1160
+ /**
1161
+ * Build a tile-sized SVG overlay with a translucent badge and an explicit text baseline.
1162
+ * @param text - A formatTileLabel result containing only digits, #, spaces, colons and periods.
1163
+ * @param tileWidth - Positive tile canvas width in pixels.
1164
+ * @param tileHeight - Positive tile canvas height in pixels.
1165
+ * @returns Encoded SVG bytes for a top-left sharp composite overlay.
1166
+ */
1167
+ function buildTileLabelSvg(text, tileWidth, tileHeight) {
1168
+ const fontSize = Math.max(8, Math.round(tileHeight * SHEET_LABEL_HEIGHT_RATIO));
1169
+ const padX = Math.round(fontSize * .4);
1170
+ const padY = Math.round(fontSize * .2);
1171
+ const badgeWidth = Math.min(tileWidth, Math.ceil(text.length * fontSize * .6) + 2 * padX);
1172
+ 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>`);
1173
+ }
1174
+ var init_build_tile_label_svg = __esmMin((() => {
1175
+ init_pipeline_defaults();
1176
+ }));
1177
+
1178
+ //#endregion
1179
+ //#region src/core/utils/sheet/format-tile-label.ts
1180
+ /**
1181
+ * Format a contact sheet label without rounding into the next tenth of a second.
1182
+ * @param frameId - One-based candidate frame ID.
1183
+ * @param timestampMs - Nonnegative candidate time in milliseconds.
1184
+ * @returns A label in the form "#<id> mm:ss.s", allowing minutes beyond two digits.
1185
+ */
1186
+ function formatTileLabel(frameId, timestampMs) {
1187
+ const tenths = Math.floor(timestampMs / 100);
1188
+ return `#${frameId} ${String(Math.floor(tenths / 600)).padStart(2, "0")}:${String(Math.floor(tenths / 10) % 60).padStart(2, "0")}.${tenths % 10}`;
1189
+ }
1190
+ var init_format_tile_label = __esmMin((() => {}));
1191
+
1192
+ //#endregion
1193
+ //#region src/core/utils/sheet/sample-tile-frames.ts
1194
+ /**
1195
+ * Sample a sequence uniformly while always retaining both endpoints.
1196
+ * @param items - Items in temporal order.
1197
+ * @param maxTiles - Validated integer limit of at least two.
1198
+ * @returns The original sequence when within the limit, otherwise evenly sampled items.
1199
+ */
1200
+ function sampleTileFrames(items, maxTiles) {
1201
+ if (items.length <= maxTiles) return {
1202
+ items,
1203
+ sampled: false
1204
+ };
1205
+ return {
1206
+ items: Array.from({ length: maxTiles }, (_, i) => items[Math.round(i * (items.length - 1) / (maxTiles - 1))]),
1207
+ sampled: true
1208
+ };
1209
+ }
1210
+ var init_sample_tile_frames = __esmMin((() => {}));
1211
+
1212
+ //#endregion
1213
+ //#region src/core/utils/sheet/render-contact-sheet.ts
1214
+ /**
1215
+ * Read selected frame images and render a row-major contact sheet.
1216
+ * @param input - Nonempty ordered selections, positive dimensions and validated sheet settings.
1217
+ * @returns JPEG bytes and the effective tile layout with one-based frame IDs.
1218
+ * @throws Propagates sharp image reading, compositing or encoding errors.
1219
+ */
1220
+ async function renderContactSheet(input) {
1221
+ const { selected, resolution, options, quality } = input;
1222
+ const { items, sampled } = sampleTileFrames(selected, options.maxTiles);
1223
+ const columns = Math.min(options.columns, items.length);
1224
+ const rows = Math.ceil(items.length / columns);
1225
+ const tileWidth = options.tileWidth;
1226
+ const tileHeight = Math.max(1, Math.round(tileWidth * resolution.height / resolution.width));
1227
+ const gap = 4;
1228
+ const tiles = await Promise.all(map(items, async (frame, index) => {
1229
+ const tile = sharp(frame.extractPath).resize(tileWidth, tileHeight, { fit: "fill" });
1230
+ if (options.label) {
1231
+ const text = formatTileLabel(frame.id + 1, Math.round(frame.timestamp * 1e3));
1232
+ tile.composite([{
1233
+ input: buildTileLabelSvg(text, tileWidth, tileHeight),
1234
+ top: 0,
1235
+ left: 0
1236
+ }]);
1237
+ }
1238
+ return {
1239
+ input: await tile.png().toBuffer(),
1240
+ top: gap + Math.floor(index / columns) * (tileHeight + gap),
1241
+ left: gap + index % columns * (tileWidth + gap)
1242
+ };
1243
+ }));
1244
+ return {
1245
+ buffer: await sharp({ create: {
1246
+ width: columns * tileWidth + (columns + 1) * gap,
1247
+ height: rows * tileHeight + (rows + 1) * gap,
1248
+ channels: 3,
1249
+ background: "white"
1250
+ } }).composite(tiles).jpeg({
1251
+ quality,
1252
+ mozjpeg: true
1253
+ }).toBuffer(),
1254
+ metadata: {
1255
+ fileName: SHEET_FILE_NAME,
1256
+ columns,
1257
+ tileWidth,
1258
+ tileHeight,
1259
+ frameIds: map(items, (frame) => frame.id + 1),
1260
+ sampled
1261
+ }
1262
+ };
1263
+ }
1264
+ var init_render_contact_sheet = __esmMin((() => {
1265
+ init_pipeline_defaults();
1266
+ init_build_tile_label_svg();
1267
+ init_format_tile_label();
1268
+ init_sample_tile_frames();
1269
+ }));
1270
+
1271
+ //#endregion
1272
+ //#region src/core/utils/output/finalize-selection.ts
1273
+ /**
1274
+ * Read output dimensions once, render optional sheets and finalize the shared v2 document.
1275
+ * @param ctx - Pipeline state owned by the orchestrator; this function does not mutate it.
1276
+ * @param selected - Selected frames in temporal order.
1277
+ * @returns Mode-specific output, the document and zero-based API animations.
1278
+ * @throws Propagates image, rendering and output I/O errors to the orchestrator.
1279
+ */
1280
+ async function finalizeSelection(ctx, selected) {
1281
+ const { video, animations } = await buildVideoMetadata(ctx, selected, ctx.analysisResolution);
1282
+ const sheet = ctx.options.sheet && selected.length > 0 && video.resolution.width > 0 && video.resolution.height > 0 ? await renderContactSheet({
1283
+ selected,
1284
+ resolution: video.resolution,
1285
+ options: ctx.options.sheet,
1286
+ quality: ctx.options.quality
1287
+ }) : void 0;
1288
+ const document = buildSieveMetadata({
1289
+ ctx,
1290
+ selected,
1291
+ video,
1292
+ animations,
1293
+ version: PACKAGE_VERSION,
1294
+ ...sheet ? { sheet: sheet.metadata } : {}
1295
+ });
1296
+ if (ctx.options.mode === "file") return {
1297
+ outputFiles: await finalizeOutput(ctx, selected, document, sheet?.buffer),
1298
+ document,
1299
+ animations
1300
+ };
1301
+ return {
1302
+ outputFiles: [],
1303
+ outputBuffers: await readFramesAsBuffers(selected, ctx.options.quality),
1304
+ document,
1305
+ animations,
1306
+ ...sheet ? { sheetBuffer: sheet.buffer } : {}
1307
+ };
1308
+ }
1309
+ var init_finalize_selection = __esmMin((() => {
1310
+ init_package_version();
1311
+ init_workspace();
1312
+ init_build_sieve_metadata();
1313
+ init_build_video_metadata();
1314
+ init_render_contact_sheet();
1315
+ }));
1316
+
729
1317
  //#endregion
730
1318
  //#region src/core/extractor/extractor.ts
731
1319
  /**
@@ -810,7 +1398,8 @@ async function getVideoMetadata(inputPath) {
810
1398
  * @returns Zero-based candidates without a segment seek offset.
811
1399
  */
812
1400
  async function buildFrameList(framesDir, effectiveFps) {
813
- const jpgFiles = filter(await readdir(framesDir), (f) => f.endsWith(".jpg")).sort();
1401
+ const files = await readdir(framesDir);
1402
+ const jpgFiles = filter(files, (f) => f.endsWith(".jpg")).sort();
814
1403
  if (jpgFiles.length === 0) return [];
815
1404
  return map(jpgFiles, (file, index) => ({
816
1405
  id: index,
@@ -957,127 +1546,21 @@ function validateOptions(options) {
957
1546
  if (value === void 0) continue;
958
1547
  if (!Number.isFinite(value) || integer && !Number.isInteger(value) || (exclusiveMin ? value <= min : value < min) || value > max) throw new Error(`${name} must be ${requirement}, received: ${value}`);
959
1548
  }
960
- }
961
- var init_validate_options = __esmMin((() => {}));
962
-
963
- //#endregion
964
- //#region src/core/workspace/workspace.ts
965
- async function createWorkspace(sessionId) {
966
- const workspacePath = getTempWorkspaceDir(sessionId);
967
- await ensureDir(join(workspacePath, "frames"));
968
- await ensureDir(join(workspacePath, "output"));
969
- return workspacePath;
970
- }
971
- async function finalizeOutput(ctx, selectedFrames) {
972
- const stagingDir = join(ctx.workspacePath, "output");
973
- const outputPath = ctx.options.outputPath;
974
- const quality = ctx.options.quality;
975
- const outputFiles = [];
976
- const framesMetadata = [];
977
- const totalFramesCount = ctx.frames.length;
978
- const padding = Math.max(4, String(totalFramesCount).length);
979
- for (let i = 0; i < selectedFrames.length; i++) {
980
- const frame = selectedFrames[i];
981
- const fileName = `frame_${String(frame.id + 1).padStart(padding, "0")}.jpg`;
982
- const destPath = join(stagingDir, fileName);
983
- await sharp(frame.extractPath).jpeg({
984
- quality,
985
- mozjpeg: true
986
- }).toFile(destPath);
987
- outputFiles.push(join(outputPath, fileName));
988
- framesMetadata.push({
989
- step: i + 1,
990
- fileName,
991
- frameId: frame.id + 1,
992
- timestampMs: Math.round(frame.timestamp * 1e3)
993
- });
994
- }
995
- const { video, animations } = await buildVideoMetadata(ctx, selectedFrames, ctx.analysisResolution);
996
- const metadata = {
997
- video,
998
- frames: framesMetadata,
999
- animations: map(animations, (anim) => ({
1000
- ...anim,
1001
- startFrameId: anim.startFrameId + 1,
1002
- endFrameId: anim.endFrameId + 1,
1003
- durationMs: Math.round(anim.durationMs)
1004
- }))
1005
- };
1006
- await writeFile(join(stagingDir, ".metadata.json"), JSON.stringify(metadata, null, 2));
1007
- outputFiles.push(join(outputPath, ".metadata.json"));
1008
- await ensureDir(join(outputPath, ".."));
1009
- await rm(outputPath, {
1010
- recursive: true,
1011
- force: true
1012
- });
1013
- await rename(stagingDir, outputPath);
1014
- return outputFiles;
1015
- }
1016
- async function createSegmentWorkspace(parentWorkspacePath, segmentIndex) {
1017
- const segmentPath = join(parentWorkspacePath, "segments", String(segmentIndex));
1018
- await ensureDir(join(segmentPath, "frames"));
1019
- return segmentPath;
1020
- }
1021
- async function cleanupWorkspace(workspacePath) {
1022
- if (!workspacePath) return;
1023
- try {
1024
- await rm(workspacePath, {
1025
- recursive: true,
1026
- force: true
1027
- });
1028
- } catch {}
1029
- }
1030
- /**
1031
- * Write a video buffer to a temp file in the workspace and return the path.
1032
- * Used by 'buffer' input mode.
1033
- */
1034
- async function writeInputBuffer(buffer, workspacePath) {
1035
- const inputDir = join(workspacePath, "input");
1036
- await ensureDir(inputDir);
1037
- const tempPath = join(inputDir, "input.mp4");
1038
- await writeFile(tempPath, buffer);
1039
- return tempPath;
1040
- }
1041
- /**
1042
- * Write an array of frame Buffers as JPG files and return FrameNode[].
1043
- * Used by 'frames' input mode.
1044
- */
1045
- async function writeInputFrames(frames, workspacePath) {
1046
- const framesDir = join(workspacePath, "frames");
1047
- await ensureDir(framesDir);
1048
- const frameNodes = [];
1049
- for (let i = 0; i < frames.length; i++) {
1050
- const extractPath = join(framesDir, `frame_${String(i).padStart(6, "0")}${FRAME_OUTPUT_EXTENSION}`);
1051
- await writeFile(extractPath, frames[i]);
1052
- frameNodes.push({
1053
- id: i,
1054
- timestamp: i,
1055
- extractPath
1056
- });
1549
+ const sheet = options.sheet;
1550
+ if (sheet === void 0 || typeof sheet === "boolean") return;
1551
+ 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)}`);
1552
+ for (const [name, min] of [
1553
+ ["columns", 1],
1554
+ ["tileWidth", 16],
1555
+ ["maxTiles", 2]
1556
+ ]) {
1557
+ const value = sheet[name];
1558
+ if (value === void 0) continue;
1559
+ if (!Number.isInteger(value) || value < min) throw new Error(`sheet.${name} must be an integer >= ${min}, received: ${value}`);
1057
1560
  }
1058
- return frameNodes;
1561
+ if (sheet.label !== void 0 && typeof sheet.label !== "boolean") throw new Error(`sheet.label must be a boolean, received: ${sheet.label}`);
1059
1562
  }
1060
- /**
1061
- * Read selected FrameNode files as Buffers with JPEG compression.
1062
- * Used to return output buffers in 'buffer' and 'frames' modes.
1063
- */
1064
- async function readFramesAsBuffers(frameNodes, quality) {
1065
- return Promise.all(map(frameNodes, (f) => sharp(f.extractPath).jpeg({
1066
- quality,
1067
- mozjpeg: true
1068
- }).toBuffer()));
1069
- }
1070
- var init_workspace$1 = __esmMin((() => {
1071
- init_workspace_layout();
1072
- init_paths();
1073
- init_build_video_metadata();
1074
- }));
1075
-
1076
- //#endregion
1077
- //#region src/core/workspace/index.ts
1078
- var init_workspace = __esmMin((() => {
1079
- init_workspace$1();
1080
- }));
1563
+ var init_validate_options = __esmMin((() => {}));
1081
1564
 
1082
1565
  //#endregion
1083
1566
  //#region src/core/input-resolver/input-resolver.ts
@@ -1093,12 +1576,14 @@ function resolveOptions(options) {
1093
1576
  const inputPath = mode === "file" ? resolveAbsolute(options.inputPath) : void 0;
1094
1577
  const outputPath = options.outputPath ?? (inputPath ? deriveOutputPath(inputPath) : join(process.cwd(), "scene-sieve-output"));
1095
1578
  const threshold = options.threshold ?? .5;
1579
+ const pruneMode = "threshold-with-cap";
1580
+ const sheet = typeof options.sheet === "object" ? options.sheet : {};
1096
1581
  return {
1097
1582
  mode,
1098
1583
  inputPath,
1099
1584
  count: options.count ?? 20,
1100
1585
  threshold,
1101
- pruneMode: "threshold-with-cap",
1586
+ pruneMode,
1102
1587
  outputPath,
1103
1588
  fps: options.fps ?? 5,
1104
1589
  maxFrames: options.maxFrames ?? 300,
@@ -1108,7 +1593,14 @@ function resolveOptions(options) {
1108
1593
  animationThreshold: options.animationThreshold ?? 5,
1109
1594
  debug: options.debug ?? false,
1110
1595
  maxSegmentDuration: options.maxSegmentDuration ?? 300,
1111
- concurrency: options.concurrency ?? 2
1596
+ concurrency: options.concurrency ?? 2,
1597
+ sheet: options.sheet ? {
1598
+ columns: sheet.columns ?? 4,
1599
+ tileWidth: sheet.tileWidth ?? 320,
1600
+ maxTiles: sheet.maxTiles ?? 40,
1601
+ label: sheet.label ?? true
1602
+ } : null,
1603
+ includeEdges: options.includeEdges ?? false
1112
1604
  };
1113
1605
  }
1114
1606
  /**
@@ -1222,7 +1714,7 @@ function normalizeScores(items) {
1222
1714
  if (s <= 0) return 0;
1223
1715
  return lowerBound(sorted, s) / sorted.length;
1224
1716
  });
1225
- return map(logisticZ, (z, i) => z * (1 - NORMALIZATION_ALPHA) + cdf[i] * NORMALIZATION_ALPHA);
1717
+ return map(logisticZ, (z, i) => z * .6 + cdf[i] * NORMALIZATION_ALPHA);
1226
1718
  }
1227
1719
  var NORMALIZATION_ALPHA, NORMALIZATION_MAD_COEFFICIENT, NORMALIZATION_MIN_SAMPLE_SIZE;
1228
1720
  var init_normalize_scores = __esmMin((() => {
@@ -1645,16 +2137,16 @@ function remapEdges(segmentResults, globalIdMap) {
1645
2137
  const existingIdx = edgeMap.get(edgeKey);
1646
2138
  if (existingIdx !== void 0) {
1647
2139
  if (edges[existingIdx].score < edge.score) edges[existingIdx] = {
2140
+ ...edge,
1648
2141
  sourceId: newSourceId,
1649
- targetId: newTargetId,
1650
- score: edge.score
2142
+ targetId: newTargetId
1651
2143
  };
1652
2144
  } else {
1653
2145
  edgeMap.set(edgeKey, edges.length);
1654
2146
  edges.push({
2147
+ ...edge,
1655
2148
  sourceId: newSourceId,
1656
- targetId: newTargetId,
1657
- score: edge.score
2149
+ targetId: newTargetId
1658
2150
  });
1659
2151
  }
1660
2152
  }
@@ -1729,7 +2221,8 @@ function buildSegmentContext(segment, frames, segmentWorkspacePath, resolvedOpti
1729
2221
  * Each segment uses an isolated workspace directory.
1730
2222
  */
1731
2223
  async function processSegment(inputPath, segment, workspacePath, resolvedOptions, onProgress) {
1732
- const frames = await extractFramesForRange(inputPath, join(workspacePath, "frames"), segment.effectiveFps, resolvedOptions.scale, segment.extractStartTime, segment.extractDuration, segment.allocatedFrames);
2224
+ const framesDir = join(workspacePath, "frames");
2225
+ const frames = await extractFramesForRange(inputPath, framesDir, segment.effectiveFps, resolvedOptions.scale, segment.extractStartTime, segment.extractDuration, segment.allocatedFrames);
1733
2226
  if (frames.length < 2) return {
1734
2227
  segment,
1735
2228
  frames,
@@ -1740,7 +2233,8 @@ async function processSegment(inputPath, segment, workspacePath, resolvedOptions
1740
2233
  height: 0
1741
2234
  }
1742
2235
  };
1743
- const { edges, animations, analysisResolution } = await analyzeFrames(buildSegmentContext(segment, frames, workspacePath, resolvedOptions, onProgress));
2236
+ const ctx = buildSegmentContext(segment, frames, workspacePath, resolvedOptions, onProgress);
2237
+ const { edges, animations, analysisResolution } = await analyzeFrames(ctx);
1744
2238
  return {
1745
2239
  segment,
1746
2240
  frames,
@@ -1805,20 +2299,20 @@ async function runSegmentedPipeline(options, resolvedOptions) {
1805
2299
  status: "FINALIZING",
1806
2300
  emitProgress: (percent) => options.onProgress?.("FINALIZING", percent)
1807
2301
  };
1808
- let outputFiles = [];
1809
- let outputBuffers;
1810
- if (resolvedOptions.mode === "buffer" || resolvedOptions.mode === "frames") outputBuffers = await readFramesAsBuffers(prunedFrames, resolvedOptions.quality);
1811
- else outputFiles = await finalizeOutput(ctx, prunedFrames);
2302
+ const finalized = await finalizeSelection(ctx, prunedFrames);
1812
2303
  options.onProgress?.("FINALIZING", 100);
1813
2304
  logger.success(`Segmented pipeline: ${prunedFrames.length} scenes from ${frames.length} frames (${segments.length} segments)`);
1814
- const outputMetadata = await buildVideoMetadata(ctx, prunedFrames, analysisResolution);
1815
2305
  return {
1816
2306
  success: true,
1817
2307
  originalFramesCount: frames.length,
1818
2308
  prunedFramesCount: prunedFrames.length,
1819
- outputFiles,
1820
- outputBuffers,
1821
- ...outputMetadata,
2309
+ outputFiles: finalized.outputFiles,
2310
+ outputBuffers: finalized.outputBuffers,
2311
+ video: finalized.document.video,
2312
+ animations: finalized.animations,
2313
+ frames: finalized.document.frames,
2314
+ ...finalized.document.sheet ? { sheet: finalized.document.sheet } : {},
2315
+ ...finalized.sheetBuffer ? { sheetBuffer: finalized.sheetBuffer } : {},
1822
2316
  executionTimeMs: Date.now() - pipelineStart
1823
2317
  };
1824
2318
  } catch (error) {
@@ -1834,7 +2328,7 @@ var init_segmenter$1 = __esmMin((() => {
1834
2328
  init_concurrency();
1835
2329
  init_logger();
1836
2330
  init_analyzer();
1837
- init_build_video_metadata();
2331
+ init_finalize_selection();
1838
2332
  init_extractor();
1839
2333
  init_input_resolver();
1840
2334
  init_pruner();
@@ -1849,6 +2343,12 @@ var init_segmenter = __esmMin((() => {
1849
2343
 
1850
2344
  //#endregion
1851
2345
  //#region src/core/orchestrator/orchestrator.ts
2346
+ /**
2347
+ * Run the five pipeline stages, delegating segmented inputs to the segmenter.
2348
+ * @param options - Mode-specific input and optional pipeline settings.
2349
+ * @returns Selected outputs with v2 frame metadata and zero-based API animations.
2350
+ * @throws Propagates stage failures after cleaning the workspace unless debug is enabled.
2351
+ */
1852
2352
  async function runPipeline(options) {
1853
2353
  setDebugMode(options.debug ?? false);
1854
2354
  const resolvedOptions = resolveOptions(options);
@@ -1896,25 +2396,21 @@ async function runPipeline(options) {
1896
2396
  const prunedFrames = filter(ctx.frames, (f) => survivingIds.has(f.id));
1897
2397
  ctx.emitProgress(100);
1898
2398
  ctx.status = "FINALIZING";
1899
- let outputFiles = [];
1900
- let outputBuffers;
1901
- if (resolvedOptions.mode === "buffer" || resolvedOptions.mode === "frames") {
1902
- outputBuffers = await readFramesAsBuffers(prunedFrames, resolvedOptions.quality);
1903
- ctx.emitProgress(100);
1904
- } else {
1905
- outputFiles = await finalizeOutput(ctx, prunedFrames);
1906
- ctx.emitProgress(100);
1907
- }
2399
+ const finalized = await finalizeSelection(ctx, prunedFrames);
2400
+ ctx.emitProgress(100);
1908
2401
  ctx.status = "SUCCESS";
1909
2402
  logger.success(`Extracted ${prunedFrames.length} scenes from ${ctx.frames.length} frames`);
1910
- const metadata = await buildVideoMetadata(ctx, prunedFrames, analysisResolution);
1911
2403
  return {
1912
2404
  success: true,
1913
2405
  originalFramesCount: ctx.frames.length,
1914
2406
  prunedFramesCount: prunedFrames.length,
1915
- outputFiles,
1916
- outputBuffers,
1917
- ...metadata,
2407
+ outputFiles: finalized.outputFiles,
2408
+ outputBuffers: finalized.outputBuffers,
2409
+ video: finalized.document.video,
2410
+ animations: finalized.animations,
2411
+ frames: finalized.document.frames,
2412
+ ...finalized.document.sheet ? { sheet: finalized.document.sheet } : {},
2413
+ ...finalized.sheetBuffer ? { sheetBuffer: finalized.sheetBuffer } : {},
1918
2414
  executionTimeMs: Date.now() - startTime
1919
2415
  };
1920
2416
  } catch (error) {
@@ -1930,7 +2426,7 @@ async function runPipeline(options) {
1930
2426
  var init_orchestrator = __esmMin((() => {
1931
2427
  init_logger();
1932
2428
  init_analyzer();
1933
- init_build_video_metadata();
2429
+ init_finalize_selection();
1934
2430
  init_extractor();
1935
2431
  init_input_resolver();
1936
2432
  init_pruner();