@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.
- package/README.md +88 -25
- package/dist/cli/commands/Sieve.d.ts +5 -0
- package/dist/cli/options/parse-options.d.ts +5 -1
- package/dist/cli.mjs +749 -237
- package/dist/constants/package-version.d.ts +2 -0
- package/dist/constants/pipeline-defaults.d.ts +20 -0
- package/dist/core/index.d.ts +1 -0
- package/dist/core/orchestrator/orchestrator.d.ts +6 -0
- package/dist/core/utils/metadata/build-edge-metadata.d.ts +11 -0
- package/dist/core/utils/metadata/build-frame-metadata.d.ts +20 -0
- package/dist/core/utils/metadata/build-sieve-metadata.d.ts +14 -0
- package/dist/core/utils/metadata/build-tool-metadata.d.ts +8 -0
- package/dist/core/utils/metadata/change/build-frame-change.d.ts +20 -0
- package/dist/core/utils/metadata/change/select-regions.d.ts +8 -0
- package/dist/core/utils/metadata/change/union-area/y-coverage-tree.d.ts +26 -0
- package/dist/core/utils/metadata/change/union-area.d.ts +7 -0
- package/dist/core/utils/metadata/scale-bounding-box.d.ts +11 -0
- package/dist/core/utils/output/finalize-selection.d.ts +15 -0
- package/dist/core/utils/sheet/build-tile-label-svg.d.ts +8 -0
- package/dist/core/utils/sheet/format-tile-label.d.ts +7 -0
- package/dist/core/utils/sheet/render-contact-sheet.d.ts +19 -0
- package/dist/core/utils/sheet/sample-tile-frames.d.ts +10 -0
- package/dist/core/workspace/workspace.d.ts +11 -2
- package/dist/index.cjs +701 -205
- package/dist/index.d.ts +1 -1
- package/dist/index.mjs +700 -204
- package/dist/pipeline-worker.mjs +640 -188
- package/dist/types/index.d.ts +148 -0
- 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)
|
|
80
|
-
|
|
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
|
-
|
|
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/
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
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
|
-
|
|
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
|
|
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 *
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1928
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2457
|
+
init_finalize_selection();
|
|
1962
2458
|
init_extractor();
|
|
1963
2459
|
init_input_resolver();
|
|
1964
2460
|
init_pruner();
|