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