@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.cjs CHANGED
@@ -28,7 +28,7 @@ var __copyProps = (to, from, except, desc) => {
28
28
  }
29
29
  return to;
30
30
  };
31
- var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", {
31
+ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(isNodeMode || !mod || !mod.__esModule || !__hasOwnProp.call(mod, "default") ? __defProp(target, "default", {
32
32
  value: mod,
33
33
  enumerable: true
34
34
  }) : target, mod));
@@ -41,13 +41,14 @@ picocolors = __toESM(picocolors, 1);
41
41
  let node_module = require("node:module");
42
42
  let sharp = require("sharp");
43
43
  sharp = __toESM(sharp, 1);
44
+ let node_fs = require("node:fs");
44
45
  let node_fs_promises = require("node:fs/promises");
45
46
  let node_path = require("node:path");
47
+ let node_os = require("node:os");
46
48
  let _ffprobe_installer_ffprobe = require("@ffprobe-installer/ffprobe");
47
49
  let execa = require("execa");
48
50
  let ffmpeg_static = require("ffmpeg-static");
49
51
  ffmpeg_static = __toESM(ffmpeg_static, 1);
50
- let node_os = require("node:os");
51
52
 
52
53
  //#region src/logging/logger.ts
53
54
  function setDebugMode(enabled) {
@@ -76,18 +77,23 @@ var init_logger = __esmMin((() => {
76
77
  console.error(`${picocolors.default.red("error")} ${message}`);
77
78
  },
78
79
  debug(message) {
79
- if (debugMode) if (jsonMode) process.stderr.write(`${picocolors.default.gray(`[${timestamp()}] debug`)} ${message}\n`);
80
- else console.log(`${picocolors.default.gray(`[${timestamp()}] debug`)} ${message}`);
80
+ if (debugMode) {
81
+ if (jsonMode) process.stderr.write(`${picocolors.default.gray(`[${timestamp()}] debug`)} ${message}\n`);
82
+ else console.log(`${picocolors.default.gray(`[${timestamp()}] debug`)} ${message}`);
83
+ }
81
84
  }
82
85
  };
83
86
  }));
84
87
 
85
88
  //#endregion
86
89
  //#region src/constants/pipeline-defaults.ts
87
- var DEFAULT_THRESHOLD, IOU_THRESHOLD;
90
+ var DEFAULT_THRESHOLD, IOU_THRESHOLD, SHEET_LABEL_HEIGHT_RATIO, SHEET_FILE_NAME, METADATA_FILE_NAME;
88
91
  var init_pipeline_defaults = __esmMin((() => {
89
92
  DEFAULT_THRESHOLD = .5;
90
93
  IOU_THRESHOLD = .9;
94
+ SHEET_LABEL_HEIGHT_RATIO = .07;
95
+ SHEET_FILE_NAME = "sheet.jpg";
96
+ METADATA_FILE_NAME = ".metadata.json";
91
97
  }));
92
98
 
93
99
  //#endregion
@@ -438,7 +444,8 @@ async function analyzeBatch(cvLib, akaze, frames, carry, scale, tracker, pairOff
438
444
  try {
439
445
  prev ??= computeFrameFeatures(cvLib, akaze, preprocessed[i]);
440
446
  next = computeFrameFeatures(cvLib, akaze, preprocessed[i + 1]);
441
- let dbscanResult = dbscan(computeNewPoints(cvLib, prev, next), imageWidth, imageHeight);
447
+ const sNew = computeNewPoints(cvLib, prev, next);
448
+ let dbscanResult = dbscan(sNew, imageWidth, imageHeight);
442
449
  let clusters = dbscanResult.boundingBoxes;
443
450
  if (clusters.length === 0) {
444
451
  const pixelDiffPoints = computePixelDiff(cvLib, preprocessed[i], preprocessed[i + 1]);
@@ -451,13 +458,18 @@ async function analyzeBatch(cvLib, akaze, frames, carry, scale, tracker, pairOff
451
458
  const clusterPointCounts = new Array(clusters.length).fill(0);
452
459
  for (const label of dbscanResult.labels) if (label >= 0) clusterPointCounts[label]++;
453
460
  const animationIndices = tracker.update(clusters, pairIndex);
461
+ const change = {
462
+ regions: (0, _winglet_common_utils.filter)(clusters, (_, ci) => !animationIndices.has(ci)),
463
+ animatedRegions: (0, _winglet_common_utils.filter)(clusters, (_, ci) => animationIndices.has(ci))
464
+ };
454
465
  const animationWeights = (0, _winglet_common_utils.map)(clusters, (_, ci) => animationIndices.has(ci) ? tracker.getAnimationWeight(ci, clusters) : 0);
455
466
  const score = computeInformationGain(clusters, clusterPointCounts, imageArea, animationIndices, animationWeights);
456
467
  logger.debug(`Edge ${frames[i].id}->${frames[i + 1].id} G(t)=${score.toFixed(6)}`);
457
468
  edges.push({
458
469
  sourceId: frames[i].id,
459
470
  targetId: frames[i + 1].id,
460
- score
471
+ score,
472
+ change
461
473
  });
462
474
  } catch (err) {
463
475
  logger.warn(`Frame pair analysis failed: ${String(err)}`);
@@ -655,51 +667,12 @@ var init_analyzer = __esmMin((() => {
655
667
  }));
656
668
 
657
669
  //#endregion
658
- //#region src/core/utils/metadata/build-video-metadata.ts
659
- /**
660
- * Read output dimensions and build consistent video and animation metadata.
661
- * JPEG finalization does not resize, so the source dimensions match the output.
662
- * @param ctx Pipeline state with source duration, effective FPS and analysis-space animations.
663
- * @param selected Selected frames in output order; the first candidate is the fallback.
664
- * @param analysisResolution Analysis dimensions; absent or zero dimensions imply no scaling.
665
- * @returns Video metadata and new output-space animations, retaining zero-based frame IDs.
666
- * @throws If sharp cannot read the selected or fallback image. Empty input performs no image I/O.
667
- */
668
- async function buildVideoMetadata(ctx, selected, analysisResolution) {
669
- const firstFrame = selected[0] ?? ctx.frames[0];
670
- const dimensions = firstFrame ? await (0, sharp.default)(firstFrame.extractPath).metadata() : void 0;
671
- const width = dimensions?.width ?? 0;
672
- const height = dimensions?.height ?? 0;
673
- const sx = analysisResolution?.width ? width / analysisResolution.width : 1;
674
- const sy = analysisResolution?.height ? height / analysisResolution.height : 1;
675
- const lastTimestamp = ctx.frames[ctx.frames.length - 1]?.timestamp ?? 0;
676
- const duration = ctx.options.mode === "frames" ? lastTimestamp : ctx.sourceDurationSec ?? lastTimestamp;
677
- return {
678
- video: {
679
- originalDurationMs: Math.round(duration * 1e3),
680
- fps: ctx.options.mode === "frames" ? 1 : ctx.effectiveFps ?? ctx.options.fps,
681
- resolution: {
682
- width,
683
- height
684
- }
685
- },
686
- animations: (ctx.animations ?? []).map((animation) => {
687
- const box = animation.boundingBox;
688
- const x = Math.max(0, Math.min(width, Math.round(box.x * sx)));
689
- const y = Math.max(0, Math.min(height, Math.round(box.y * sy)));
690
- return {
691
- ...animation,
692
- boundingBox: {
693
- x,
694
- y,
695
- width: Math.max(0, Math.min(width - x, Math.round(box.width * sx))),
696
- height: Math.max(0, Math.min(height - y, Math.round(box.height * sy)))
697
- }
698
- };
699
- })
700
- };
701
- }
702
- var init_build_video_metadata = __esmMin((() => {}));
670
+ //#region src/constants/package-version.ts
671
+ var manifestPath, PACKAGE_VERSION;
672
+ var init_package_version = __esmMin((() => {
673
+ manifestPath = (0, node_fs.existsSync)(new URL("../package.json", require("url").pathToFileURL(__filename).href)) ? "../package.json" : "../../package.json";
674
+ PACKAGE_VERSION = (0, node_module.createRequire)(require("url").pathToFileURL(__filename).href)(manifestPath).version;
675
+ }));
703
676
 
704
677
  //#endregion
705
678
  //#region src/core/constants/workspace-layout.ts
@@ -750,10 +723,625 @@ function resolveAbsolute(p) {
750
723
  * e.g., /path/to/video.mp4 -> /path/to/video_scenes
751
724
  */
752
725
  function deriveOutputPath(inputPath) {
753
- return (0, node_path.resolve)((0, node_path.resolve)(inputPath, ".."), `${(0, node_path.basename)(inputPath, (0, node_path.extname)(inputPath))}_scenes`);
726
+ const dir = (0, node_path.resolve)(inputPath, "..");
727
+ const name = (0, node_path.basename)(inputPath, (0, node_path.extname)(inputPath));
728
+ return (0, node_path.resolve)(dir, `${name}_scenes`);
754
729
  }
755
730
  var init_paths = __esmMin((() => {}));
756
731
 
732
+ //#endregion
733
+ //#region src/core/workspace/workspace.ts
734
+ async function createWorkspace(sessionId) {
735
+ const workspacePath = getTempWorkspaceDir(sessionId);
736
+ await ensureDir((0, node_path.join)(workspacePath, "frames"));
737
+ await ensureDir((0, node_path.join)(workspacePath, "output"));
738
+ return workspacePath;
739
+ }
740
+ /**
741
+ * Write selected JPEGs and the supplied document before replacing the output directory.
742
+ * @param ctx - Workspace, quality and destination settings.
743
+ * @param selectedFrames - Frames paired by position with document.frames.
744
+ * @param document - Complete metadata, including the output file names.
745
+ * @param sheetBuffer - Optional JPEG contact sheet bytes to persist unchanged.
746
+ * @returns Selected JPEG paths, optional sheet path and finally the metadata path.
747
+ * @throws Rejects mismatched frame counts and propagates image or filesystem errors.
748
+ */
749
+ async function finalizeOutput(ctx, selectedFrames, document, sheetBuffer) {
750
+ if (document.frames.length !== selectedFrames.length) throw new Error("metadata frame count must match selected frame count");
751
+ const stagingDir = (0, node_path.join)(ctx.workspacePath, "output");
752
+ const outputPath = ctx.options.outputPath;
753
+ const quality = ctx.options.quality;
754
+ const outputFiles = [];
755
+ for (let i = 0; i < selectedFrames.length; i++) {
756
+ const frame = selectedFrames[i];
757
+ const fileName = document.frames[i].fileName;
758
+ const destPath = (0, node_path.join)(stagingDir, fileName);
759
+ await (0, sharp.default)(frame.extractPath).jpeg({
760
+ quality,
761
+ mozjpeg: true
762
+ }).toFile(destPath);
763
+ outputFiles.push((0, node_path.join)(outputPath, fileName));
764
+ }
765
+ if (sheetBuffer) {
766
+ await (0, node_fs_promises.writeFile)((0, node_path.join)(stagingDir, SHEET_FILE_NAME), sheetBuffer);
767
+ outputFiles.push((0, node_path.join)(outputPath, SHEET_FILE_NAME));
768
+ }
769
+ const metadataPath = (0, node_path.join)(stagingDir, METADATA_FILE_NAME);
770
+ await (0, node_fs_promises.writeFile)(metadataPath, JSON.stringify(document, null, 2));
771
+ outputFiles.push((0, node_path.join)(outputPath, METADATA_FILE_NAME));
772
+ await ensureDir((0, node_path.join)(outputPath, ".."));
773
+ await (0, node_fs_promises.rm)(outputPath, {
774
+ recursive: true,
775
+ force: true
776
+ });
777
+ await (0, node_fs_promises.rename)(stagingDir, outputPath);
778
+ return outputFiles;
779
+ }
780
+ async function createSegmentWorkspace(parentWorkspacePath, segmentIndex) {
781
+ const segmentPath = (0, node_path.join)(parentWorkspacePath, "segments", String(segmentIndex));
782
+ await ensureDir((0, node_path.join)(segmentPath, "frames"));
783
+ return segmentPath;
784
+ }
785
+ async function cleanupWorkspace(workspacePath) {
786
+ if (!workspacePath) return;
787
+ try {
788
+ await (0, node_fs_promises.rm)(workspacePath, {
789
+ recursive: true,
790
+ force: true
791
+ });
792
+ } catch {}
793
+ }
794
+ /**
795
+ * Write a video buffer to a temp file in the workspace and return the path.
796
+ * Used by 'buffer' input mode.
797
+ */
798
+ async function writeInputBuffer(buffer, workspacePath) {
799
+ const inputDir = (0, node_path.join)(workspacePath, "input");
800
+ await ensureDir(inputDir);
801
+ const tempPath = (0, node_path.join)(inputDir, "input.mp4");
802
+ await (0, node_fs_promises.writeFile)(tempPath, buffer);
803
+ return tempPath;
804
+ }
805
+ /**
806
+ * Write an array of frame Buffers as JPG files and return FrameNode[].
807
+ * Used by 'frames' input mode.
808
+ */
809
+ async function writeInputFrames(frames, workspacePath) {
810
+ const framesDir = (0, node_path.join)(workspacePath, "frames");
811
+ await ensureDir(framesDir);
812
+ const frameNodes = [];
813
+ for (let i = 0; i < frames.length; i++) {
814
+ const filename = `frame_${String(i).padStart(6, "0")}${FRAME_OUTPUT_EXTENSION}`;
815
+ const extractPath = (0, node_path.join)(framesDir, filename);
816
+ await (0, node_fs_promises.writeFile)(extractPath, frames[i]);
817
+ frameNodes.push({
818
+ id: i,
819
+ timestamp: i,
820
+ extractPath
821
+ });
822
+ }
823
+ return frameNodes;
824
+ }
825
+ /**
826
+ * Read selected FrameNode files as Buffers with JPEG compression.
827
+ * Used to return output buffers in 'buffer' and 'frames' modes.
828
+ */
829
+ async function readFramesAsBuffers(frameNodes, quality) {
830
+ return Promise.all((0, _winglet_common_utils.map)(frameNodes, (f) => (0, sharp.default)(f.extractPath).jpeg({
831
+ quality,
832
+ mozjpeg: true
833
+ }).toBuffer()));
834
+ }
835
+ var init_workspace$1 = __esmMin((() => {
836
+ init_workspace_layout();
837
+ init_pipeline_defaults();
838
+ init_paths();
839
+ }));
840
+
841
+ //#endregion
842
+ //#region src/core/workspace/index.ts
843
+ var init_workspace = __esmMin((() => {
844
+ init_workspace$1();
845
+ }));
846
+
847
+ //#endregion
848
+ //#region src/core/utils/metadata/change/union-area/y-coverage-tree.ts
849
+ var YCoverageTree;
850
+ var init_y_coverage_tree = __esmMin((() => {
851
+ YCoverageTree = class {
852
+ bounds;
853
+ /** Number of whole-node covering intervals, independent of descendants. */
854
+ counts;
855
+ /** Covered geometric length for each node, including partially covered children. */
856
+ lengths;
857
+ /**
858
+ * Allocate linear storage for the elementary intervals between coordinates.
859
+ * @param bounds - At least two sorted unique finite y endpoints.
860
+ */
861
+ constructor(bounds) {
862
+ this.bounds = bounds;
863
+ this.counts = Array(4 * bounds.length).fill(0);
864
+ this.lengths = Array(4 * bounds.length).fill(0);
865
+ }
866
+ /** Total active covered y length, in the input coordinate system. */
867
+ get coveredLength() {
868
+ return this.lengths[1];
869
+ }
870
+ /**
871
+ * Adjust a nonempty half-open interval and refresh its ancestors' lengths.
872
+ * @param start - Inclusive endpoint index; 0 <= start < end.
873
+ * @param end - Exclusive endpoint index; end < bounds.length.
874
+ * @param delta - One on entry, minus one for the matching departure.
875
+ * @param node - Internal tree slot; callers use the root default.
876
+ * @param left - Inclusive endpoint index of this node.
877
+ * @param right - Exclusive endpoint index of this node.
878
+ * @returns Nothing; mutates this tree's coverage in O(log n).
879
+ */
880
+ update(start, end, delta, node = 1, left = 0, right = this.bounds.length - 1) {
881
+ if (start <= left && right <= end) this.counts[node] += delta;
882
+ else {
883
+ const middle = Math.floor((left + right) / 2);
884
+ if (start < middle) this.update(start, end, delta, node * 2, left, middle);
885
+ if (end > middle) this.update(start, end, delta, node * 2 + 1, middle, right);
886
+ }
887
+ 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];
888
+ }
889
+ };
890
+ }));
891
+
892
+ //#endregion
893
+ //#region src/core/utils/metadata/change/union-area.ts
894
+ /**
895
+ * Measure rectangle union with an x sweep in O(n log n) time and O(n) space.
896
+ * @param boxes - Finite rectangles; nonpositive dimensions are ignored.
897
+ * @returns Covered area in the input coordinate system, or zero for empty input.
898
+ */
899
+ function unionArea(boxes) {
900
+ const events = [];
901
+ const endpoints = [];
902
+ for (const box of boxes) {
903
+ if (!(box.width > 0 && box.height > 0)) continue;
904
+ const end = box.y + box.height;
905
+ if (end === box.y) continue;
906
+ events.push({
907
+ x: box.x,
908
+ start: box.y,
909
+ end,
910
+ delta: 1
911
+ });
912
+ events.push({
913
+ x: box.x + box.width,
914
+ start: box.y,
915
+ end,
916
+ delta: -1
917
+ });
918
+ endpoints.push(box.y, end);
919
+ }
920
+ if (events.length === 0) return 0;
921
+ events.sort((a, b) => a.x - b.x);
922
+ endpoints.sort((a, b) => a - b);
923
+ const bounds = endpoints.filter((value, index) => index === 0 || value !== endpoints[index - 1]);
924
+ const indices = new Map(bounds.map((value, index) => [value, index]));
925
+ const coverage = new YCoverageTree(bounds);
926
+ let area = 0;
927
+ let previousX = events[0].x;
928
+ for (const event of events) {
929
+ area += (event.x - previousX) * coverage.coveredLength;
930
+ coverage.update(indices.get(event.start), indices.get(event.end), event.delta);
931
+ previousX = event.x;
932
+ }
933
+ return area;
934
+ }
935
+ var init_union_area = __esmMin((() => {
936
+ init_y_coverage_tree();
937
+ }));
938
+
939
+ //#endregion
940
+ //#region src/core/utils/metadata/build-edge-metadata.ts
941
+ /**
942
+ * Serialize raw candidate edges in graph order.
943
+ * @param graph - Adjacent-pair edges, optionally carrying tracker partitions.
944
+ * @param analysisResolution - Analysis dimensions used for both area fractions.
945
+ * @returns One-based IDs with six-decimal scores and four-decimal clamped ratios.
946
+ */
947
+ function buildEdgeMetadata(graph, analysisResolution) {
948
+ const area = analysisResolution.width * analysisResolution.height;
949
+ return (0, _winglet_common_utils.map)(graph, (edge) => ({
950
+ sourceFrameId: edge.sourceId + 1,
951
+ targetFrameId: edge.targetId + 1,
952
+ score: Math.round(edge.score * 1e6) / 1e6,
953
+ areaRatio: area > 0 ? Math.round(Math.max(0, Math.min(1, unionArea(edge.change?.regions ?? []) / area)) * 1e4) / 1e4 : 0,
954
+ animatedAreaRatio: area > 0 ? Math.round(Math.max(0, Math.min(1, unionArea(edge.change?.animatedRegions ?? []) / area)) * 1e4) / 1e4 : 0
955
+ }));
956
+ }
957
+ var init_build_edge_metadata = __esmMin((() => {
958
+ init_union_area();
959
+ }));
960
+
961
+ //#endregion
962
+ //#region src/core/utils/metadata/scale-bounding-box.ts
963
+ /**
964
+ * Convert an analysis box to clamped integer output pixels.
965
+ * @param box - Analysis-space rectangle; the input remains unchanged.
966
+ * @param sx - Horizontal output-to-analysis scale.
967
+ * @param sy - Vertical output-to-analysis scale.
968
+ * @param width - Nonnegative output image width.
969
+ * @param height - Nonnegative output image height.
970
+ * @returns A rectangle contained within the output dimensions.
971
+ */
972
+ function scaleBoundingBox(box, sx, sy, width, height) {
973
+ const x = Math.max(0, Math.min(width, Math.round(box.x * sx)));
974
+ const y = Math.max(0, Math.min(height, Math.round(box.y * sy)));
975
+ return {
976
+ x,
977
+ y,
978
+ width: Math.max(0, Math.min(width - x, Math.round(box.width * sx))),
979
+ height: Math.max(0, Math.min(height - y, Math.round(box.height * sy)))
980
+ };
981
+ }
982
+ var init_scale_bounding_box = __esmMin((() => {}));
983
+
984
+ //#endregion
985
+ //#region src/core/utils/metadata/change/select-regions.ts
986
+ /**
987
+ * Select distinct positive-area boxes with deterministic area and coordinate ties.
988
+ * @param boxes - Already scaled output rectangles; the input is not mutated.
989
+ * @param limit - Nonnegative maximum number of regions.
990
+ * @returns Largest boxes ordered by area descending, then y, x and width ascending.
991
+ */
992
+ function selectRegions(boxes, limit) {
993
+ return (0, _winglet_common_utils.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);
994
+ }
995
+ var init_select_regions = __esmMin((() => {}));
996
+
997
+ //#endregion
998
+ //#region src/core/utils/metadata/change/build-frame-change.ts
999
+ /**
1000
+ * Aggregate adjacent-pair evidence across one selected-frame span.
1001
+ * @param input - Ordered raw edges, one-based previous ID, skipped count and dimensions.
1002
+ * @returns Rounded raw scores, analysis-space union ratio and output-space regions.
1003
+ */
1004
+ function buildFrameChange(input) {
1005
+ const { spanEdges, fromFrameId, skippedCandidates, analysisResolution, outputResolution, regionLimit } = input;
1006
+ const boxes = spanEdges.flatMap((edge) => edge.change?.regions ?? []);
1007
+ const area = analysisResolution.width * analysisResolution.height;
1008
+ const peakScore = spanEdges.reduce((peak, edge) => Math.max(peak, edge.score), 0);
1009
+ const sumScore = spanEdges.reduce((sum, edge) => sum + edge.score, 0);
1010
+ const areaRatio = area > 0 ? Math.max(0, Math.min(1, unionArea(boxes) / area)) : 0;
1011
+ const regions = area > 0 ? selectRegions((0, _winglet_common_utils.map)(boxes, (box) => scaleBoundingBox(box, outputResolution.width / analysisResolution.width, outputResolution.height / analysisResolution.height, outputResolution.width, outputResolution.height)), regionLimit) : [];
1012
+ return {
1013
+ fromFrameId,
1014
+ skippedCandidates,
1015
+ peakScore: Math.round(peakScore * 1e6) / 1e6,
1016
+ sumScore: Math.round(sumScore * 1e6) / 1e6,
1017
+ areaRatio: Math.round(areaRatio * 1e4) / 1e4,
1018
+ regions
1019
+ };
1020
+ }
1021
+ var init_build_frame_change = __esmMin((() => {
1022
+ init_scale_bounding_box();
1023
+ init_select_regions();
1024
+ init_union_area();
1025
+ }));
1026
+
1027
+ //#endregion
1028
+ //#region src/core/utils/metadata/build-frame-metadata.ts
1029
+ /**
1030
+ * Describe selected frames using candidate adjacency rather than synthetic pruning edges.
1031
+ * @param input - Chronological candidates and selections, raw graph and output dimensions.
1032
+ * @returns One-based frame summaries with rounded timestamps and nonnegative holds.
1033
+ */
1034
+ function buildFrameMetadata(input) {
1035
+ const { frames, graph, selected, originalDurationMs, analysisResolution, outputResolution } = input;
1036
+ const edgesByPair = new Map((0, _winglet_common_utils.map)(graph, (edge) => [`${edge.sourceId}:${edge.targetId}`, edge]));
1037
+ const candidateIndices = new Map((0, _winglet_common_utils.map)(frames, (frame, index) => [frame.id, index]));
1038
+ const padding = Math.max(4, String(frames.length).length);
1039
+ return (0, _winglet_common_utils.map)(selected, (frame, index) => {
1040
+ const timestampMs = Math.round(frame.timestamp * 1e3);
1041
+ const nextTimestampMs = index + 1 < selected.length ? Math.round(selected[index + 1].timestamp * 1e3) : originalDurationMs;
1042
+ const previous = selected[index - 1];
1043
+ const previousIndex = previous ? candidateIndices.get(previous.id) : 0;
1044
+ const currentIndex = candidateIndices.get(frame.id);
1045
+ const spanEdges = [];
1046
+ if (previous) for (let i = previousIndex; i < currentIndex; i++) {
1047
+ const edge = edgesByPair.get(`${frames[i].id}:${frames[i + 1].id}`);
1048
+ if (edge) spanEdges.push(edge);
1049
+ }
1050
+ return {
1051
+ step: index + 1,
1052
+ fileName: `frame_${String(frame.id + 1).padStart(padding, "0")}.jpg`,
1053
+ frameId: frame.id + 1,
1054
+ timestampMs,
1055
+ holdsMs: Math.max(0, nextTimestampMs - timestampMs),
1056
+ change: previous ? buildFrameChange({
1057
+ spanEdges,
1058
+ fromFrameId: previous.id + 1,
1059
+ skippedCandidates: currentIndex - previousIndex - 1,
1060
+ analysisResolution,
1061
+ outputResolution,
1062
+ regionLimit: 5
1063
+ }) : null
1064
+ };
1065
+ });
1066
+ }
1067
+ var init_build_frame_metadata = __esmMin((() => {
1068
+ init_pipeline_defaults();
1069
+ init_build_frame_change();
1070
+ }));
1071
+
1072
+ //#endregion
1073
+ //#region src/core/utils/metadata/build-tool-metadata.ts
1074
+ /**
1075
+ * Record the tool and the nine selection and encoding settings in contract order.
1076
+ * @param options - Validated pipeline settings; operational options are excluded.
1077
+ * @param version - Runtime package version supplied by the I/O boundary.
1078
+ * @returns Deterministic tool provenance without paths or execution details.
1079
+ */
1080
+ function buildToolMetadata(options, version) {
1081
+ return {
1082
+ name: "@lumy-pack/scene-sieve",
1083
+ version,
1084
+ params: {
1085
+ fps: options.fps,
1086
+ count: options.count,
1087
+ threshold: options.threshold,
1088
+ scale: options.scale,
1089
+ quality: options.quality,
1090
+ maxFrames: options.maxFrames,
1091
+ iouThreshold: options.iouThreshold,
1092
+ animationThreshold: options.animationThreshold,
1093
+ maxSegmentDuration: options.maxSegmentDuration
1094
+ }
1095
+ };
1096
+ }
1097
+ var init_build_tool_metadata = __esmMin((() => {}));
1098
+
1099
+ //#endregion
1100
+ //#region src/core/utils/metadata/build-sieve-metadata.ts
1101
+ /**
1102
+ * Assemble the complete v2 document without I/O or mutation.
1103
+ * @param input - Pipeline state, selections, output-space video and zero-based animations.
1104
+ * @returns Contract-ordered metadata, omitting unrequested optional keys entirely.
1105
+ */
1106
+ function buildSieveMetadata(input) {
1107
+ const { ctx, selected, video, animations, version, sheet } = input;
1108
+ const analysisResolution = ctx.analysisResolution ?? {
1109
+ width: 0,
1110
+ height: 0
1111
+ };
1112
+ return {
1113
+ metadataVersion: 2,
1114
+ tool: buildToolMetadata(ctx.options, version),
1115
+ video,
1116
+ frames: buildFrameMetadata({
1117
+ frames: ctx.frames,
1118
+ graph: ctx.graph,
1119
+ selected,
1120
+ originalDurationMs: video.originalDurationMs,
1121
+ analysisResolution,
1122
+ outputResolution: video.resolution
1123
+ }),
1124
+ animations: (0, _winglet_common_utils.map)(animations, (animation) => ({
1125
+ ...animation,
1126
+ startFrameId: animation.startFrameId + 1,
1127
+ endFrameId: animation.endFrameId + 1,
1128
+ durationMs: Math.round(animation.durationMs)
1129
+ })),
1130
+ ...sheet ? { sheet } : {},
1131
+ ...ctx.options.includeEdges ? { edges: buildEdgeMetadata(ctx.graph, analysisResolution) } : {}
1132
+ };
1133
+ }
1134
+ var init_build_sieve_metadata = __esmMin((() => {
1135
+ init_pipeline_defaults();
1136
+ init_build_edge_metadata();
1137
+ init_build_frame_metadata();
1138
+ init_build_tool_metadata();
1139
+ }));
1140
+
1141
+ //#endregion
1142
+ //#region src/core/utils/metadata/build-video-metadata.ts
1143
+ /**
1144
+ * Read output dimensions and build consistent video and animation metadata.
1145
+ * JPEG finalization does not resize, so the source dimensions match the output.
1146
+ * @param ctx Pipeline state with source duration, effective FPS and analysis-space animations.
1147
+ * @param selected Selected frames in output order; the first candidate is the fallback.
1148
+ * @param analysisResolution Analysis dimensions; absent or zero dimensions imply no scaling.
1149
+ * @returns Video metadata and new output-space animations, retaining zero-based frame IDs.
1150
+ * @throws If sharp cannot read the selected or fallback image. Empty input performs no image I/O.
1151
+ */
1152
+ async function buildVideoMetadata(ctx, selected, analysisResolution) {
1153
+ const firstFrame = selected[0] ?? ctx.frames[0];
1154
+ const dimensions = firstFrame ? await (0, sharp.default)(firstFrame.extractPath).metadata() : void 0;
1155
+ const width = dimensions?.width ?? 0;
1156
+ const height = dimensions?.height ?? 0;
1157
+ const sx = analysisResolution?.width ? width / analysisResolution.width : 1;
1158
+ const sy = analysisResolution?.height ? height / analysisResolution.height : 1;
1159
+ const lastTimestamp = ctx.frames[ctx.frames.length - 1]?.timestamp ?? 0;
1160
+ const duration = ctx.options.mode === "frames" ? lastTimestamp : ctx.sourceDurationSec ?? lastTimestamp;
1161
+ return {
1162
+ video: {
1163
+ originalDurationMs: Math.round(duration * 1e3),
1164
+ fps: ctx.options.mode === "frames" ? 1 : ctx.effectiveFps ?? ctx.options.fps,
1165
+ resolution: {
1166
+ width,
1167
+ height
1168
+ },
1169
+ candidatesCount: ctx.frames.length,
1170
+ selectedCount: selected.length,
1171
+ source: {
1172
+ fileName: ctx.options.mode === "file" && ctx.options.inputPath ? (0, node_path.basename)(ctx.options.inputPath) : null,
1173
+ mode: ctx.options.mode
1174
+ }
1175
+ },
1176
+ animations: (ctx.animations ?? []).map((animation) => ({
1177
+ ...animation,
1178
+ boundingBox: scaleBoundingBox(animation.boundingBox, sx, sy, width, height)
1179
+ }))
1180
+ };
1181
+ }
1182
+ var init_build_video_metadata = __esmMin((() => {
1183
+ init_scale_bounding_box();
1184
+ }));
1185
+
1186
+ //#endregion
1187
+ //#region src/core/utils/sheet/build-tile-label-svg.ts
1188
+ /**
1189
+ * Build a tile-sized SVG overlay with a translucent badge and an explicit text baseline.
1190
+ * @param text - A formatTileLabel result containing only digits, #, spaces, colons and periods.
1191
+ * @param tileWidth - Positive tile canvas width in pixels.
1192
+ * @param tileHeight - Positive tile canvas height in pixels.
1193
+ * @returns Encoded SVG bytes for a top-left sharp composite overlay.
1194
+ */
1195
+ function buildTileLabelSvg(text, tileWidth, tileHeight) {
1196
+ const fontSize = Math.max(8, Math.round(tileHeight * SHEET_LABEL_HEIGHT_RATIO));
1197
+ const padX = Math.round(fontSize * .4);
1198
+ const padY = Math.round(fontSize * .2);
1199
+ const badgeWidth = Math.min(tileWidth, Math.ceil(text.length * fontSize * .6) + 2 * padX);
1200
+ 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>`);
1201
+ }
1202
+ var init_build_tile_label_svg = __esmMin((() => {
1203
+ init_pipeline_defaults();
1204
+ }));
1205
+
1206
+ //#endregion
1207
+ //#region src/core/utils/sheet/format-tile-label.ts
1208
+ /**
1209
+ * Format a contact sheet label without rounding into the next tenth of a second.
1210
+ * @param frameId - One-based candidate frame ID.
1211
+ * @param timestampMs - Nonnegative candidate time in milliseconds.
1212
+ * @returns A label in the form "#<id> mm:ss.s", allowing minutes beyond two digits.
1213
+ */
1214
+ function formatTileLabel(frameId, timestampMs) {
1215
+ const tenths = Math.floor(timestampMs / 100);
1216
+ return `#${frameId} ${String(Math.floor(tenths / 600)).padStart(2, "0")}:${String(Math.floor(tenths / 10) % 60).padStart(2, "0")}.${tenths % 10}`;
1217
+ }
1218
+ var init_format_tile_label = __esmMin((() => {}));
1219
+
1220
+ //#endregion
1221
+ //#region src/core/utils/sheet/sample-tile-frames.ts
1222
+ /**
1223
+ * Sample a sequence uniformly while always retaining both endpoints.
1224
+ * @param items - Items in temporal order.
1225
+ * @param maxTiles - Validated integer limit of at least two.
1226
+ * @returns The original sequence when within the limit, otherwise evenly sampled items.
1227
+ */
1228
+ function sampleTileFrames(items, maxTiles) {
1229
+ if (items.length <= maxTiles) return {
1230
+ items,
1231
+ sampled: false
1232
+ };
1233
+ return {
1234
+ items: Array.from({ length: maxTiles }, (_, i) => items[Math.round(i * (items.length - 1) / (maxTiles - 1))]),
1235
+ sampled: true
1236
+ };
1237
+ }
1238
+ var init_sample_tile_frames = __esmMin((() => {}));
1239
+
1240
+ //#endregion
1241
+ //#region src/core/utils/sheet/render-contact-sheet.ts
1242
+ /**
1243
+ * Read selected frame images and render a row-major contact sheet.
1244
+ * @param input - Nonempty ordered selections, positive dimensions and validated sheet settings.
1245
+ * @returns JPEG bytes and the effective tile layout with one-based frame IDs.
1246
+ * @throws Propagates sharp image reading, compositing or encoding errors.
1247
+ */
1248
+ async function renderContactSheet(input) {
1249
+ const { selected, resolution, options, quality } = input;
1250
+ const { items, sampled } = sampleTileFrames(selected, options.maxTiles);
1251
+ const columns = Math.min(options.columns, items.length);
1252
+ const rows = Math.ceil(items.length / columns);
1253
+ const tileWidth = options.tileWidth;
1254
+ const tileHeight = Math.max(1, Math.round(tileWidth * resolution.height / resolution.width));
1255
+ const gap = 4;
1256
+ const tiles = await Promise.all((0, _winglet_common_utils.map)(items, async (frame, index) => {
1257
+ const tile = (0, sharp.default)(frame.extractPath).resize(tileWidth, tileHeight, { fit: "fill" });
1258
+ if (options.label) {
1259
+ const text = formatTileLabel(frame.id + 1, Math.round(frame.timestamp * 1e3));
1260
+ tile.composite([{
1261
+ input: buildTileLabelSvg(text, tileWidth, tileHeight),
1262
+ top: 0,
1263
+ left: 0
1264
+ }]);
1265
+ }
1266
+ return {
1267
+ input: await tile.png().toBuffer(),
1268
+ top: gap + Math.floor(index / columns) * (tileHeight + gap),
1269
+ left: gap + index % columns * (tileWidth + gap)
1270
+ };
1271
+ }));
1272
+ return {
1273
+ buffer: await (0, sharp.default)({ create: {
1274
+ width: columns * tileWidth + (columns + 1) * gap,
1275
+ height: rows * tileHeight + (rows + 1) * gap,
1276
+ channels: 3,
1277
+ background: "white"
1278
+ } }).composite(tiles).jpeg({
1279
+ quality,
1280
+ mozjpeg: true
1281
+ }).toBuffer(),
1282
+ metadata: {
1283
+ fileName: SHEET_FILE_NAME,
1284
+ columns,
1285
+ tileWidth,
1286
+ tileHeight,
1287
+ frameIds: (0, _winglet_common_utils.map)(items, (frame) => frame.id + 1),
1288
+ sampled
1289
+ }
1290
+ };
1291
+ }
1292
+ var init_render_contact_sheet = __esmMin((() => {
1293
+ init_pipeline_defaults();
1294
+ init_build_tile_label_svg();
1295
+ init_format_tile_label();
1296
+ init_sample_tile_frames();
1297
+ }));
1298
+
1299
+ //#endregion
1300
+ //#region src/core/utils/output/finalize-selection.ts
1301
+ /**
1302
+ * Read output dimensions once, render optional sheets and finalize the shared v2 document.
1303
+ * @param ctx - Pipeline state owned by the orchestrator; this function does not mutate it.
1304
+ * @param selected - Selected frames in temporal order.
1305
+ * @returns Mode-specific output, the document and zero-based API animations.
1306
+ * @throws Propagates image, rendering and output I/O errors to the orchestrator.
1307
+ */
1308
+ async function finalizeSelection(ctx, selected) {
1309
+ const { video, animations } = await buildVideoMetadata(ctx, selected, ctx.analysisResolution);
1310
+ const sheet = ctx.options.sheet && selected.length > 0 && video.resolution.width > 0 && video.resolution.height > 0 ? await renderContactSheet({
1311
+ selected,
1312
+ resolution: video.resolution,
1313
+ options: ctx.options.sheet,
1314
+ quality: ctx.options.quality
1315
+ }) : void 0;
1316
+ const document = buildSieveMetadata({
1317
+ ctx,
1318
+ selected,
1319
+ video,
1320
+ animations,
1321
+ version: PACKAGE_VERSION,
1322
+ ...sheet ? { sheet: sheet.metadata } : {}
1323
+ });
1324
+ if (ctx.options.mode === "file") return {
1325
+ outputFiles: await finalizeOutput(ctx, selected, document, sheet?.buffer),
1326
+ document,
1327
+ animations
1328
+ };
1329
+ return {
1330
+ outputFiles: [],
1331
+ outputBuffers: await readFramesAsBuffers(selected, ctx.options.quality),
1332
+ document,
1333
+ animations,
1334
+ ...sheet ? { sheetBuffer: sheet.buffer } : {}
1335
+ };
1336
+ }
1337
+ var init_finalize_selection = __esmMin((() => {
1338
+ init_package_version();
1339
+ init_workspace();
1340
+ init_build_sieve_metadata();
1341
+ init_build_video_metadata();
1342
+ init_render_contact_sheet();
1343
+ }));
1344
+
757
1345
  //#endregion
758
1346
  //#region src/core/extractor/extractor.ts
759
1347
  /**
@@ -838,7 +1426,8 @@ async function getVideoMetadata(inputPath) {
838
1426
  * @returns Zero-based candidates without a segment seek offset.
839
1427
  */
840
1428
  async function buildFrameList(framesDir, effectiveFps) {
841
- const jpgFiles = (0, _winglet_common_utils.filter)(await (0, node_fs_promises.readdir)(framesDir), (f) => f.endsWith(".jpg")).sort();
1429
+ const files = await (0, node_fs_promises.readdir)(framesDir);
1430
+ const jpgFiles = (0, _winglet_common_utils.filter)(files, (f) => f.endsWith(".jpg")).sort();
842
1431
  if (jpgFiles.length === 0) return [];
843
1432
  return (0, _winglet_common_utils.map)(jpgFiles, (file, index) => ({
844
1433
  id: index,
@@ -985,127 +1574,21 @@ function validateOptions(options) {
985
1574
  if (value === void 0) continue;
986
1575
  if (!Number.isFinite(value) || integer && !Number.isInteger(value) || (exclusiveMin ? value <= min : value < min) || value > max) throw new Error(`${name} must be ${requirement}, received: ${value}`);
987
1576
  }
988
- }
989
- var init_validate_options = __esmMin((() => {}));
990
-
991
- //#endregion
992
- //#region src/core/workspace/workspace.ts
993
- async function createWorkspace(sessionId) {
994
- const workspacePath = getTempWorkspaceDir(sessionId);
995
- await ensureDir((0, node_path.join)(workspacePath, "frames"));
996
- await ensureDir((0, node_path.join)(workspacePath, "output"));
997
- return workspacePath;
998
- }
999
- async function finalizeOutput(ctx, selectedFrames) {
1000
- const stagingDir = (0, node_path.join)(ctx.workspacePath, "output");
1001
- const outputPath = ctx.options.outputPath;
1002
- const quality = ctx.options.quality;
1003
- const outputFiles = [];
1004
- const framesMetadata = [];
1005
- const totalFramesCount = ctx.frames.length;
1006
- const padding = Math.max(4, String(totalFramesCount).length);
1007
- for (let i = 0; i < selectedFrames.length; i++) {
1008
- const frame = selectedFrames[i];
1009
- const fileName = `frame_${String(frame.id + 1).padStart(padding, "0")}.jpg`;
1010
- const destPath = (0, node_path.join)(stagingDir, fileName);
1011
- await (0, sharp.default)(frame.extractPath).jpeg({
1012
- quality,
1013
- mozjpeg: true
1014
- }).toFile(destPath);
1015
- outputFiles.push((0, node_path.join)(outputPath, fileName));
1016
- framesMetadata.push({
1017
- step: i + 1,
1018
- fileName,
1019
- frameId: frame.id + 1,
1020
- timestampMs: Math.round(frame.timestamp * 1e3)
1021
- });
1022
- }
1023
- const { video, animations } = await buildVideoMetadata(ctx, selectedFrames, ctx.analysisResolution);
1024
- const metadata = {
1025
- video,
1026
- frames: framesMetadata,
1027
- animations: (0, _winglet_common_utils.map)(animations, (anim) => ({
1028
- ...anim,
1029
- startFrameId: anim.startFrameId + 1,
1030
- endFrameId: anim.endFrameId + 1,
1031
- durationMs: Math.round(anim.durationMs)
1032
- }))
1033
- };
1034
- await (0, node_fs_promises.writeFile)((0, node_path.join)(stagingDir, ".metadata.json"), JSON.stringify(metadata, null, 2));
1035
- outputFiles.push((0, node_path.join)(outputPath, ".metadata.json"));
1036
- await ensureDir((0, node_path.join)(outputPath, ".."));
1037
- await (0, node_fs_promises.rm)(outputPath, {
1038
- recursive: true,
1039
- force: true
1040
- });
1041
- await (0, node_fs_promises.rename)(stagingDir, outputPath);
1042
- return outputFiles;
1043
- }
1044
- async function createSegmentWorkspace(parentWorkspacePath, segmentIndex) {
1045
- const segmentPath = (0, node_path.join)(parentWorkspacePath, "segments", String(segmentIndex));
1046
- await ensureDir((0, node_path.join)(segmentPath, "frames"));
1047
- return segmentPath;
1048
- }
1049
- async function cleanupWorkspace(workspacePath) {
1050
- if (!workspacePath) return;
1051
- try {
1052
- await (0, node_fs_promises.rm)(workspacePath, {
1053
- recursive: true,
1054
- force: true
1055
- });
1056
- } catch {}
1057
- }
1058
- /**
1059
- * Write a video buffer to a temp file in the workspace and return the path.
1060
- * Used by 'buffer' input mode.
1061
- */
1062
- async function writeInputBuffer(buffer, workspacePath) {
1063
- const inputDir = (0, node_path.join)(workspacePath, "input");
1064
- await ensureDir(inputDir);
1065
- const tempPath = (0, node_path.join)(inputDir, "input.mp4");
1066
- await (0, node_fs_promises.writeFile)(tempPath, buffer);
1067
- return tempPath;
1068
- }
1069
- /**
1070
- * Write an array of frame Buffers as JPG files and return FrameNode[].
1071
- * Used by 'frames' input mode.
1072
- */
1073
- async function writeInputFrames(frames, workspacePath) {
1074
- const framesDir = (0, node_path.join)(workspacePath, "frames");
1075
- await ensureDir(framesDir);
1076
- const frameNodes = [];
1077
- for (let i = 0; i < frames.length; i++) {
1078
- const extractPath = (0, node_path.join)(framesDir, `frame_${String(i).padStart(6, "0")}${FRAME_OUTPUT_EXTENSION}`);
1079
- await (0, node_fs_promises.writeFile)(extractPath, frames[i]);
1080
- frameNodes.push({
1081
- id: i,
1082
- timestamp: i,
1083
- extractPath
1084
- });
1577
+ const sheet = options.sheet;
1578
+ if (sheet === void 0 || typeof sheet === "boolean") return;
1579
+ 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)}`);
1580
+ for (const [name, min] of [
1581
+ ["columns", 1],
1582
+ ["tileWidth", 16],
1583
+ ["maxTiles", 2]
1584
+ ]) {
1585
+ const value = sheet[name];
1586
+ if (value === void 0) continue;
1587
+ if (!Number.isInteger(value) || value < min) throw new Error(`sheet.${name} must be an integer >= ${min}, received: ${value}`);
1085
1588
  }
1086
- return frameNodes;
1589
+ if (sheet.label !== void 0 && typeof sheet.label !== "boolean") throw new Error(`sheet.label must be a boolean, received: ${sheet.label}`);
1087
1590
  }
1088
- /**
1089
- * Read selected FrameNode files as Buffers with JPEG compression.
1090
- * Used to return output buffers in 'buffer' and 'frames' modes.
1091
- */
1092
- async function readFramesAsBuffers(frameNodes, quality) {
1093
- return Promise.all((0, _winglet_common_utils.map)(frameNodes, (f) => (0, sharp.default)(f.extractPath).jpeg({
1094
- quality,
1095
- mozjpeg: true
1096
- }).toBuffer()));
1097
- }
1098
- var init_workspace$1 = __esmMin((() => {
1099
- init_workspace_layout();
1100
- init_paths();
1101
- init_build_video_metadata();
1102
- }));
1103
-
1104
- //#endregion
1105
- //#region src/core/workspace/index.ts
1106
- var init_workspace = __esmMin((() => {
1107
- init_workspace$1();
1108
- }));
1591
+ var init_validate_options = __esmMin((() => {}));
1109
1592
 
1110
1593
  //#endregion
1111
1594
  //#region src/core/input-resolver/input-resolver.ts
@@ -1121,12 +1604,14 @@ function resolveOptions(options) {
1121
1604
  const inputPath = mode === "file" ? resolveAbsolute(options.inputPath) : void 0;
1122
1605
  const outputPath = options.outputPath ?? (inputPath ? deriveOutputPath(inputPath) : (0, node_path.join)(process.cwd(), "scene-sieve-output"));
1123
1606
  const threshold = options.threshold ?? .5;
1607
+ const pruneMode = "threshold-with-cap";
1608
+ const sheet = typeof options.sheet === "object" ? options.sheet : {};
1124
1609
  return {
1125
1610
  mode,
1126
1611
  inputPath,
1127
1612
  count: options.count ?? 20,
1128
1613
  threshold,
1129
- pruneMode: "threshold-with-cap",
1614
+ pruneMode,
1130
1615
  outputPath,
1131
1616
  fps: options.fps ?? 5,
1132
1617
  maxFrames: options.maxFrames ?? 300,
@@ -1136,7 +1621,14 @@ function resolveOptions(options) {
1136
1621
  animationThreshold: options.animationThreshold ?? 5,
1137
1622
  debug: options.debug ?? false,
1138
1623
  maxSegmentDuration: options.maxSegmentDuration ?? 300,
1139
- concurrency: options.concurrency ?? 2
1624
+ concurrency: options.concurrency ?? 2,
1625
+ sheet: options.sheet ? {
1626
+ columns: sheet.columns ?? 4,
1627
+ tileWidth: sheet.tileWidth ?? 320,
1628
+ maxTiles: sheet.maxTiles ?? 40,
1629
+ label: sheet.label ?? true
1630
+ } : null,
1631
+ includeEdges: options.includeEdges ?? false
1140
1632
  };
1141
1633
  }
1142
1634
  /**
@@ -1250,7 +1742,7 @@ function normalizeScores(items) {
1250
1742
  if (s <= 0) return 0;
1251
1743
  return lowerBound(sorted, s) / sorted.length;
1252
1744
  });
1253
- return (0, _winglet_common_utils.map)(logisticZ, (z, i) => z * (1 - NORMALIZATION_ALPHA) + cdf[i] * NORMALIZATION_ALPHA);
1745
+ return (0, _winglet_common_utils.map)(logisticZ, (z, i) => z * .6 + cdf[i] * NORMALIZATION_ALPHA);
1254
1746
  }
1255
1747
  var NORMALIZATION_ALPHA, NORMALIZATION_MAD_COEFFICIENT, NORMALIZATION_MIN_SAMPLE_SIZE;
1256
1748
  var init_normalize_scores = __esmMin((() => {
@@ -1673,16 +2165,16 @@ function remapEdges(segmentResults, globalIdMap) {
1673
2165
  const existingIdx = edgeMap.get(edgeKey);
1674
2166
  if (existingIdx !== void 0) {
1675
2167
  if (edges[existingIdx].score < edge.score) edges[existingIdx] = {
2168
+ ...edge,
1676
2169
  sourceId: newSourceId,
1677
- targetId: newTargetId,
1678
- score: edge.score
2170
+ targetId: newTargetId
1679
2171
  };
1680
2172
  } else {
1681
2173
  edgeMap.set(edgeKey, edges.length);
1682
2174
  edges.push({
2175
+ ...edge,
1683
2176
  sourceId: newSourceId,
1684
- targetId: newTargetId,
1685
- score: edge.score
2177
+ targetId: newTargetId
1686
2178
  });
1687
2179
  }
1688
2180
  }
@@ -1757,7 +2249,8 @@ function buildSegmentContext(segment, frames, segmentWorkspacePath, resolvedOpti
1757
2249
  * Each segment uses an isolated workspace directory.
1758
2250
  */
1759
2251
  async function processSegment(inputPath, segment, workspacePath, resolvedOptions, onProgress) {
1760
- const frames = await extractFramesForRange(inputPath, (0, node_path.join)(workspacePath, "frames"), segment.effectiveFps, resolvedOptions.scale, segment.extractStartTime, segment.extractDuration, segment.allocatedFrames);
2252
+ const framesDir = (0, node_path.join)(workspacePath, "frames");
2253
+ const frames = await extractFramesForRange(inputPath, framesDir, segment.effectiveFps, resolvedOptions.scale, segment.extractStartTime, segment.extractDuration, segment.allocatedFrames);
1761
2254
  if (frames.length < 2) return {
1762
2255
  segment,
1763
2256
  frames,
@@ -1768,7 +2261,8 @@ async function processSegment(inputPath, segment, workspacePath, resolvedOptions
1768
2261
  height: 0
1769
2262
  }
1770
2263
  };
1771
- const { edges, animations, analysisResolution } = await analyzeFrames(buildSegmentContext(segment, frames, workspacePath, resolvedOptions, onProgress));
2264
+ const ctx = buildSegmentContext(segment, frames, workspacePath, resolvedOptions, onProgress);
2265
+ const { edges, animations, analysisResolution } = await analyzeFrames(ctx);
1772
2266
  return {
1773
2267
  segment,
1774
2268
  frames,
@@ -1833,20 +2327,20 @@ async function runSegmentedPipeline(options, resolvedOptions) {
1833
2327
  status: "FINALIZING",
1834
2328
  emitProgress: (percent) => options.onProgress?.("FINALIZING", percent)
1835
2329
  };
1836
- let outputFiles = [];
1837
- let outputBuffers;
1838
- if (resolvedOptions.mode === "buffer" || resolvedOptions.mode === "frames") outputBuffers = await readFramesAsBuffers(prunedFrames, resolvedOptions.quality);
1839
- else outputFiles = await finalizeOutput(ctx, prunedFrames);
2330
+ const finalized = await finalizeSelection(ctx, prunedFrames);
1840
2331
  options.onProgress?.("FINALIZING", 100);
1841
2332
  logger.success(`Segmented pipeline: ${prunedFrames.length} scenes from ${frames.length} frames (${segments.length} segments)`);
1842
- const outputMetadata = await buildVideoMetadata(ctx, prunedFrames, analysisResolution);
1843
2333
  return {
1844
2334
  success: true,
1845
2335
  originalFramesCount: frames.length,
1846
2336
  prunedFramesCount: prunedFrames.length,
1847
- outputFiles,
1848
- outputBuffers,
1849
- ...outputMetadata,
2337
+ outputFiles: finalized.outputFiles,
2338
+ outputBuffers: finalized.outputBuffers,
2339
+ video: finalized.document.video,
2340
+ animations: finalized.animations,
2341
+ frames: finalized.document.frames,
2342
+ ...finalized.document.sheet ? { sheet: finalized.document.sheet } : {},
2343
+ ...finalized.sheetBuffer ? { sheetBuffer: finalized.sheetBuffer } : {},
1850
2344
  executionTimeMs: Date.now() - pipelineStart
1851
2345
  };
1852
2346
  } catch (error) {
@@ -1862,7 +2356,7 @@ var init_segmenter$1 = __esmMin((() => {
1862
2356
  init_concurrency();
1863
2357
  init_logger();
1864
2358
  init_analyzer();
1865
- init_build_video_metadata();
2359
+ init_finalize_selection();
1866
2360
  init_extractor();
1867
2361
  init_input_resolver();
1868
2362
  init_pruner();
@@ -1877,6 +2371,12 @@ var init_segmenter = __esmMin((() => {
1877
2371
 
1878
2372
  //#endregion
1879
2373
  //#region src/core/orchestrator/orchestrator.ts
2374
+ /**
2375
+ * Run the five pipeline stages, delegating segmented inputs to the segmenter.
2376
+ * @param options - Mode-specific input and optional pipeline settings.
2377
+ * @returns Selected outputs with v2 frame metadata and zero-based API animations.
2378
+ * @throws Propagates stage failures after cleaning the workspace unless debug is enabled.
2379
+ */
1880
2380
  async function runPipeline(options) {
1881
2381
  setDebugMode(options.debug ?? false);
1882
2382
  const resolvedOptions = resolveOptions(options);
@@ -1924,25 +2424,21 @@ async function runPipeline(options) {
1924
2424
  const prunedFrames = (0, _winglet_common_utils.filter)(ctx.frames, (f) => survivingIds.has(f.id));
1925
2425
  ctx.emitProgress(100);
1926
2426
  ctx.status = "FINALIZING";
1927
- let outputFiles = [];
1928
- let outputBuffers;
1929
- if (resolvedOptions.mode === "buffer" || resolvedOptions.mode === "frames") {
1930
- outputBuffers = await readFramesAsBuffers(prunedFrames, resolvedOptions.quality);
1931
- ctx.emitProgress(100);
1932
- } else {
1933
- outputFiles = await finalizeOutput(ctx, prunedFrames);
1934
- ctx.emitProgress(100);
1935
- }
2427
+ const finalized = await finalizeSelection(ctx, prunedFrames);
2428
+ ctx.emitProgress(100);
1936
2429
  ctx.status = "SUCCESS";
1937
2430
  logger.success(`Extracted ${prunedFrames.length} scenes from ${ctx.frames.length} frames`);
1938
- const metadata = await buildVideoMetadata(ctx, prunedFrames, analysisResolution);
1939
2431
  return {
1940
2432
  success: true,
1941
2433
  originalFramesCount: ctx.frames.length,
1942
2434
  prunedFramesCount: prunedFrames.length,
1943
- outputFiles,
1944
- outputBuffers,
1945
- ...metadata,
2435
+ outputFiles: finalized.outputFiles,
2436
+ outputBuffers: finalized.outputBuffers,
2437
+ video: finalized.document.video,
2438
+ animations: finalized.animations,
2439
+ frames: finalized.document.frames,
2440
+ ...finalized.document.sheet ? { sheet: finalized.document.sheet } : {},
2441
+ ...finalized.sheetBuffer ? { sheetBuffer: finalized.sheetBuffer } : {},
1946
2442
  executionTimeMs: Date.now() - startTime
1947
2443
  };
1948
2444
  } catch (error) {
@@ -1958,7 +2454,7 @@ async function runPipeline(options) {
1958
2454
  var init_orchestrator = __esmMin((() => {
1959
2455
  init_logger();
1960
2456
  init_analyzer();
1961
- init_build_video_metadata();
2457
+ init_finalize_selection();
1962
2458
  init_extractor();
1963
2459
  init_input_resolver();
1964
2460
  init_pruner();