@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/cli.mjs CHANGED
@@ -12,10 +12,10 @@ import pc from "picocolors";
12
12
  import sharp from "sharp";
13
13
  import { mkdir, readdir, rename, rm, stat, writeFile } from "node:fs/promises";
14
14
  import { basename, dirname, extname, join, resolve } from "node:path";
15
+ import { homedir, tmpdir } from "node:os";
15
16
  import { path } from "@ffprobe-installer/ffprobe";
16
17
  import { execa } from "execa";
17
18
  import ffmpegPath from "ffmpeg-static";
18
- import { homedir, tmpdir } from "node:os";
19
19
  import { fileURLToPath } from "node:url";
20
20
  import { Worker } from "node:worker_threads";
21
21
 
@@ -147,10 +147,13 @@ const PhaseStep = ({ phase }) => {
147
147
 
148
148
  //#endregion
149
149
  //#region src/constants/pipeline-defaults.ts
150
- var DEFAULT_THRESHOLD, IOU_THRESHOLD;
150
+ var DEFAULT_THRESHOLD, IOU_THRESHOLD, SHEET_LABEL_HEIGHT_RATIO, SHEET_FILE_NAME, METADATA_FILE_NAME;
151
151
  var init_pipeline_defaults = __esmMin((() => {
152
152
  DEFAULT_THRESHOLD = .5;
153
153
  IOU_THRESHOLD = .9;
154
+ SHEET_LABEL_HEIGHT_RATIO = .07;
155
+ SHEET_FILE_NAME = "sheet.jpg";
156
+ METADATA_FILE_NAME = ".metadata.json";
154
157
  }));
155
158
 
156
159
  //#endregion
@@ -184,8 +187,10 @@ var init_logger = __esmMin((() => {
184
187
  console.error(`${pc.red("error")} ${message}`);
185
188
  },
186
189
  debug(message) {
187
- if (debugMode) if (jsonMode) process.stderr.write(`${pc.gray(`[${timestamp()}] debug`)} ${message}\n`);
188
- else console.log(`${pc.gray(`[${timestamp()}] debug`)} ${message}`);
190
+ if (debugMode) {
191
+ if (jsonMode) process.stderr.write(`${pc.gray(`[${timestamp()}] debug`)} ${message}\n`);
192
+ else console.log(`${pc.gray(`[${timestamp()}] debug`)} ${message}`);
193
+ }
189
194
  }
190
195
  };
191
196
  }));
@@ -538,7 +543,8 @@ async function analyzeBatch(cvLib, akaze, frames, carry, scale, tracker, pairOff
538
543
  try {
539
544
  prev ??= computeFrameFeatures(cvLib, akaze, preprocessed[i]);
540
545
  next = computeFrameFeatures(cvLib, akaze, preprocessed[i + 1]);
541
- let dbscanResult = dbscan(computeNewPoints(cvLib, prev, next), imageWidth, imageHeight);
546
+ const sNew = computeNewPoints(cvLib, prev, next);
547
+ let dbscanResult = dbscan(sNew, imageWidth, imageHeight);
542
548
  let clusters = dbscanResult.boundingBoxes;
543
549
  if (clusters.length === 0) {
544
550
  const pixelDiffPoints = computePixelDiff(cvLib, preprocessed[i], preprocessed[i + 1]);
@@ -551,13 +557,18 @@ async function analyzeBatch(cvLib, akaze, frames, carry, scale, tracker, pairOff
551
557
  const clusterPointCounts = new Array(clusters.length).fill(0);
552
558
  for (const label of dbscanResult.labels) if (label >= 0) clusterPointCounts[label]++;
553
559
  const animationIndices = tracker.update(clusters, pairIndex);
560
+ const change = {
561
+ regions: filter(clusters, (_, ci) => !animationIndices.has(ci)),
562
+ animatedRegions: filter(clusters, (_, ci) => animationIndices.has(ci))
563
+ };
554
564
  const animationWeights = map(clusters, (_, ci) => animationIndices.has(ci) ? tracker.getAnimationWeight(ci, clusters) : 0);
555
565
  const score = computeInformationGain(clusters, clusterPointCounts, imageArea, animationIndices, animationWeights);
556
566
  logger.debug(`Edge ${frames[i].id}->${frames[i + 1].id} G(t)=${score.toFixed(6)}`);
557
567
  edges.push({
558
568
  sourceId: frames[i].id,
559
569
  targetId: frames[i + 1].id,
560
- score
570
+ score,
571
+ change
561
572
  });
562
573
  } catch (err) {
563
574
  logger.warn(`Frame pair analysis failed: ${String(err)}`);
@@ -755,51 +766,12 @@ var init_analyzer = __esmMin((() => {
755
766
  }));
756
767
 
757
768
  //#endregion
758
- //#region src/core/utils/metadata/build-video-metadata.ts
759
- /**
760
- * Read output dimensions and build consistent video and animation metadata.
761
- * JPEG finalization does not resize, so the source dimensions match the output.
762
- * @param ctx Pipeline state with source duration, effective FPS and analysis-space animations.
763
- * @param selected Selected frames in output order; the first candidate is the fallback.
764
- * @param analysisResolution Analysis dimensions; absent or zero dimensions imply no scaling.
765
- * @returns Video metadata and new output-space animations, retaining zero-based frame IDs.
766
- * @throws If sharp cannot read the selected or fallback image. Empty input performs no image I/O.
767
- */
768
- async function buildVideoMetadata(ctx, selected, analysisResolution) {
769
- const firstFrame = selected[0] ?? ctx.frames[0];
770
- const dimensions = firstFrame ? await sharp(firstFrame.extractPath).metadata() : void 0;
771
- const width = dimensions?.width ?? 0;
772
- const height = dimensions?.height ?? 0;
773
- const sx = analysisResolution?.width ? width / analysisResolution.width : 1;
774
- const sy = analysisResolution?.height ? height / analysisResolution.height : 1;
775
- const lastTimestamp = ctx.frames[ctx.frames.length - 1]?.timestamp ?? 0;
776
- const duration = ctx.options.mode === "frames" ? lastTimestamp : ctx.sourceDurationSec ?? lastTimestamp;
777
- return {
778
- video: {
779
- originalDurationMs: Math.round(duration * 1e3),
780
- fps: ctx.options.mode === "frames" ? 1 : ctx.effectiveFps ?? ctx.options.fps,
781
- resolution: {
782
- width,
783
- height
784
- }
785
- },
786
- animations: (ctx.animations ?? []).map((animation) => {
787
- const box = animation.boundingBox;
788
- const x = Math.max(0, Math.min(width, Math.round(box.x * sx)));
789
- const y = Math.max(0, Math.min(height, Math.round(box.y * sy)));
790
- return {
791
- ...animation,
792
- boundingBox: {
793
- x,
794
- y,
795
- width: Math.max(0, Math.min(width - x, Math.round(box.width * sx))),
796
- height: Math.max(0, Math.min(height - y, Math.round(box.height * sy)))
797
- }
798
- };
799
- })
800
- };
801
- }
802
- var init_build_video_metadata = __esmMin((() => {}));
769
+ //#region src/constants/package-version.ts
770
+ var manifestPath, PACKAGE_VERSION;
771
+ var init_package_version = __esmMin((() => {
772
+ manifestPath = existsSync(new URL("../package.json", import.meta.url)) ? "../package.json" : "../../package.json";
773
+ PACKAGE_VERSION = createRequire(import.meta.url)(manifestPath).version;
774
+ }));
803
775
 
804
776
  //#endregion
805
777
  //#region src/core/constants/workspace-layout.ts
@@ -850,10 +822,646 @@ function resolveAbsolute(p) {
850
822
  * e.g., /path/to/video.mp4 -> /path/to/video_scenes
851
823
  */
852
824
  function deriveOutputPath(inputPath) {
853
- return resolve(resolve(inputPath, ".."), `${basename(inputPath, extname(inputPath))}_scenes`);
825
+ const dir = resolve(inputPath, "..");
826
+ const name = basename(inputPath, extname(inputPath));
827
+ return resolve(dir, `${name}_scenes`);
854
828
  }
855
829
  var init_paths = __esmMin((() => {}));
856
830
 
831
+ //#endregion
832
+ //#region src/core/workspace/workspace.ts
833
+ async function createWorkspace(sessionId) {
834
+ const workspacePath = getTempWorkspaceDir(sessionId);
835
+ await ensureDir(join(workspacePath, "frames"));
836
+ await ensureDir(join(workspacePath, "output"));
837
+ return workspacePath;
838
+ }
839
+ /**
840
+ * Write selected JPEGs and the supplied document before replacing the output directory.
841
+ * @param ctx - Workspace, quality and destination settings.
842
+ * @param selectedFrames - Frames paired by position with document.frames.
843
+ * @param document - Complete metadata, including the output file names.
844
+ * @param sheetBuffer - Optional JPEG contact sheet bytes to persist unchanged.
845
+ * @returns Selected JPEG paths, optional sheet path and finally the metadata path.
846
+ * @throws Rejects mismatched frame counts and propagates image or filesystem errors.
847
+ */
848
+ async function finalizeOutput(ctx, selectedFrames, document, sheetBuffer) {
849
+ if (document.frames.length !== selectedFrames.length) throw new Error("metadata frame count must match selected frame count");
850
+ const stagingDir = join(ctx.workspacePath, "output");
851
+ const outputPath = ctx.options.outputPath;
852
+ const quality = ctx.options.quality;
853
+ const outputFiles = [];
854
+ for (let i = 0; i < selectedFrames.length; i++) {
855
+ const frame = selectedFrames[i];
856
+ const fileName = document.frames[i].fileName;
857
+ const destPath = join(stagingDir, fileName);
858
+ await sharp(frame.extractPath).jpeg({
859
+ quality,
860
+ mozjpeg: true
861
+ }).toFile(destPath);
862
+ outputFiles.push(join(outputPath, fileName));
863
+ }
864
+ if (sheetBuffer) {
865
+ await writeFile(join(stagingDir, SHEET_FILE_NAME), sheetBuffer);
866
+ outputFiles.push(join(outputPath, SHEET_FILE_NAME));
867
+ }
868
+ const metadataPath = join(stagingDir, METADATA_FILE_NAME);
869
+ await writeFile(metadataPath, JSON.stringify(document, null, 2));
870
+ outputFiles.push(join(outputPath, METADATA_FILE_NAME));
871
+ await ensureDir(join(outputPath, ".."));
872
+ await rm(outputPath, {
873
+ recursive: true,
874
+ force: true
875
+ });
876
+ await rename(stagingDir, outputPath);
877
+ return outputFiles;
878
+ }
879
+ async function createSegmentWorkspace(parentWorkspacePath, segmentIndex) {
880
+ const segmentPath = join(parentWorkspacePath, "segments", String(segmentIndex));
881
+ await ensureDir(join(segmentPath, "frames"));
882
+ return segmentPath;
883
+ }
884
+ async function cleanupWorkspace(workspacePath) {
885
+ if (!workspacePath) return;
886
+ try {
887
+ await rm(workspacePath, {
888
+ recursive: true,
889
+ force: true
890
+ });
891
+ } catch {}
892
+ }
893
+ /**
894
+ * Remove stale workspace directories left by previous interrupted runs.
895
+ * Only deletes directories older than 1 hour to avoid removing active workspaces.
896
+ */
897
+ async function cleanupStaleWorkspaces() {
898
+ const entries = await readdir(TEMP_BASE_DIR);
899
+ const now = Date.now();
900
+ for (const entry of entries) {
901
+ if (!entry.startsWith(WORKSPACE_PREFIX)) continue;
902
+ const fullPath = join(TEMP_BASE_DIR, entry);
903
+ try {
904
+ const info = await stat(fullPath);
905
+ if (info.isDirectory() && now - info.mtimeMs > STALE_THRESHOLD_MS) await rm(fullPath, {
906
+ recursive: true,
907
+ force: true
908
+ });
909
+ } catch {}
910
+ }
911
+ }
912
+ /**
913
+ * Write a video buffer to a temp file in the workspace and return the path.
914
+ * Used by 'buffer' input mode.
915
+ */
916
+ async function writeInputBuffer(buffer, workspacePath) {
917
+ const inputDir = join(workspacePath, "input");
918
+ await ensureDir(inputDir);
919
+ const tempPath = join(inputDir, "input.mp4");
920
+ await writeFile(tempPath, buffer);
921
+ return tempPath;
922
+ }
923
+ /**
924
+ * Write an array of frame Buffers as JPG files and return FrameNode[].
925
+ * Used by 'frames' input mode.
926
+ */
927
+ async function writeInputFrames(frames, workspacePath) {
928
+ const framesDir = join(workspacePath, "frames");
929
+ await ensureDir(framesDir);
930
+ const frameNodes = [];
931
+ for (let i = 0; i < frames.length; i++) {
932
+ const filename = `frame_${String(i).padStart(6, "0")}${FRAME_OUTPUT_EXTENSION}`;
933
+ const extractPath = join(framesDir, filename);
934
+ await writeFile(extractPath, frames[i]);
935
+ frameNodes.push({
936
+ id: i,
937
+ timestamp: i,
938
+ extractPath
939
+ });
940
+ }
941
+ return frameNodes;
942
+ }
943
+ /**
944
+ * Read selected FrameNode files as Buffers with JPEG compression.
945
+ * Used to return output buffers in 'buffer' and 'frames' modes.
946
+ */
947
+ async function readFramesAsBuffers(frameNodes, quality) {
948
+ return Promise.all(map(frameNodes, (f) => sharp(f.extractPath).jpeg({
949
+ quality,
950
+ mozjpeg: true
951
+ }).toBuffer()));
952
+ }
953
+ var STALE_THRESHOLD_MS;
954
+ var init_workspace$1 = __esmMin((() => {
955
+ init_workspace_layout();
956
+ init_pipeline_defaults();
957
+ init_paths();
958
+ STALE_THRESHOLD_MS = 36e5;
959
+ }));
960
+
961
+ //#endregion
962
+ //#region src/core/workspace/index.ts
963
+ var init_workspace = __esmMin((() => {
964
+ init_workspace$1();
965
+ }));
966
+
967
+ //#endregion
968
+ //#region src/core/utils/metadata/change/union-area/y-coverage-tree.ts
969
+ var YCoverageTree;
970
+ var init_y_coverage_tree = __esmMin((() => {
971
+ YCoverageTree = class {
972
+ bounds;
973
+ /** Number of whole-node covering intervals, independent of descendants. */
974
+ counts;
975
+ /** Covered geometric length for each node, including partially covered children. */
976
+ lengths;
977
+ /**
978
+ * Allocate linear storage for the elementary intervals between coordinates.
979
+ * @param bounds - At least two sorted unique finite y endpoints.
980
+ */
981
+ constructor(bounds) {
982
+ this.bounds = bounds;
983
+ this.counts = Array(4 * bounds.length).fill(0);
984
+ this.lengths = Array(4 * bounds.length).fill(0);
985
+ }
986
+ /** Total active covered y length, in the input coordinate system. */
987
+ get coveredLength() {
988
+ return this.lengths[1];
989
+ }
990
+ /**
991
+ * Adjust a nonempty half-open interval and refresh its ancestors' lengths.
992
+ * @param start - Inclusive endpoint index; 0 <= start < end.
993
+ * @param end - Exclusive endpoint index; end < bounds.length.
994
+ * @param delta - One on entry, minus one for the matching departure.
995
+ * @param node - Internal tree slot; callers use the root default.
996
+ * @param left - Inclusive endpoint index of this node.
997
+ * @param right - Exclusive endpoint index of this node.
998
+ * @returns Nothing; mutates this tree's coverage in O(log n).
999
+ */
1000
+ update(start, end, delta, node = 1, left = 0, right = this.bounds.length - 1) {
1001
+ if (start <= left && right <= end) this.counts[node] += delta;
1002
+ else {
1003
+ const middle = Math.floor((left + right) / 2);
1004
+ if (start < middle) this.update(start, end, delta, node * 2, left, middle);
1005
+ if (end > middle) this.update(start, end, delta, node * 2 + 1, middle, right);
1006
+ }
1007
+ 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];
1008
+ }
1009
+ };
1010
+ }));
1011
+
1012
+ //#endregion
1013
+ //#region src/core/utils/metadata/change/union-area.ts
1014
+ /**
1015
+ * Measure rectangle union with an x sweep in O(n log n) time and O(n) space.
1016
+ * @param boxes - Finite rectangles; nonpositive dimensions are ignored.
1017
+ * @returns Covered area in the input coordinate system, or zero for empty input.
1018
+ */
1019
+ function unionArea(boxes) {
1020
+ const events = [];
1021
+ const endpoints = [];
1022
+ for (const box of boxes) {
1023
+ if (!(box.width > 0 && box.height > 0)) continue;
1024
+ const end = box.y + box.height;
1025
+ if (end === box.y) continue;
1026
+ events.push({
1027
+ x: box.x,
1028
+ start: box.y,
1029
+ end,
1030
+ delta: 1
1031
+ });
1032
+ events.push({
1033
+ x: box.x + box.width,
1034
+ start: box.y,
1035
+ end,
1036
+ delta: -1
1037
+ });
1038
+ endpoints.push(box.y, end);
1039
+ }
1040
+ if (events.length === 0) return 0;
1041
+ events.sort((a, b) => a.x - b.x);
1042
+ endpoints.sort((a, b) => a - b);
1043
+ const bounds = endpoints.filter((value, index) => index === 0 || value !== endpoints[index - 1]);
1044
+ const indices = new Map(bounds.map((value, index) => [value, index]));
1045
+ const coverage = new YCoverageTree(bounds);
1046
+ let area = 0;
1047
+ let previousX = events[0].x;
1048
+ for (const event of events) {
1049
+ area += (event.x - previousX) * coverage.coveredLength;
1050
+ coverage.update(indices.get(event.start), indices.get(event.end), event.delta);
1051
+ previousX = event.x;
1052
+ }
1053
+ return area;
1054
+ }
1055
+ var init_union_area = __esmMin((() => {
1056
+ init_y_coverage_tree();
1057
+ }));
1058
+
1059
+ //#endregion
1060
+ //#region src/core/utils/metadata/build-edge-metadata.ts
1061
+ /**
1062
+ * Serialize raw candidate edges in graph order.
1063
+ * @param graph - Adjacent-pair edges, optionally carrying tracker partitions.
1064
+ * @param analysisResolution - Analysis dimensions used for both area fractions.
1065
+ * @returns One-based IDs with six-decimal scores and four-decimal clamped ratios.
1066
+ */
1067
+ function buildEdgeMetadata(graph, analysisResolution) {
1068
+ const area = analysisResolution.width * analysisResolution.height;
1069
+ return map(graph, (edge) => ({
1070
+ sourceFrameId: edge.sourceId + 1,
1071
+ targetFrameId: edge.targetId + 1,
1072
+ score: Math.round(edge.score * 1e6) / 1e6,
1073
+ areaRatio: area > 0 ? Math.round(Math.max(0, Math.min(1, unionArea(edge.change?.regions ?? []) / area)) * 1e4) / 1e4 : 0,
1074
+ animatedAreaRatio: area > 0 ? Math.round(Math.max(0, Math.min(1, unionArea(edge.change?.animatedRegions ?? []) / area)) * 1e4) / 1e4 : 0
1075
+ }));
1076
+ }
1077
+ var init_build_edge_metadata = __esmMin((() => {
1078
+ init_union_area();
1079
+ }));
1080
+
1081
+ //#endregion
1082
+ //#region src/core/utils/metadata/scale-bounding-box.ts
1083
+ /**
1084
+ * Convert an analysis box to clamped integer output pixels.
1085
+ * @param box - Analysis-space rectangle; the input remains unchanged.
1086
+ * @param sx - Horizontal output-to-analysis scale.
1087
+ * @param sy - Vertical output-to-analysis scale.
1088
+ * @param width - Nonnegative output image width.
1089
+ * @param height - Nonnegative output image height.
1090
+ * @returns A rectangle contained within the output dimensions.
1091
+ */
1092
+ function scaleBoundingBox(box, sx, sy, width, height) {
1093
+ const x = Math.max(0, Math.min(width, Math.round(box.x * sx)));
1094
+ const y = Math.max(0, Math.min(height, Math.round(box.y * sy)));
1095
+ return {
1096
+ x,
1097
+ y,
1098
+ width: Math.max(0, Math.min(width - x, Math.round(box.width * sx))),
1099
+ height: Math.max(0, Math.min(height - y, Math.round(box.height * sy)))
1100
+ };
1101
+ }
1102
+ var init_scale_bounding_box = __esmMin((() => {}));
1103
+
1104
+ //#endregion
1105
+ //#region src/core/utils/metadata/change/select-regions.ts
1106
+ /**
1107
+ * Select distinct positive-area boxes with deterministic area and coordinate ties.
1108
+ * @param boxes - Already scaled output rectangles; the input is not mutated.
1109
+ * @param limit - Nonnegative maximum number of regions.
1110
+ * @returns Largest boxes ordered by area descending, then y, x and width ascending.
1111
+ */
1112
+ function selectRegions(boxes, limit) {
1113
+ 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);
1114
+ }
1115
+ var init_select_regions = __esmMin((() => {}));
1116
+
1117
+ //#endregion
1118
+ //#region src/core/utils/metadata/change/build-frame-change.ts
1119
+ /**
1120
+ * Aggregate adjacent-pair evidence across one selected-frame span.
1121
+ * @param input - Ordered raw edges, one-based previous ID, skipped count and dimensions.
1122
+ * @returns Rounded raw scores, analysis-space union ratio and output-space regions.
1123
+ */
1124
+ function buildFrameChange(input) {
1125
+ const { spanEdges, fromFrameId, skippedCandidates, analysisResolution, outputResolution, regionLimit } = input;
1126
+ const boxes = spanEdges.flatMap((edge) => edge.change?.regions ?? []);
1127
+ const area = analysisResolution.width * analysisResolution.height;
1128
+ const peakScore = spanEdges.reduce((peak, edge) => Math.max(peak, edge.score), 0);
1129
+ const sumScore = spanEdges.reduce((sum, edge) => sum + edge.score, 0);
1130
+ const areaRatio = area > 0 ? Math.max(0, Math.min(1, unionArea(boxes) / area)) : 0;
1131
+ const regions = area > 0 ? selectRegions(map(boxes, (box) => scaleBoundingBox(box, outputResolution.width / analysisResolution.width, outputResolution.height / analysisResolution.height, outputResolution.width, outputResolution.height)), regionLimit) : [];
1132
+ return {
1133
+ fromFrameId,
1134
+ skippedCandidates,
1135
+ peakScore: Math.round(peakScore * 1e6) / 1e6,
1136
+ sumScore: Math.round(sumScore * 1e6) / 1e6,
1137
+ areaRatio: Math.round(areaRatio * 1e4) / 1e4,
1138
+ regions
1139
+ };
1140
+ }
1141
+ var init_build_frame_change = __esmMin((() => {
1142
+ init_scale_bounding_box();
1143
+ init_select_regions();
1144
+ init_union_area();
1145
+ }));
1146
+
1147
+ //#endregion
1148
+ //#region src/core/utils/metadata/build-frame-metadata.ts
1149
+ /**
1150
+ * Describe selected frames using candidate adjacency rather than synthetic pruning edges.
1151
+ * @param input - Chronological candidates and selections, raw graph and output dimensions.
1152
+ * @returns One-based frame summaries with rounded timestamps and nonnegative holds.
1153
+ */
1154
+ function buildFrameMetadata(input) {
1155
+ const { frames, graph, selected, originalDurationMs, analysisResolution, outputResolution } = input;
1156
+ const edgesByPair = new Map(map(graph, (edge) => [`${edge.sourceId}:${edge.targetId}`, edge]));
1157
+ const candidateIndices = new Map(map(frames, (frame, index) => [frame.id, index]));
1158
+ const padding = Math.max(4, String(frames.length).length);
1159
+ return map(selected, (frame, index) => {
1160
+ const timestampMs = Math.round(frame.timestamp * 1e3);
1161
+ const nextTimestampMs = index + 1 < selected.length ? Math.round(selected[index + 1].timestamp * 1e3) : originalDurationMs;
1162
+ const previous = selected[index - 1];
1163
+ const previousIndex = previous ? candidateIndices.get(previous.id) : 0;
1164
+ const currentIndex = candidateIndices.get(frame.id);
1165
+ const spanEdges = [];
1166
+ if (previous) for (let i = previousIndex; i < currentIndex; i++) {
1167
+ const edge = edgesByPair.get(`${frames[i].id}:${frames[i + 1].id}`);
1168
+ if (edge) spanEdges.push(edge);
1169
+ }
1170
+ return {
1171
+ step: index + 1,
1172
+ fileName: `frame_${String(frame.id + 1).padStart(padding, "0")}.jpg`,
1173
+ frameId: frame.id + 1,
1174
+ timestampMs,
1175
+ holdsMs: Math.max(0, nextTimestampMs - timestampMs),
1176
+ change: previous ? buildFrameChange({
1177
+ spanEdges,
1178
+ fromFrameId: previous.id + 1,
1179
+ skippedCandidates: currentIndex - previousIndex - 1,
1180
+ analysisResolution,
1181
+ outputResolution,
1182
+ regionLimit: 5
1183
+ }) : null
1184
+ };
1185
+ });
1186
+ }
1187
+ var init_build_frame_metadata = __esmMin((() => {
1188
+ init_pipeline_defaults();
1189
+ init_build_frame_change();
1190
+ }));
1191
+
1192
+ //#endregion
1193
+ //#region src/core/utils/metadata/build-tool-metadata.ts
1194
+ /**
1195
+ * Record the tool and the nine selection and encoding settings in contract order.
1196
+ * @param options - Validated pipeline settings; operational options are excluded.
1197
+ * @param version - Runtime package version supplied by the I/O boundary.
1198
+ * @returns Deterministic tool provenance without paths or execution details.
1199
+ */
1200
+ function buildToolMetadata(options, version) {
1201
+ return {
1202
+ name: "@lumy-pack/scene-sieve",
1203
+ version,
1204
+ params: {
1205
+ fps: options.fps,
1206
+ count: options.count,
1207
+ threshold: options.threshold,
1208
+ scale: options.scale,
1209
+ quality: options.quality,
1210
+ maxFrames: options.maxFrames,
1211
+ iouThreshold: options.iouThreshold,
1212
+ animationThreshold: options.animationThreshold,
1213
+ maxSegmentDuration: options.maxSegmentDuration
1214
+ }
1215
+ };
1216
+ }
1217
+ var init_build_tool_metadata = __esmMin((() => {}));
1218
+
1219
+ //#endregion
1220
+ //#region src/core/utils/metadata/build-sieve-metadata.ts
1221
+ /**
1222
+ * Assemble the complete v2 document without I/O or mutation.
1223
+ * @param input - Pipeline state, selections, output-space video and zero-based animations.
1224
+ * @returns Contract-ordered metadata, omitting unrequested optional keys entirely.
1225
+ */
1226
+ function buildSieveMetadata(input) {
1227
+ const { ctx, selected, video, animations, version, sheet } = input;
1228
+ const analysisResolution = ctx.analysisResolution ?? {
1229
+ width: 0,
1230
+ height: 0
1231
+ };
1232
+ return {
1233
+ metadataVersion: 2,
1234
+ tool: buildToolMetadata(ctx.options, version),
1235
+ video,
1236
+ frames: buildFrameMetadata({
1237
+ frames: ctx.frames,
1238
+ graph: ctx.graph,
1239
+ selected,
1240
+ originalDurationMs: video.originalDurationMs,
1241
+ analysisResolution,
1242
+ outputResolution: video.resolution
1243
+ }),
1244
+ animations: map(animations, (animation) => ({
1245
+ ...animation,
1246
+ startFrameId: animation.startFrameId + 1,
1247
+ endFrameId: animation.endFrameId + 1,
1248
+ durationMs: Math.round(animation.durationMs)
1249
+ })),
1250
+ ...sheet ? { sheet } : {},
1251
+ ...ctx.options.includeEdges ? { edges: buildEdgeMetadata(ctx.graph, analysisResolution) } : {}
1252
+ };
1253
+ }
1254
+ var init_build_sieve_metadata = __esmMin((() => {
1255
+ init_pipeline_defaults();
1256
+ init_build_edge_metadata();
1257
+ init_build_frame_metadata();
1258
+ init_build_tool_metadata();
1259
+ }));
1260
+
1261
+ //#endregion
1262
+ //#region src/core/utils/metadata/build-video-metadata.ts
1263
+ /**
1264
+ * Read output dimensions and build consistent video and animation metadata.
1265
+ * JPEG finalization does not resize, so the source dimensions match the output.
1266
+ * @param ctx Pipeline state with source duration, effective FPS and analysis-space animations.
1267
+ * @param selected Selected frames in output order; the first candidate is the fallback.
1268
+ * @param analysisResolution Analysis dimensions; absent or zero dimensions imply no scaling.
1269
+ * @returns Video metadata and new output-space animations, retaining zero-based frame IDs.
1270
+ * @throws If sharp cannot read the selected or fallback image. Empty input performs no image I/O.
1271
+ */
1272
+ async function buildVideoMetadata(ctx, selected, analysisResolution) {
1273
+ const firstFrame = selected[0] ?? ctx.frames[0];
1274
+ const dimensions = firstFrame ? await sharp(firstFrame.extractPath).metadata() : void 0;
1275
+ const width = dimensions?.width ?? 0;
1276
+ const height = dimensions?.height ?? 0;
1277
+ const sx = analysisResolution?.width ? width / analysisResolution.width : 1;
1278
+ const sy = analysisResolution?.height ? height / analysisResolution.height : 1;
1279
+ const lastTimestamp = ctx.frames[ctx.frames.length - 1]?.timestamp ?? 0;
1280
+ const duration = ctx.options.mode === "frames" ? lastTimestamp : ctx.sourceDurationSec ?? lastTimestamp;
1281
+ return {
1282
+ video: {
1283
+ originalDurationMs: Math.round(duration * 1e3),
1284
+ fps: ctx.options.mode === "frames" ? 1 : ctx.effectiveFps ?? ctx.options.fps,
1285
+ resolution: {
1286
+ width,
1287
+ height
1288
+ },
1289
+ candidatesCount: ctx.frames.length,
1290
+ selectedCount: selected.length,
1291
+ source: {
1292
+ fileName: ctx.options.mode === "file" && ctx.options.inputPath ? basename(ctx.options.inputPath) : null,
1293
+ mode: ctx.options.mode
1294
+ }
1295
+ },
1296
+ animations: (ctx.animations ?? []).map((animation) => ({
1297
+ ...animation,
1298
+ boundingBox: scaleBoundingBox(animation.boundingBox, sx, sy, width, height)
1299
+ }))
1300
+ };
1301
+ }
1302
+ var init_build_video_metadata = __esmMin((() => {
1303
+ init_scale_bounding_box();
1304
+ }));
1305
+
1306
+ //#endregion
1307
+ //#region src/core/utils/sheet/build-tile-label-svg.ts
1308
+ /**
1309
+ * Build a tile-sized SVG overlay with a translucent badge and an explicit text baseline.
1310
+ * @param text - A formatTileLabel result containing only digits, #, spaces, colons and periods.
1311
+ * @param tileWidth - Positive tile canvas width in pixels.
1312
+ * @param tileHeight - Positive tile canvas height in pixels.
1313
+ * @returns Encoded SVG bytes for a top-left sharp composite overlay.
1314
+ */
1315
+ function buildTileLabelSvg(text, tileWidth, tileHeight) {
1316
+ const fontSize = Math.max(8, Math.round(tileHeight * SHEET_LABEL_HEIGHT_RATIO));
1317
+ const padX = Math.round(fontSize * .4);
1318
+ const padY = Math.round(fontSize * .2);
1319
+ const badgeWidth = Math.min(tileWidth, Math.ceil(text.length * fontSize * .6) + 2 * padX);
1320
+ 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>`);
1321
+ }
1322
+ var init_build_tile_label_svg = __esmMin((() => {
1323
+ init_pipeline_defaults();
1324
+ }));
1325
+
1326
+ //#endregion
1327
+ //#region src/core/utils/sheet/format-tile-label.ts
1328
+ /**
1329
+ * Format a contact sheet label without rounding into the next tenth of a second.
1330
+ * @param frameId - One-based candidate frame ID.
1331
+ * @param timestampMs - Nonnegative candidate time in milliseconds.
1332
+ * @returns A label in the form "#<id> mm:ss.s", allowing minutes beyond two digits.
1333
+ */
1334
+ function formatTileLabel(frameId, timestampMs) {
1335
+ const tenths = Math.floor(timestampMs / 100);
1336
+ return `#${frameId} ${String(Math.floor(tenths / 600)).padStart(2, "0")}:${String(Math.floor(tenths / 10) % 60).padStart(2, "0")}.${tenths % 10}`;
1337
+ }
1338
+ var init_format_tile_label = __esmMin((() => {}));
1339
+
1340
+ //#endregion
1341
+ //#region src/core/utils/sheet/sample-tile-frames.ts
1342
+ /**
1343
+ * Sample a sequence uniformly while always retaining both endpoints.
1344
+ * @param items - Items in temporal order.
1345
+ * @param maxTiles - Validated integer limit of at least two.
1346
+ * @returns The original sequence when within the limit, otherwise evenly sampled items.
1347
+ */
1348
+ function sampleTileFrames(items, maxTiles) {
1349
+ if (items.length <= maxTiles) return {
1350
+ items,
1351
+ sampled: false
1352
+ };
1353
+ return {
1354
+ items: Array.from({ length: maxTiles }, (_, i) => items[Math.round(i * (items.length - 1) / (maxTiles - 1))]),
1355
+ sampled: true
1356
+ };
1357
+ }
1358
+ var init_sample_tile_frames = __esmMin((() => {}));
1359
+
1360
+ //#endregion
1361
+ //#region src/core/utils/sheet/render-contact-sheet.ts
1362
+ /**
1363
+ * Read selected frame images and render a row-major contact sheet.
1364
+ * @param input - Nonempty ordered selections, positive dimensions and validated sheet settings.
1365
+ * @returns JPEG bytes and the effective tile layout with one-based frame IDs.
1366
+ * @throws Propagates sharp image reading, compositing or encoding errors.
1367
+ */
1368
+ async function renderContactSheet(input) {
1369
+ const { selected, resolution, options, quality } = input;
1370
+ const { items, sampled } = sampleTileFrames(selected, options.maxTiles);
1371
+ const columns = Math.min(options.columns, items.length);
1372
+ const rows = Math.ceil(items.length / columns);
1373
+ const tileWidth = options.tileWidth;
1374
+ const tileHeight = Math.max(1, Math.round(tileWidth * resolution.height / resolution.width));
1375
+ const gap = 4;
1376
+ const tiles = await Promise.all(map(items, async (frame, index) => {
1377
+ const tile = sharp(frame.extractPath).resize(tileWidth, tileHeight, { fit: "fill" });
1378
+ if (options.label) {
1379
+ const text = formatTileLabel(frame.id + 1, Math.round(frame.timestamp * 1e3));
1380
+ tile.composite([{
1381
+ input: buildTileLabelSvg(text, tileWidth, tileHeight),
1382
+ top: 0,
1383
+ left: 0
1384
+ }]);
1385
+ }
1386
+ return {
1387
+ input: await tile.png().toBuffer(),
1388
+ top: gap + Math.floor(index / columns) * (tileHeight + gap),
1389
+ left: gap + index % columns * (tileWidth + gap)
1390
+ };
1391
+ }));
1392
+ return {
1393
+ buffer: await sharp({ create: {
1394
+ width: columns * tileWidth + (columns + 1) * gap,
1395
+ height: rows * tileHeight + (rows + 1) * gap,
1396
+ channels: 3,
1397
+ background: "white"
1398
+ } }).composite(tiles).jpeg({
1399
+ quality,
1400
+ mozjpeg: true
1401
+ }).toBuffer(),
1402
+ metadata: {
1403
+ fileName: SHEET_FILE_NAME,
1404
+ columns,
1405
+ tileWidth,
1406
+ tileHeight,
1407
+ frameIds: map(items, (frame) => frame.id + 1),
1408
+ sampled
1409
+ }
1410
+ };
1411
+ }
1412
+ var init_render_contact_sheet = __esmMin((() => {
1413
+ init_pipeline_defaults();
1414
+ init_build_tile_label_svg();
1415
+ init_format_tile_label();
1416
+ init_sample_tile_frames();
1417
+ }));
1418
+
1419
+ //#endregion
1420
+ //#region src/core/utils/output/finalize-selection.ts
1421
+ /**
1422
+ * Read output dimensions once, render optional sheets and finalize the shared v2 document.
1423
+ * @param ctx - Pipeline state owned by the orchestrator; this function does not mutate it.
1424
+ * @param selected - Selected frames in temporal order.
1425
+ * @returns Mode-specific output, the document and zero-based API animations.
1426
+ * @throws Propagates image, rendering and output I/O errors to the orchestrator.
1427
+ */
1428
+ async function finalizeSelection(ctx, selected) {
1429
+ const { video, animations } = await buildVideoMetadata(ctx, selected, ctx.analysisResolution);
1430
+ const sheet = ctx.options.sheet && selected.length > 0 && video.resolution.width > 0 && video.resolution.height > 0 ? await renderContactSheet({
1431
+ selected,
1432
+ resolution: video.resolution,
1433
+ options: ctx.options.sheet,
1434
+ quality: ctx.options.quality
1435
+ }) : void 0;
1436
+ const document = buildSieveMetadata({
1437
+ ctx,
1438
+ selected,
1439
+ video,
1440
+ animations,
1441
+ version: PACKAGE_VERSION,
1442
+ ...sheet ? { sheet: sheet.metadata } : {}
1443
+ });
1444
+ if (ctx.options.mode === "file") return {
1445
+ outputFiles: await finalizeOutput(ctx, selected, document, sheet?.buffer),
1446
+ document,
1447
+ animations
1448
+ };
1449
+ return {
1450
+ outputFiles: [],
1451
+ outputBuffers: await readFramesAsBuffers(selected, ctx.options.quality),
1452
+ document,
1453
+ animations,
1454
+ ...sheet ? { sheetBuffer: sheet.buffer } : {}
1455
+ };
1456
+ }
1457
+ var init_finalize_selection = __esmMin((() => {
1458
+ init_package_version();
1459
+ init_workspace();
1460
+ init_build_sieve_metadata();
1461
+ init_build_video_metadata();
1462
+ init_render_contact_sheet();
1463
+ }));
1464
+
857
1465
  //#endregion
858
1466
  //#region src/core/extractor/extractor.ts
859
1467
  /**
@@ -938,7 +1546,8 @@ async function getVideoMetadata(inputPath) {
938
1546
  * @returns Zero-based candidates without a segment seek offset.
939
1547
  */
940
1548
  async function buildFrameList(framesDir, effectiveFps) {
941
- const jpgFiles = filter(await readdir(framesDir), (f) => f.endsWith(".jpg")).sort();
1549
+ const files = await readdir(framesDir);
1550
+ const jpgFiles = filter(files, (f) => f.endsWith(".jpg")).sort();
942
1551
  if (jpgFiles.length === 0) return [];
943
1552
  return map(jpgFiles, (file, index) => ({
944
1553
  id: index,
@@ -1085,148 +1694,21 @@ function validateOptions(options) {
1085
1694
  if (value === void 0) continue;
1086
1695
  if (!Number.isFinite(value) || integer && !Number.isInteger(value) || (exclusiveMin ? value <= min : value < min) || value > max) throw new Error(`${name} must be ${requirement}, received: ${value}`);
1087
1696
  }
1088
- }
1089
- var init_validate_options = __esmMin((() => {}));
1090
-
1091
- //#endregion
1092
- //#region src/core/workspace/workspace.ts
1093
- async function createWorkspace(sessionId) {
1094
- const workspacePath = getTempWorkspaceDir(sessionId);
1095
- await ensureDir(join(workspacePath, "frames"));
1096
- await ensureDir(join(workspacePath, "output"));
1097
- return workspacePath;
1098
- }
1099
- async function finalizeOutput(ctx, selectedFrames) {
1100
- const stagingDir = join(ctx.workspacePath, "output");
1101
- const outputPath = ctx.options.outputPath;
1102
- const quality = ctx.options.quality;
1103
- const outputFiles = [];
1104
- const framesMetadata = [];
1105
- const totalFramesCount = ctx.frames.length;
1106
- const padding = Math.max(4, String(totalFramesCount).length);
1107
- for (let i = 0; i < selectedFrames.length; i++) {
1108
- const frame = selectedFrames[i];
1109
- const fileName = `frame_${String(frame.id + 1).padStart(padding, "0")}.jpg`;
1110
- const destPath = join(stagingDir, fileName);
1111
- await sharp(frame.extractPath).jpeg({
1112
- quality,
1113
- mozjpeg: true
1114
- }).toFile(destPath);
1115
- outputFiles.push(join(outputPath, fileName));
1116
- framesMetadata.push({
1117
- step: i + 1,
1118
- fileName,
1119
- frameId: frame.id + 1,
1120
- timestampMs: Math.round(frame.timestamp * 1e3)
1121
- });
1122
- }
1123
- const { video, animations } = await buildVideoMetadata(ctx, selectedFrames, ctx.analysisResolution);
1124
- const metadata = {
1125
- video,
1126
- frames: framesMetadata,
1127
- animations: map(animations, (anim) => ({
1128
- ...anim,
1129
- startFrameId: anim.startFrameId + 1,
1130
- endFrameId: anim.endFrameId + 1,
1131
- durationMs: Math.round(anim.durationMs)
1132
- }))
1133
- };
1134
- await writeFile(join(stagingDir, ".metadata.json"), JSON.stringify(metadata, null, 2));
1135
- outputFiles.push(join(outputPath, ".metadata.json"));
1136
- await ensureDir(join(outputPath, ".."));
1137
- await rm(outputPath, {
1138
- recursive: true,
1139
- force: true
1140
- });
1141
- await rename(stagingDir, outputPath);
1142
- return outputFiles;
1143
- }
1144
- async function createSegmentWorkspace(parentWorkspacePath, segmentIndex) {
1145
- const segmentPath = join(parentWorkspacePath, "segments", String(segmentIndex));
1146
- await ensureDir(join(segmentPath, "frames"));
1147
- return segmentPath;
1148
- }
1149
- async function cleanupWorkspace(workspacePath) {
1150
- if (!workspacePath) return;
1151
- try {
1152
- await rm(workspacePath, {
1153
- recursive: true,
1154
- force: true
1155
- });
1156
- } catch {}
1157
- }
1158
- /**
1159
- * Remove stale workspace directories left by previous interrupted runs.
1160
- * Only deletes directories older than 1 hour to avoid removing active workspaces.
1161
- */
1162
- async function cleanupStaleWorkspaces() {
1163
- const entries = await readdir(TEMP_BASE_DIR);
1164
- const now = Date.now();
1165
- for (const entry of entries) {
1166
- if (!entry.startsWith(WORKSPACE_PREFIX)) continue;
1167
- const fullPath = join(TEMP_BASE_DIR, entry);
1168
- try {
1169
- const info = await stat(fullPath);
1170
- if (info.isDirectory() && now - info.mtimeMs > STALE_THRESHOLD_MS) await rm(fullPath, {
1171
- recursive: true,
1172
- force: true
1173
- });
1174
- } catch {}
1175
- }
1176
- }
1177
- /**
1178
- * Write a video buffer to a temp file in the workspace and return the path.
1179
- * Used by 'buffer' input mode.
1180
- */
1181
- async function writeInputBuffer(buffer, workspacePath) {
1182
- const inputDir = join(workspacePath, "input");
1183
- await ensureDir(inputDir);
1184
- const tempPath = join(inputDir, "input.mp4");
1185
- await writeFile(tempPath, buffer);
1186
- return tempPath;
1187
- }
1188
- /**
1189
- * Write an array of frame Buffers as JPG files and return FrameNode[].
1190
- * Used by 'frames' input mode.
1191
- */
1192
- async function writeInputFrames(frames, workspacePath) {
1193
- const framesDir = join(workspacePath, "frames");
1194
- await ensureDir(framesDir);
1195
- const frameNodes = [];
1196
- for (let i = 0; i < frames.length; i++) {
1197
- const extractPath = join(framesDir, `frame_${String(i).padStart(6, "0")}${FRAME_OUTPUT_EXTENSION}`);
1198
- await writeFile(extractPath, frames[i]);
1199
- frameNodes.push({
1200
- id: i,
1201
- timestamp: i,
1202
- extractPath
1203
- });
1697
+ const sheet = options.sheet;
1698
+ if (sheet === void 0 || typeof sheet === "boolean") return;
1699
+ 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)}`);
1700
+ for (const [name, min] of [
1701
+ ["columns", 1],
1702
+ ["tileWidth", 16],
1703
+ ["maxTiles", 2]
1704
+ ]) {
1705
+ const value = sheet[name];
1706
+ if (value === void 0) continue;
1707
+ if (!Number.isInteger(value) || value < min) throw new Error(`sheet.${name} must be an integer >= ${min}, received: ${value}`);
1204
1708
  }
1205
- return frameNodes;
1206
- }
1207
- /**
1208
- * Read selected FrameNode files as Buffers with JPEG compression.
1209
- * Used to return output buffers in 'buffer' and 'frames' modes.
1210
- */
1211
- async function readFramesAsBuffers(frameNodes, quality) {
1212
- return Promise.all(map(frameNodes, (f) => sharp(f.extractPath).jpeg({
1213
- quality,
1214
- mozjpeg: true
1215
- }).toBuffer()));
1709
+ if (sheet.label !== void 0 && typeof sheet.label !== "boolean") throw new Error(`sheet.label must be a boolean, received: ${sheet.label}`);
1216
1710
  }
1217
- var STALE_THRESHOLD_MS;
1218
- var init_workspace$1 = __esmMin((() => {
1219
- init_workspace_layout();
1220
- init_paths();
1221
- init_build_video_metadata();
1222
- STALE_THRESHOLD_MS = 3600 * 1e3;
1223
- }));
1224
-
1225
- //#endregion
1226
- //#region src/core/workspace/index.ts
1227
- var init_workspace = __esmMin((() => {
1228
- init_workspace$1();
1229
- }));
1711
+ var init_validate_options = __esmMin((() => {}));
1230
1712
 
1231
1713
  //#endregion
1232
1714
  //#region src/core/input-resolver/input-resolver.ts
@@ -1242,12 +1724,14 @@ function resolveOptions(options) {
1242
1724
  const inputPath = mode === "file" ? resolveAbsolute(options.inputPath) : void 0;
1243
1725
  const outputPath = options.outputPath ?? (inputPath ? deriveOutputPath(inputPath) : join(process.cwd(), "scene-sieve-output"));
1244
1726
  const threshold = options.threshold ?? .5;
1727
+ const pruneMode = "threshold-with-cap";
1728
+ const sheet = typeof options.sheet === "object" ? options.sheet : {};
1245
1729
  return {
1246
1730
  mode,
1247
1731
  inputPath,
1248
1732
  count: options.count ?? 20,
1249
1733
  threshold,
1250
- pruneMode: "threshold-with-cap",
1734
+ pruneMode,
1251
1735
  outputPath,
1252
1736
  fps: options.fps ?? 5,
1253
1737
  maxFrames: options.maxFrames ?? 300,
@@ -1257,7 +1741,14 @@ function resolveOptions(options) {
1257
1741
  animationThreshold: options.animationThreshold ?? 5,
1258
1742
  debug: options.debug ?? false,
1259
1743
  maxSegmentDuration: options.maxSegmentDuration ?? 300,
1260
- concurrency: options.concurrency ?? 2
1744
+ concurrency: options.concurrency ?? 2,
1745
+ sheet: options.sheet ? {
1746
+ columns: sheet.columns ?? 4,
1747
+ tileWidth: sheet.tileWidth ?? 320,
1748
+ maxTiles: sheet.maxTiles ?? 40,
1749
+ label: sheet.label ?? true
1750
+ } : null,
1751
+ includeEdges: options.includeEdges ?? false
1261
1752
  };
1262
1753
  }
1263
1754
  /**
@@ -1371,7 +1862,7 @@ function normalizeScores(items) {
1371
1862
  if (s <= 0) return 0;
1372
1863
  return lowerBound(sorted, s) / sorted.length;
1373
1864
  });
1374
- return map(logisticZ, (z, i) => z * (1 - NORMALIZATION_ALPHA) + cdf[i] * NORMALIZATION_ALPHA);
1865
+ return map(logisticZ, (z, i) => z * .6 + cdf[i] * NORMALIZATION_ALPHA);
1375
1866
  }
1376
1867
  var NORMALIZATION_ALPHA, NORMALIZATION_MAD_COEFFICIENT, NORMALIZATION_MIN_SAMPLE_SIZE;
1377
1868
  var init_normalize_scores = __esmMin((() => {
@@ -1794,16 +2285,16 @@ function remapEdges(segmentResults, globalIdMap) {
1794
2285
  const existingIdx = edgeMap.get(edgeKey);
1795
2286
  if (existingIdx !== void 0) {
1796
2287
  if (edges[existingIdx].score < edge.score) edges[existingIdx] = {
2288
+ ...edge,
1797
2289
  sourceId: newSourceId,
1798
- targetId: newTargetId,
1799
- score: edge.score
2290
+ targetId: newTargetId
1800
2291
  };
1801
2292
  } else {
1802
2293
  edgeMap.set(edgeKey, edges.length);
1803
2294
  edges.push({
2295
+ ...edge,
1804
2296
  sourceId: newSourceId,
1805
- targetId: newTargetId,
1806
- score: edge.score
2297
+ targetId: newTargetId
1807
2298
  });
1808
2299
  }
1809
2300
  }
@@ -1878,7 +2369,8 @@ function buildSegmentContext(segment, frames, segmentWorkspacePath, resolvedOpti
1878
2369
  * Each segment uses an isolated workspace directory.
1879
2370
  */
1880
2371
  async function processSegment(inputPath, segment, workspacePath, resolvedOptions, onProgress) {
1881
- const frames = await extractFramesForRange(inputPath, join(workspacePath, "frames"), segment.effectiveFps, resolvedOptions.scale, segment.extractStartTime, segment.extractDuration, segment.allocatedFrames);
2372
+ const framesDir = join(workspacePath, "frames");
2373
+ const frames = await extractFramesForRange(inputPath, framesDir, segment.effectiveFps, resolvedOptions.scale, segment.extractStartTime, segment.extractDuration, segment.allocatedFrames);
1882
2374
  if (frames.length < 2) return {
1883
2375
  segment,
1884
2376
  frames,
@@ -1889,7 +2381,8 @@ async function processSegment(inputPath, segment, workspacePath, resolvedOptions
1889
2381
  height: 0
1890
2382
  }
1891
2383
  };
1892
- const { edges, animations, analysisResolution } = await analyzeFrames(buildSegmentContext(segment, frames, workspacePath, resolvedOptions, onProgress));
2384
+ const ctx = buildSegmentContext(segment, frames, workspacePath, resolvedOptions, onProgress);
2385
+ const { edges, animations, analysisResolution } = await analyzeFrames(ctx);
1893
2386
  return {
1894
2387
  segment,
1895
2388
  frames,
@@ -1954,20 +2447,20 @@ async function runSegmentedPipeline(options, resolvedOptions) {
1954
2447
  status: "FINALIZING",
1955
2448
  emitProgress: (percent) => options.onProgress?.("FINALIZING", percent)
1956
2449
  };
1957
- let outputFiles = [];
1958
- let outputBuffers;
1959
- if (resolvedOptions.mode === "buffer" || resolvedOptions.mode === "frames") outputBuffers = await readFramesAsBuffers(prunedFrames, resolvedOptions.quality);
1960
- else outputFiles = await finalizeOutput(ctx, prunedFrames);
2450
+ const finalized = await finalizeSelection(ctx, prunedFrames);
1961
2451
  options.onProgress?.("FINALIZING", 100);
1962
2452
  logger.success(`Segmented pipeline: ${prunedFrames.length} scenes from ${frames.length} frames (${segments.length} segments)`);
1963
- const outputMetadata = await buildVideoMetadata(ctx, prunedFrames, analysisResolution);
1964
2453
  return {
1965
2454
  success: true,
1966
2455
  originalFramesCount: frames.length,
1967
2456
  prunedFramesCount: prunedFrames.length,
1968
- outputFiles,
1969
- outputBuffers,
1970
- ...outputMetadata,
2457
+ outputFiles: finalized.outputFiles,
2458
+ outputBuffers: finalized.outputBuffers,
2459
+ video: finalized.document.video,
2460
+ animations: finalized.animations,
2461
+ frames: finalized.document.frames,
2462
+ ...finalized.document.sheet ? { sheet: finalized.document.sheet } : {},
2463
+ ...finalized.sheetBuffer ? { sheetBuffer: finalized.sheetBuffer } : {},
1971
2464
  executionTimeMs: Date.now() - pipelineStart
1972
2465
  };
1973
2466
  } catch (error) {
@@ -1983,7 +2476,7 @@ var init_segmenter$1 = __esmMin((() => {
1983
2476
  init_concurrency();
1984
2477
  init_logger();
1985
2478
  init_analyzer();
1986
- init_build_video_metadata();
2479
+ init_finalize_selection();
1987
2480
  init_extractor();
1988
2481
  init_input_resolver();
1989
2482
  init_pruner();
@@ -1999,6 +2492,12 @@ var init_segmenter = __esmMin((() => {
1999
2492
  //#endregion
2000
2493
  //#region src/core/orchestrator/orchestrator.ts
2001
2494
  var orchestrator_exports = /* @__PURE__ */ __exportAll({ runPipeline: () => runPipeline });
2495
+ /**
2496
+ * Run the five pipeline stages, delegating segmented inputs to the segmenter.
2497
+ * @param options - Mode-specific input and optional pipeline settings.
2498
+ * @returns Selected outputs with v2 frame metadata and zero-based API animations.
2499
+ * @throws Propagates stage failures after cleaning the workspace unless debug is enabled.
2500
+ */
2002
2501
  async function runPipeline(options) {
2003
2502
  setDebugMode(options.debug ?? false);
2004
2503
  const resolvedOptions = resolveOptions(options);
@@ -2046,25 +2545,21 @@ async function runPipeline(options) {
2046
2545
  const prunedFrames = filter(ctx.frames, (f) => survivingIds.has(f.id));
2047
2546
  ctx.emitProgress(100);
2048
2547
  ctx.status = "FINALIZING";
2049
- let outputFiles = [];
2050
- let outputBuffers;
2051
- if (resolvedOptions.mode === "buffer" || resolvedOptions.mode === "frames") {
2052
- outputBuffers = await readFramesAsBuffers(prunedFrames, resolvedOptions.quality);
2053
- ctx.emitProgress(100);
2054
- } else {
2055
- outputFiles = await finalizeOutput(ctx, prunedFrames);
2056
- ctx.emitProgress(100);
2057
- }
2548
+ const finalized = await finalizeSelection(ctx, prunedFrames);
2549
+ ctx.emitProgress(100);
2058
2550
  ctx.status = "SUCCESS";
2059
2551
  logger.success(`Extracted ${prunedFrames.length} scenes from ${ctx.frames.length} frames`);
2060
- const metadata = await buildVideoMetadata(ctx, prunedFrames, analysisResolution);
2061
2552
  return {
2062
2553
  success: true,
2063
2554
  originalFramesCount: ctx.frames.length,
2064
2555
  prunedFramesCount: prunedFrames.length,
2065
- outputFiles,
2066
- outputBuffers,
2067
- ...metadata,
2556
+ outputFiles: finalized.outputFiles,
2557
+ outputBuffers: finalized.outputBuffers,
2558
+ video: finalized.document.video,
2559
+ animations: finalized.animations,
2560
+ frames: finalized.document.frames,
2561
+ ...finalized.document.sheet ? { sheet: finalized.document.sheet } : {},
2562
+ ...finalized.sheetBuffer ? { sheetBuffer: finalized.sheetBuffer } : {},
2068
2563
  executionTimeMs: Date.now() - startTime
2069
2564
  };
2070
2565
  } catch (error) {
@@ -2080,7 +2575,7 @@ async function runPipeline(options) {
2080
2575
  var init_orchestrator = __esmMin((() => {
2081
2576
  init_logger();
2082
2577
  init_analyzer();
2083
- init_build_video_metadata();
2578
+ init_finalize_selection();
2084
2579
  init_extractor();
2085
2580
  init_input_resolver();
2086
2581
  init_pruner();
@@ -2165,6 +2660,16 @@ const SIEVE_COMMAND = {
2165
2660
  required: true
2166
2661
  }],
2167
2662
  options: [
2663
+ {
2664
+ flag: "--sheet",
2665
+ description: "Generate a contact sheet of selected frames",
2666
+ type: "boolean"
2667
+ },
2668
+ {
2669
+ flag: "--include-edges",
2670
+ description: "Include candidate edge diagnostics in metadata",
2671
+ type: "boolean"
2672
+ },
2168
2673
  {
2169
2674
  flag: "-n, --count <number>",
2170
2675
  description: "Max number of frames to keep (default: 20)",
@@ -2280,7 +2785,9 @@ function parsePipelineOptions(opts) {
2280
2785
  animationThreshold: opts.animThreshold !== void 0 ? parseNumberStrict(opts.animThreshold, true) : void 0,
2281
2786
  maxSegmentDuration: opts.maxSegmentDuration !== void 0 ? parseNumberStrict(opts.maxSegmentDuration) : void 0,
2282
2787
  concurrency: opts.concurrency !== void 0 ? parseNumberStrict(opts.concurrency, true) : void 0,
2283
- debug: opts.debug ?? false
2788
+ debug: opts.debug ?? false,
2789
+ ...opts.sheet !== void 0 ? { sheet: opts.sheet } : {},
2790
+ ...opts.includeEdges !== void 0 ? { includeEdges: opts.includeEdges } : {}
2284
2791
  };
2285
2792
  }
2286
2793
 
@@ -2329,7 +2836,7 @@ function phaseKeyToIndex(phase) {
2329
2836
  }
2330
2837
  function registerSieveCommand(program, version) {
2331
2838
  const cmd = SIEVE_COMMAND;
2332
- program.argument("<input>", cmd.arguments[0].description).option("-n, --count <number>", cmd.options.find((o) => o.flag.includes("--count")).description).option("-t, --threshold <number>", cmd.options.find((o) => o.flag.includes("--threshold")).description).option("-o, --output <path>", cmd.options.find((o) => o.flag.includes("--output")).description).option("--fps <number>", cmd.options.find((o) => o.flag.includes("--fps")).description, String(5)).option("-mf, --max-frames <number>", cmd.options.find((o) => o.flag.includes("--max-frames")).description, String(300)).option("-s, --scale <number>", cmd.options.find((o) => o.flag.includes("--scale")).description, String(720)).option("-q, --quality <number>", cmd.options.find((o) => o.flag.includes("--quality")).description, String(80)).option("-it, --iou-threshold <number>", cmd.options.find((o) => o.flag.includes("--iou-threshold")).description).option("-at, --anim-threshold <number>", cmd.options.find((o) => o.flag.includes("--anim-threshold")).description).option("--max-segment-duration <number>", cmd.options.find((o) => o.flag.includes("--max-segment-duration")).description).option("--concurrency <number>", cmd.options.find((o) => o.flag.includes("--concurrency")).description).option("--debug", cmd.options.find((o) => o.flag.includes("--debug")).description).option("--json", cmd.options.find((o) => o.flag.includes("--json")).description).option("--describe", cmd.options.find((o) => o.flag.includes("--describe")).description).action(async (input, opts) => {
2839
+ program.argument("<input>", cmd.arguments[0].description).option("-n, --count <number>", cmd.options.find((o) => o.flag.includes("--count")).description).option("-t, --threshold <number>", cmd.options.find((o) => o.flag.includes("--threshold")).description).option("-o, --output <path>", cmd.options.find((o) => o.flag.includes("--output")).description).option("--fps <number>", cmd.options.find((o) => o.flag.includes("--fps")).description, String(5)).option("-mf, --max-frames <number>", cmd.options.find((o) => o.flag.includes("--max-frames")).description, String(300)).option("-s, --scale <number>", cmd.options.find((o) => o.flag.includes("--scale")).description, String(720)).option("-q, --quality <number>", cmd.options.find((o) => o.flag.includes("--quality")).description, String(80)).option("-it, --iou-threshold <number>", cmd.options.find((o) => o.flag.includes("--iou-threshold")).description).option("-at, --anim-threshold <number>", cmd.options.find((o) => o.flag.includes("--anim-threshold")).description).option("--max-segment-duration <number>", cmd.options.find((o) => o.flag.includes("--max-segment-duration")).description).option("--concurrency <number>", cmd.options.find((o) => o.flag.includes("--concurrency")).description).option("--debug", cmd.options.find((o) => o.flag.includes("--debug")).description).option("--sheet", cmd.options.find((o) => o.flag === "--sheet").description).option("--include-edges", cmd.options.find((o) => o.flag === "--include-edges").description).option("--json", cmd.options.find((o) => o.flag.includes("--json")).description).option("--describe", cmd.options.find((o) => o.flag.includes("--describe")).description).action(async (input, opts) => {
2333
2840
  const parsed = parsePipelineOptions(opts);
2334
2841
  if (opts.json) {
2335
2842
  setJsonMode(true);
@@ -2350,14 +2857,17 @@ function registerSieveCommand(program, version) {
2350
2857
  }) + "\n");
2351
2858
  }
2352
2859
  });
2353
- respond("extract", {
2860
+ const data = {
2354
2861
  success: result.success,
2355
2862
  originalFrames: result.originalFramesCount,
2356
2863
  selectedFrames: result.prunedFramesCount,
2357
2864
  outputFiles: result.outputFiles,
2358
2865
  animations: result.animations ?? [],
2359
- video: result.video ?? null
2360
- }, startTime, version);
2866
+ video: result.video ?? null,
2867
+ frames: result.frames ?? [],
2868
+ sheet: result.sheet ?? null
2869
+ };
2870
+ respond("extract", data, startTime, version);
2361
2871
  } catch (error) {
2362
2872
  const err = error instanceof Error ? error : new Error(String(error));
2363
2873
  respondError("extract", classifyError(err), err.message, startTime, version);
@@ -2407,7 +2917,9 @@ const SieveView = (props) => {
2407
2917
  animationThreshold: props.animationThreshold,
2408
2918
  maxSegmentDuration: props.maxSegmentDuration,
2409
2919
  concurrency: props.concurrency,
2410
- debug: props.debug
2920
+ debug: props.debug,
2921
+ ...props.sheet !== void 0 ? { sheet: props.sheet } : {},
2922
+ includeEdges: props.includeEdges
2411
2923
  }, (phase, percent) => {
2412
2924
  const phaseIdx = phaseKeyToIndex(phase);
2413
2925
  if (phaseIdx < 0) return;
@@ -2541,23 +3053,23 @@ const SieveView = (props) => {
2541
3053
 
2542
3054
  //#endregion
2543
3055
  //#region src/cli.ts
2544
- const { version } = createRequire(import.meta.url)("../package.json");
3056
+ init_package_version();
2545
3057
  const program = new Command();
2546
- program.name("scene-sieve").description("Extract key frames from video and GIF files").version(version);
2547
- registerSieveCommand(program, version);
3058
+ program.name("scene-sieve").description("Extract key frames from video and GIF files").version(PACKAGE_VERSION);
3059
+ registerSieveCommand(program, PACKAGE_VERSION);
2548
3060
  if (process.argv.includes("--describe")) {
2549
3061
  const startTime = Date.now();
2550
3062
  respond("describe", {
2551
3063
  name: SIEVE_COMMAND.name,
2552
- version,
3064
+ version: PACKAGE_VERSION,
2553
3065
  description: SIEVE_COMMAND.description,
2554
3066
  arguments: SIEVE_COMMAND.arguments,
2555
3067
  options: SIEVE_COMMAND.options
2556
- }, startTime, version);
3068
+ }, startTime, PACKAGE_VERSION);
2557
3069
  process.exit(0);
2558
3070
  }
2559
3071
  program.parseAsync(process.argv).catch((error) => {
2560
- if (process.argv.includes("--json")) respondError("extract", SieveErrorCode.UNKNOWN, error.message, Date.now(), version);
3072
+ if (process.argv.includes("--json")) respondError("extract", SieveErrorCode.UNKNOWN, error.message, Date.now(), PACKAGE_VERSION);
2561
3073
  else console.error("Fatal error:", error.message);
2562
3074
  process.exit(1);
2563
3075
  });