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