@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/pipeline-worker.mjs
CHANGED
|
@@ -4,12 +4,13 @@ import { randomUUID } from "node:crypto";
|
|
|
4
4
|
import { filter, map } from "@winglet/common-utils";
|
|
5
5
|
import pc from "picocolors";
|
|
6
6
|
import sharp from "sharp";
|
|
7
|
+
import { existsSync } from "node:fs";
|
|
7
8
|
import { mkdir, readdir, rename, rm, stat, writeFile } from "node:fs/promises";
|
|
8
9
|
import { basename, extname, join, resolve } from "node:path";
|
|
10
|
+
import { homedir, tmpdir } from "node:os";
|
|
9
11
|
import { path } from "@ffprobe-installer/ffprobe";
|
|
10
12
|
import { execa } from "execa";
|
|
11
13
|
import ffmpegPath from "ffmpeg-static";
|
|
12
|
-
import { homedir, tmpdir } from "node:os";
|
|
13
14
|
|
|
14
15
|
//#region src/logging/logger.ts
|
|
15
16
|
let debugMode = false;
|
|
@@ -36,8 +37,10 @@ const logger = {
|
|
|
36
37
|
console.error(`${pc.red("error")} ${message}`);
|
|
37
38
|
},
|
|
38
39
|
debug(message) {
|
|
39
|
-
if (debugMode)
|
|
40
|
-
|
|
40
|
+
if (debugMode) {
|
|
41
|
+
if (jsonMode) process.stderr.write(`${pc.gray(`[${timestamp()}] debug`)} ${message}\n`);
|
|
42
|
+
else console.log(`${pc.gray(`[${timestamp()}] debug`)} ${message}`);
|
|
43
|
+
}
|
|
41
44
|
}
|
|
42
45
|
};
|
|
43
46
|
|
|
@@ -45,6 +48,12 @@ const logger = {
|
|
|
45
48
|
//#region src/constants/pipeline-defaults.ts
|
|
46
49
|
const DEFAULT_THRESHOLD = .5;
|
|
47
50
|
const IOU_THRESHOLD = .9;
|
|
51
|
+
/** Tile-height fraction used for label font size. */
|
|
52
|
+
const SHEET_LABEL_HEIGHT_RATIO = .07;
|
|
53
|
+
/** Fixed contact sheet output name. */
|
|
54
|
+
const SHEET_FILE_NAME = "sheet.jpg";
|
|
55
|
+
/** Fixed metadata document output name. */
|
|
56
|
+
const METADATA_FILE_NAME = ".metadata.json";
|
|
48
57
|
|
|
49
58
|
//#endregion
|
|
50
59
|
//#region src/core/analyzer/constants/vision-tuning.ts
|
|
@@ -467,7 +476,8 @@ async function analyzeBatch(cvLib, akaze, frames, carry, scale, tracker, pairOff
|
|
|
467
476
|
try {
|
|
468
477
|
prev ??= computeFrameFeatures(cvLib, akaze, preprocessed[i]);
|
|
469
478
|
next = computeFrameFeatures(cvLib, akaze, preprocessed[i + 1]);
|
|
470
|
-
|
|
479
|
+
const sNew = computeNewPoints(cvLib, prev, next);
|
|
480
|
+
let dbscanResult = dbscan(sNew, imageWidth, imageHeight);
|
|
471
481
|
let clusters = dbscanResult.boundingBoxes;
|
|
472
482
|
if (clusters.length === 0) {
|
|
473
483
|
const pixelDiffPoints = computePixelDiff(cvLib, preprocessed[i], preprocessed[i + 1]);
|
|
@@ -480,13 +490,18 @@ async function analyzeBatch(cvLib, akaze, frames, carry, scale, tracker, pairOff
|
|
|
480
490
|
const clusterPointCounts = new Array(clusters.length).fill(0);
|
|
481
491
|
for (const label of dbscanResult.labels) if (label >= 0) clusterPointCounts[label]++;
|
|
482
492
|
const animationIndices = tracker.update(clusters, pairIndex);
|
|
493
|
+
const change = {
|
|
494
|
+
regions: filter(clusters, (_, ci) => !animationIndices.has(ci)),
|
|
495
|
+
animatedRegions: filter(clusters, (_, ci) => animationIndices.has(ci))
|
|
496
|
+
};
|
|
483
497
|
const animationWeights = map(clusters, (_, ci) => animationIndices.has(ci) ? tracker.getAnimationWeight(ci, clusters) : 0);
|
|
484
498
|
const score = computeInformationGain(clusters, clusterPointCounts, imageArea, animationIndices, animationWeights);
|
|
485
499
|
logger.debug(`Edge ${frames[i].id}->${frames[i + 1].id} G(t)=${score.toFixed(6)}`);
|
|
486
500
|
edges.push({
|
|
487
501
|
sourceId: frames[i].id,
|
|
488
502
|
targetId: frames[i + 1].id,
|
|
489
|
-
score
|
|
503
|
+
score,
|
|
504
|
+
change
|
|
490
505
|
});
|
|
491
506
|
} catch (err) {
|
|
492
507
|
logger.warn(`Frame pair analysis failed: ${String(err)}`);
|
|
@@ -584,50 +599,11 @@ async function analyzeFrames(ctx) {
|
|
|
584
599
|
}
|
|
585
600
|
|
|
586
601
|
//#endregion
|
|
587
|
-
//#region src/
|
|
588
|
-
/**
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
* @param selected Selected frames in output order; the first candidate is the fallback.
|
|
593
|
-
* @param analysisResolution Analysis dimensions; absent or zero dimensions imply no scaling.
|
|
594
|
-
* @returns Video metadata and new output-space animations, retaining zero-based frame IDs.
|
|
595
|
-
* @throws If sharp cannot read the selected or fallback image. Empty input performs no image I/O.
|
|
596
|
-
*/
|
|
597
|
-
async function buildVideoMetadata(ctx, selected, analysisResolution) {
|
|
598
|
-
const firstFrame = selected[0] ?? ctx.frames[0];
|
|
599
|
-
const dimensions = firstFrame ? await sharp(firstFrame.extractPath).metadata() : void 0;
|
|
600
|
-
const width = dimensions?.width ?? 0;
|
|
601
|
-
const height = dimensions?.height ?? 0;
|
|
602
|
-
const sx = analysisResolution?.width ? width / analysisResolution.width : 1;
|
|
603
|
-
const sy = analysisResolution?.height ? height / analysisResolution.height : 1;
|
|
604
|
-
const lastTimestamp = ctx.frames[ctx.frames.length - 1]?.timestamp ?? 0;
|
|
605
|
-
const duration = ctx.options.mode === "frames" ? lastTimestamp : ctx.sourceDurationSec ?? lastTimestamp;
|
|
606
|
-
return {
|
|
607
|
-
video: {
|
|
608
|
-
originalDurationMs: Math.round(duration * 1e3),
|
|
609
|
-
fps: ctx.options.mode === "frames" ? 1 : ctx.effectiveFps ?? ctx.options.fps,
|
|
610
|
-
resolution: {
|
|
611
|
-
width,
|
|
612
|
-
height
|
|
613
|
-
}
|
|
614
|
-
},
|
|
615
|
-
animations: (ctx.animations ?? []).map((animation) => {
|
|
616
|
-
const box = animation.boundingBox;
|
|
617
|
-
const x = Math.max(0, Math.min(width, Math.round(box.x * sx)));
|
|
618
|
-
const y = Math.max(0, Math.min(height, Math.round(box.y * sy)));
|
|
619
|
-
return {
|
|
620
|
-
...animation,
|
|
621
|
-
boundingBox: {
|
|
622
|
-
x,
|
|
623
|
-
y,
|
|
624
|
-
width: Math.max(0, Math.min(width - x, Math.round(box.width * sx))),
|
|
625
|
-
height: Math.max(0, Math.min(height - y, Math.round(box.height * sy)))
|
|
626
|
-
}
|
|
627
|
-
};
|
|
628
|
-
})
|
|
629
|
-
};
|
|
630
|
-
}
|
|
602
|
+
//#region src/constants/package-version.ts
|
|
603
|
+
/** Source constants are two levels below the manifest; runtime bundles are one level below. */
|
|
604
|
+
const manifestPath = existsSync(new URL("../package.json", import.meta.url)) ? "../package.json" : "../../package.json";
|
|
605
|
+
/** Runtime manifest version shared by CLI responses and metadata documents. */
|
|
606
|
+
const PACKAGE_VERSION = createRequire(import.meta.url)(manifestPath).version;
|
|
631
607
|
|
|
632
608
|
//#endregion
|
|
633
609
|
//#region src/core/constants/workspace-layout.ts
|
|
@@ -678,7 +654,564 @@ function resolveAbsolute(p) {
|
|
|
678
654
|
* e.g., /path/to/video.mp4 -> /path/to/video_scenes
|
|
679
655
|
*/
|
|
680
656
|
function deriveOutputPath(inputPath) {
|
|
681
|
-
|
|
657
|
+
const dir = resolve(inputPath, "..");
|
|
658
|
+
const name = basename(inputPath, extname(inputPath));
|
|
659
|
+
return resolve(dir, `${name}_scenes`);
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
//#endregion
|
|
663
|
+
//#region src/core/workspace/workspace.ts
|
|
664
|
+
async function createWorkspace(sessionId) {
|
|
665
|
+
const workspacePath = getTempWorkspaceDir(sessionId);
|
|
666
|
+
await ensureDir(join(workspacePath, "frames"));
|
|
667
|
+
await ensureDir(join(workspacePath, "output"));
|
|
668
|
+
return workspacePath;
|
|
669
|
+
}
|
|
670
|
+
/**
|
|
671
|
+
* Write selected JPEGs and the supplied document before replacing the output directory.
|
|
672
|
+
* @param ctx - Workspace, quality and destination settings.
|
|
673
|
+
* @param selectedFrames - Frames paired by position with document.frames.
|
|
674
|
+
* @param document - Complete metadata, including the output file names.
|
|
675
|
+
* @param sheetBuffer - Optional JPEG contact sheet bytes to persist unchanged.
|
|
676
|
+
* @returns Selected JPEG paths, optional sheet path and finally the metadata path.
|
|
677
|
+
* @throws Rejects mismatched frame counts and propagates image or filesystem errors.
|
|
678
|
+
*/
|
|
679
|
+
async function finalizeOutput(ctx, selectedFrames, document, sheetBuffer) {
|
|
680
|
+
if (document.frames.length !== selectedFrames.length) throw new Error("metadata frame count must match selected frame count");
|
|
681
|
+
const stagingDir = join(ctx.workspacePath, "output");
|
|
682
|
+
const outputPath = ctx.options.outputPath;
|
|
683
|
+
const quality = ctx.options.quality;
|
|
684
|
+
const outputFiles = [];
|
|
685
|
+
for (let i = 0; i < selectedFrames.length; i++) {
|
|
686
|
+
const frame = selectedFrames[i];
|
|
687
|
+
const fileName = document.frames[i].fileName;
|
|
688
|
+
const destPath = join(stagingDir, fileName);
|
|
689
|
+
await sharp(frame.extractPath).jpeg({
|
|
690
|
+
quality,
|
|
691
|
+
mozjpeg: true
|
|
692
|
+
}).toFile(destPath);
|
|
693
|
+
outputFiles.push(join(outputPath, fileName));
|
|
694
|
+
}
|
|
695
|
+
if (sheetBuffer) {
|
|
696
|
+
await writeFile(join(stagingDir, SHEET_FILE_NAME), sheetBuffer);
|
|
697
|
+
outputFiles.push(join(outputPath, SHEET_FILE_NAME));
|
|
698
|
+
}
|
|
699
|
+
const metadataPath = join(stagingDir, METADATA_FILE_NAME);
|
|
700
|
+
await writeFile(metadataPath, JSON.stringify(document, null, 2));
|
|
701
|
+
outputFiles.push(join(outputPath, METADATA_FILE_NAME));
|
|
702
|
+
await ensureDir(join(outputPath, ".."));
|
|
703
|
+
await rm(outputPath, {
|
|
704
|
+
recursive: true,
|
|
705
|
+
force: true
|
|
706
|
+
});
|
|
707
|
+
await rename(stagingDir, outputPath);
|
|
708
|
+
return outputFiles;
|
|
709
|
+
}
|
|
710
|
+
async function createSegmentWorkspace(parentWorkspacePath, segmentIndex) {
|
|
711
|
+
const segmentPath = join(parentWorkspacePath, "segments", String(segmentIndex));
|
|
712
|
+
await ensureDir(join(segmentPath, "frames"));
|
|
713
|
+
return segmentPath;
|
|
714
|
+
}
|
|
715
|
+
async function cleanupWorkspace(workspacePath) {
|
|
716
|
+
if (!workspacePath) return;
|
|
717
|
+
try {
|
|
718
|
+
await rm(workspacePath, {
|
|
719
|
+
recursive: true,
|
|
720
|
+
force: true
|
|
721
|
+
});
|
|
722
|
+
} catch {}
|
|
723
|
+
}
|
|
724
|
+
/**
|
|
725
|
+
* Write a video buffer to a temp file in the workspace and return the path.
|
|
726
|
+
* Used by 'buffer' input mode.
|
|
727
|
+
*/
|
|
728
|
+
async function writeInputBuffer(buffer, workspacePath) {
|
|
729
|
+
const inputDir = join(workspacePath, "input");
|
|
730
|
+
await ensureDir(inputDir);
|
|
731
|
+
const tempPath = join(inputDir, "input.mp4");
|
|
732
|
+
await writeFile(tempPath, buffer);
|
|
733
|
+
return tempPath;
|
|
734
|
+
}
|
|
735
|
+
/**
|
|
736
|
+
* Write an array of frame Buffers as JPG files and return FrameNode[].
|
|
737
|
+
* Used by 'frames' input mode.
|
|
738
|
+
*/
|
|
739
|
+
async function writeInputFrames(frames, workspacePath) {
|
|
740
|
+
const framesDir = join(workspacePath, "frames");
|
|
741
|
+
await ensureDir(framesDir);
|
|
742
|
+
const frameNodes = [];
|
|
743
|
+
for (let i = 0; i < frames.length; i++) {
|
|
744
|
+
const filename = `frame_${String(i).padStart(6, "0")}${FRAME_OUTPUT_EXTENSION}`;
|
|
745
|
+
const extractPath = join(framesDir, filename);
|
|
746
|
+
await writeFile(extractPath, frames[i]);
|
|
747
|
+
frameNodes.push({
|
|
748
|
+
id: i,
|
|
749
|
+
timestamp: i,
|
|
750
|
+
extractPath
|
|
751
|
+
});
|
|
752
|
+
}
|
|
753
|
+
return frameNodes;
|
|
754
|
+
}
|
|
755
|
+
/**
|
|
756
|
+
* Read selected FrameNode files as Buffers with JPEG compression.
|
|
757
|
+
* Used to return output buffers in 'buffer' and 'frames' modes.
|
|
758
|
+
*/
|
|
759
|
+
async function readFramesAsBuffers(frameNodes, quality) {
|
|
760
|
+
return Promise.all(map(frameNodes, (f) => sharp(f.extractPath).jpeg({
|
|
761
|
+
quality,
|
|
762
|
+
mozjpeg: true
|
|
763
|
+
}).toBuffer()));
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
//#endregion
|
|
767
|
+
//#region src/core/utils/metadata/change/union-area/y-coverage-tree.ts
|
|
768
|
+
/** Internal sweep helper retaining cover counts and lengths between sorted y bounds. */
|
|
769
|
+
var YCoverageTree = class {
|
|
770
|
+
bounds;
|
|
771
|
+
/** Number of whole-node covering intervals, independent of descendants. */
|
|
772
|
+
counts;
|
|
773
|
+
/** Covered geometric length for each node, including partially covered children. */
|
|
774
|
+
lengths;
|
|
775
|
+
/**
|
|
776
|
+
* Allocate linear storage for the elementary intervals between coordinates.
|
|
777
|
+
* @param bounds - At least two sorted unique finite y endpoints.
|
|
778
|
+
*/
|
|
779
|
+
constructor(bounds) {
|
|
780
|
+
this.bounds = bounds;
|
|
781
|
+
this.counts = Array(4 * bounds.length).fill(0);
|
|
782
|
+
this.lengths = Array(4 * bounds.length).fill(0);
|
|
783
|
+
}
|
|
784
|
+
/** Total active covered y length, in the input coordinate system. */
|
|
785
|
+
get coveredLength() {
|
|
786
|
+
return this.lengths[1];
|
|
787
|
+
}
|
|
788
|
+
/**
|
|
789
|
+
* Adjust a nonempty half-open interval and refresh its ancestors' lengths.
|
|
790
|
+
* @param start - Inclusive endpoint index; 0 <= start < end.
|
|
791
|
+
* @param end - Exclusive endpoint index; end < bounds.length.
|
|
792
|
+
* @param delta - One on entry, minus one for the matching departure.
|
|
793
|
+
* @param node - Internal tree slot; callers use the root default.
|
|
794
|
+
* @param left - Inclusive endpoint index of this node.
|
|
795
|
+
* @param right - Exclusive endpoint index of this node.
|
|
796
|
+
* @returns Nothing; mutates this tree's coverage in O(log n).
|
|
797
|
+
*/
|
|
798
|
+
update(start, end, delta, node = 1, left = 0, right = this.bounds.length - 1) {
|
|
799
|
+
if (start <= left && right <= end) this.counts[node] += delta;
|
|
800
|
+
else {
|
|
801
|
+
const middle = Math.floor((left + right) / 2);
|
|
802
|
+
if (start < middle) this.update(start, end, delta, node * 2, left, middle);
|
|
803
|
+
if (end > middle) this.update(start, end, delta, node * 2 + 1, middle, right);
|
|
804
|
+
}
|
|
805
|
+
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];
|
|
806
|
+
}
|
|
807
|
+
};
|
|
808
|
+
|
|
809
|
+
//#endregion
|
|
810
|
+
//#region src/core/utils/metadata/change/union-area.ts
|
|
811
|
+
/**
|
|
812
|
+
* Measure rectangle union with an x sweep in O(n log n) time and O(n) space.
|
|
813
|
+
* @param boxes - Finite rectangles; nonpositive dimensions are ignored.
|
|
814
|
+
* @returns Covered area in the input coordinate system, or zero for empty input.
|
|
815
|
+
*/
|
|
816
|
+
function unionArea(boxes) {
|
|
817
|
+
const events = [];
|
|
818
|
+
const endpoints = [];
|
|
819
|
+
for (const box of boxes) {
|
|
820
|
+
if (!(box.width > 0 && box.height > 0)) continue;
|
|
821
|
+
const end = box.y + box.height;
|
|
822
|
+
if (end === box.y) continue;
|
|
823
|
+
events.push({
|
|
824
|
+
x: box.x,
|
|
825
|
+
start: box.y,
|
|
826
|
+
end,
|
|
827
|
+
delta: 1
|
|
828
|
+
});
|
|
829
|
+
events.push({
|
|
830
|
+
x: box.x + box.width,
|
|
831
|
+
start: box.y,
|
|
832
|
+
end,
|
|
833
|
+
delta: -1
|
|
834
|
+
});
|
|
835
|
+
endpoints.push(box.y, end);
|
|
836
|
+
}
|
|
837
|
+
if (events.length === 0) return 0;
|
|
838
|
+
events.sort((a, b) => a.x - b.x);
|
|
839
|
+
endpoints.sort((a, b) => a - b);
|
|
840
|
+
const bounds = endpoints.filter((value, index) => index === 0 || value !== endpoints[index - 1]);
|
|
841
|
+
const indices = new Map(bounds.map((value, index) => [value, index]));
|
|
842
|
+
const coverage = new YCoverageTree(bounds);
|
|
843
|
+
let area = 0;
|
|
844
|
+
let previousX = events[0].x;
|
|
845
|
+
for (const event of events) {
|
|
846
|
+
area += (event.x - previousX) * coverage.coveredLength;
|
|
847
|
+
coverage.update(indices.get(event.start), indices.get(event.end), event.delta);
|
|
848
|
+
previousX = event.x;
|
|
849
|
+
}
|
|
850
|
+
return area;
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
//#endregion
|
|
854
|
+
//#region src/core/utils/metadata/build-edge-metadata.ts
|
|
855
|
+
/**
|
|
856
|
+
* Serialize raw candidate edges in graph order.
|
|
857
|
+
* @param graph - Adjacent-pair edges, optionally carrying tracker partitions.
|
|
858
|
+
* @param analysisResolution - Analysis dimensions used for both area fractions.
|
|
859
|
+
* @returns One-based IDs with six-decimal scores and four-decimal clamped ratios.
|
|
860
|
+
*/
|
|
861
|
+
function buildEdgeMetadata(graph, analysisResolution) {
|
|
862
|
+
const area = analysisResolution.width * analysisResolution.height;
|
|
863
|
+
return map(graph, (edge) => ({
|
|
864
|
+
sourceFrameId: edge.sourceId + 1,
|
|
865
|
+
targetFrameId: edge.targetId + 1,
|
|
866
|
+
score: Math.round(edge.score * 1e6) / 1e6,
|
|
867
|
+
areaRatio: area > 0 ? Math.round(Math.max(0, Math.min(1, unionArea(edge.change?.regions ?? []) / area)) * 1e4) / 1e4 : 0,
|
|
868
|
+
animatedAreaRatio: area > 0 ? Math.round(Math.max(0, Math.min(1, unionArea(edge.change?.animatedRegions ?? []) / area)) * 1e4) / 1e4 : 0
|
|
869
|
+
}));
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
//#endregion
|
|
873
|
+
//#region src/core/utils/metadata/scale-bounding-box.ts
|
|
874
|
+
/**
|
|
875
|
+
* Convert an analysis box to clamped integer output pixels.
|
|
876
|
+
* @param box - Analysis-space rectangle; the input remains unchanged.
|
|
877
|
+
* @param sx - Horizontal output-to-analysis scale.
|
|
878
|
+
* @param sy - Vertical output-to-analysis scale.
|
|
879
|
+
* @param width - Nonnegative output image width.
|
|
880
|
+
* @param height - Nonnegative output image height.
|
|
881
|
+
* @returns A rectangle contained within the output dimensions.
|
|
882
|
+
*/
|
|
883
|
+
function scaleBoundingBox(box, sx, sy, width, height) {
|
|
884
|
+
const x = Math.max(0, Math.min(width, Math.round(box.x * sx)));
|
|
885
|
+
const y = Math.max(0, Math.min(height, Math.round(box.y * sy)));
|
|
886
|
+
return {
|
|
887
|
+
x,
|
|
888
|
+
y,
|
|
889
|
+
width: Math.max(0, Math.min(width - x, Math.round(box.width * sx))),
|
|
890
|
+
height: Math.max(0, Math.min(height - y, Math.round(box.height * sy)))
|
|
891
|
+
};
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
//#endregion
|
|
895
|
+
//#region src/core/utils/metadata/change/select-regions.ts
|
|
896
|
+
/**
|
|
897
|
+
* Select distinct positive-area boxes with deterministic area and coordinate ties.
|
|
898
|
+
* @param boxes - Already scaled output rectangles; the input is not mutated.
|
|
899
|
+
* @param limit - Nonnegative maximum number of regions.
|
|
900
|
+
* @returns Largest boxes ordered by area descending, then y, x and width ascending.
|
|
901
|
+
*/
|
|
902
|
+
function selectRegions(boxes, limit) {
|
|
903
|
+
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);
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
//#endregion
|
|
907
|
+
//#region src/core/utils/metadata/change/build-frame-change.ts
|
|
908
|
+
/**
|
|
909
|
+
* Aggregate adjacent-pair evidence across one selected-frame span.
|
|
910
|
+
* @param input - Ordered raw edges, one-based previous ID, skipped count and dimensions.
|
|
911
|
+
* @returns Rounded raw scores, analysis-space union ratio and output-space regions.
|
|
912
|
+
*/
|
|
913
|
+
function buildFrameChange(input) {
|
|
914
|
+
const { spanEdges, fromFrameId, skippedCandidates, analysisResolution, outputResolution, regionLimit } = input;
|
|
915
|
+
const boxes = spanEdges.flatMap((edge) => edge.change?.regions ?? []);
|
|
916
|
+
const area = analysisResolution.width * analysisResolution.height;
|
|
917
|
+
const peakScore = spanEdges.reduce((peak, edge) => Math.max(peak, edge.score), 0);
|
|
918
|
+
const sumScore = spanEdges.reduce((sum, edge) => sum + edge.score, 0);
|
|
919
|
+
const areaRatio = area > 0 ? Math.max(0, Math.min(1, unionArea(boxes) / area)) : 0;
|
|
920
|
+
const regions = area > 0 ? selectRegions(map(boxes, (box) => scaleBoundingBox(box, outputResolution.width / analysisResolution.width, outputResolution.height / analysisResolution.height, outputResolution.width, outputResolution.height)), regionLimit) : [];
|
|
921
|
+
return {
|
|
922
|
+
fromFrameId,
|
|
923
|
+
skippedCandidates,
|
|
924
|
+
peakScore: Math.round(peakScore * 1e6) / 1e6,
|
|
925
|
+
sumScore: Math.round(sumScore * 1e6) / 1e6,
|
|
926
|
+
areaRatio: Math.round(areaRatio * 1e4) / 1e4,
|
|
927
|
+
regions
|
|
928
|
+
};
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
//#endregion
|
|
932
|
+
//#region src/core/utils/metadata/build-frame-metadata.ts
|
|
933
|
+
/**
|
|
934
|
+
* Describe selected frames using candidate adjacency rather than synthetic pruning edges.
|
|
935
|
+
* @param input - Chronological candidates and selections, raw graph and output dimensions.
|
|
936
|
+
* @returns One-based frame summaries with rounded timestamps and nonnegative holds.
|
|
937
|
+
*/
|
|
938
|
+
function buildFrameMetadata(input) {
|
|
939
|
+
const { frames, graph, selected, originalDurationMs, analysisResolution, outputResolution } = input;
|
|
940
|
+
const edgesByPair = new Map(map(graph, (edge) => [`${edge.sourceId}:${edge.targetId}`, edge]));
|
|
941
|
+
const candidateIndices = new Map(map(frames, (frame, index) => [frame.id, index]));
|
|
942
|
+
const padding = Math.max(4, String(frames.length).length);
|
|
943
|
+
return map(selected, (frame, index) => {
|
|
944
|
+
const timestampMs = Math.round(frame.timestamp * 1e3);
|
|
945
|
+
const nextTimestampMs = index + 1 < selected.length ? Math.round(selected[index + 1].timestamp * 1e3) : originalDurationMs;
|
|
946
|
+
const previous = selected[index - 1];
|
|
947
|
+
const previousIndex = previous ? candidateIndices.get(previous.id) : 0;
|
|
948
|
+
const currentIndex = candidateIndices.get(frame.id);
|
|
949
|
+
const spanEdges = [];
|
|
950
|
+
if (previous) for (let i = previousIndex; i < currentIndex; i++) {
|
|
951
|
+
const edge = edgesByPair.get(`${frames[i].id}:${frames[i + 1].id}`);
|
|
952
|
+
if (edge) spanEdges.push(edge);
|
|
953
|
+
}
|
|
954
|
+
return {
|
|
955
|
+
step: index + 1,
|
|
956
|
+
fileName: `frame_${String(frame.id + 1).padStart(padding, "0")}.jpg`,
|
|
957
|
+
frameId: frame.id + 1,
|
|
958
|
+
timestampMs,
|
|
959
|
+
holdsMs: Math.max(0, nextTimestampMs - timestampMs),
|
|
960
|
+
change: previous ? buildFrameChange({
|
|
961
|
+
spanEdges,
|
|
962
|
+
fromFrameId: previous.id + 1,
|
|
963
|
+
skippedCandidates: currentIndex - previousIndex - 1,
|
|
964
|
+
analysisResolution,
|
|
965
|
+
outputResolution,
|
|
966
|
+
regionLimit: 5
|
|
967
|
+
}) : null
|
|
968
|
+
};
|
|
969
|
+
});
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
//#endregion
|
|
973
|
+
//#region src/core/utils/metadata/build-tool-metadata.ts
|
|
974
|
+
/**
|
|
975
|
+
* Record the tool and the nine selection and encoding settings in contract order.
|
|
976
|
+
* @param options - Validated pipeline settings; operational options are excluded.
|
|
977
|
+
* @param version - Runtime package version supplied by the I/O boundary.
|
|
978
|
+
* @returns Deterministic tool provenance without paths or execution details.
|
|
979
|
+
*/
|
|
980
|
+
function buildToolMetadata(options, version) {
|
|
981
|
+
return {
|
|
982
|
+
name: "@lumy-pack/scene-sieve",
|
|
983
|
+
version,
|
|
984
|
+
params: {
|
|
985
|
+
fps: options.fps,
|
|
986
|
+
count: options.count,
|
|
987
|
+
threshold: options.threshold,
|
|
988
|
+
scale: options.scale,
|
|
989
|
+
quality: options.quality,
|
|
990
|
+
maxFrames: options.maxFrames,
|
|
991
|
+
iouThreshold: options.iouThreshold,
|
|
992
|
+
animationThreshold: options.animationThreshold,
|
|
993
|
+
maxSegmentDuration: options.maxSegmentDuration
|
|
994
|
+
}
|
|
995
|
+
};
|
|
996
|
+
}
|
|
997
|
+
|
|
998
|
+
//#endregion
|
|
999
|
+
//#region src/core/utils/metadata/build-sieve-metadata.ts
|
|
1000
|
+
/**
|
|
1001
|
+
* Assemble the complete v2 document without I/O or mutation.
|
|
1002
|
+
* @param input - Pipeline state, selections, output-space video and zero-based animations.
|
|
1003
|
+
* @returns Contract-ordered metadata, omitting unrequested optional keys entirely.
|
|
1004
|
+
*/
|
|
1005
|
+
function buildSieveMetadata(input) {
|
|
1006
|
+
const { ctx, selected, video, animations, version, sheet } = input;
|
|
1007
|
+
const analysisResolution = ctx.analysisResolution ?? {
|
|
1008
|
+
width: 0,
|
|
1009
|
+
height: 0
|
|
1010
|
+
};
|
|
1011
|
+
return {
|
|
1012
|
+
metadataVersion: 2,
|
|
1013
|
+
tool: buildToolMetadata(ctx.options, version),
|
|
1014
|
+
video,
|
|
1015
|
+
frames: buildFrameMetadata({
|
|
1016
|
+
frames: ctx.frames,
|
|
1017
|
+
graph: ctx.graph,
|
|
1018
|
+
selected,
|
|
1019
|
+
originalDurationMs: video.originalDurationMs,
|
|
1020
|
+
analysisResolution,
|
|
1021
|
+
outputResolution: video.resolution
|
|
1022
|
+
}),
|
|
1023
|
+
animations: map(animations, (animation) => ({
|
|
1024
|
+
...animation,
|
|
1025
|
+
startFrameId: animation.startFrameId + 1,
|
|
1026
|
+
endFrameId: animation.endFrameId + 1,
|
|
1027
|
+
durationMs: Math.round(animation.durationMs)
|
|
1028
|
+
})),
|
|
1029
|
+
...sheet ? { sheet } : {},
|
|
1030
|
+
...ctx.options.includeEdges ? { edges: buildEdgeMetadata(ctx.graph, analysisResolution) } : {}
|
|
1031
|
+
};
|
|
1032
|
+
}
|
|
1033
|
+
|
|
1034
|
+
//#endregion
|
|
1035
|
+
//#region src/core/utils/metadata/build-video-metadata.ts
|
|
1036
|
+
/**
|
|
1037
|
+
* Read output dimensions and build consistent video and animation metadata.
|
|
1038
|
+
* JPEG finalization does not resize, so the source dimensions match the output.
|
|
1039
|
+
* @param ctx Pipeline state with source duration, effective FPS and analysis-space animations.
|
|
1040
|
+
* @param selected Selected frames in output order; the first candidate is the fallback.
|
|
1041
|
+
* @param analysisResolution Analysis dimensions; absent or zero dimensions imply no scaling.
|
|
1042
|
+
* @returns Video metadata and new output-space animations, retaining zero-based frame IDs.
|
|
1043
|
+
* @throws If sharp cannot read the selected or fallback image. Empty input performs no image I/O.
|
|
1044
|
+
*/
|
|
1045
|
+
async function buildVideoMetadata(ctx, selected, analysisResolution) {
|
|
1046
|
+
const firstFrame = selected[0] ?? ctx.frames[0];
|
|
1047
|
+
const dimensions = firstFrame ? await sharp(firstFrame.extractPath).metadata() : void 0;
|
|
1048
|
+
const width = dimensions?.width ?? 0;
|
|
1049
|
+
const height = dimensions?.height ?? 0;
|
|
1050
|
+
const sx = analysisResolution?.width ? width / analysisResolution.width : 1;
|
|
1051
|
+
const sy = analysisResolution?.height ? height / analysisResolution.height : 1;
|
|
1052
|
+
const lastTimestamp = ctx.frames[ctx.frames.length - 1]?.timestamp ?? 0;
|
|
1053
|
+
const duration = ctx.options.mode === "frames" ? lastTimestamp : ctx.sourceDurationSec ?? lastTimestamp;
|
|
1054
|
+
return {
|
|
1055
|
+
video: {
|
|
1056
|
+
originalDurationMs: Math.round(duration * 1e3),
|
|
1057
|
+
fps: ctx.options.mode === "frames" ? 1 : ctx.effectiveFps ?? ctx.options.fps,
|
|
1058
|
+
resolution: {
|
|
1059
|
+
width,
|
|
1060
|
+
height
|
|
1061
|
+
},
|
|
1062
|
+
candidatesCount: ctx.frames.length,
|
|
1063
|
+
selectedCount: selected.length,
|
|
1064
|
+
source: {
|
|
1065
|
+
fileName: ctx.options.mode === "file" && ctx.options.inputPath ? basename(ctx.options.inputPath) : null,
|
|
1066
|
+
mode: ctx.options.mode
|
|
1067
|
+
}
|
|
1068
|
+
},
|
|
1069
|
+
animations: (ctx.animations ?? []).map((animation) => ({
|
|
1070
|
+
...animation,
|
|
1071
|
+
boundingBox: scaleBoundingBox(animation.boundingBox, sx, sy, width, height)
|
|
1072
|
+
}))
|
|
1073
|
+
};
|
|
1074
|
+
}
|
|
1075
|
+
|
|
1076
|
+
//#endregion
|
|
1077
|
+
//#region src/core/utils/sheet/build-tile-label-svg.ts
|
|
1078
|
+
/**
|
|
1079
|
+
* Build a tile-sized SVG overlay with a translucent badge and an explicit text baseline.
|
|
1080
|
+
* @param text - A formatTileLabel result containing only digits, #, spaces, colons and periods.
|
|
1081
|
+
* @param tileWidth - Positive tile canvas width in pixels.
|
|
1082
|
+
* @param tileHeight - Positive tile canvas height in pixels.
|
|
1083
|
+
* @returns Encoded SVG bytes for a top-left sharp composite overlay.
|
|
1084
|
+
*/
|
|
1085
|
+
function buildTileLabelSvg(text, tileWidth, tileHeight) {
|
|
1086
|
+
const fontSize = Math.max(8, Math.round(tileHeight * SHEET_LABEL_HEIGHT_RATIO));
|
|
1087
|
+
const padX = Math.round(fontSize * .4);
|
|
1088
|
+
const padY = Math.round(fontSize * .2);
|
|
1089
|
+
const badgeWidth = Math.min(tileWidth, Math.ceil(text.length * fontSize * .6) + 2 * padX);
|
|
1090
|
+
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>`);
|
|
1091
|
+
}
|
|
1092
|
+
|
|
1093
|
+
//#endregion
|
|
1094
|
+
//#region src/core/utils/sheet/format-tile-label.ts
|
|
1095
|
+
/**
|
|
1096
|
+
* Format a contact sheet label without rounding into the next tenth of a second.
|
|
1097
|
+
* @param frameId - One-based candidate frame ID.
|
|
1098
|
+
* @param timestampMs - Nonnegative candidate time in milliseconds.
|
|
1099
|
+
* @returns A label in the form "#<id> mm:ss.s", allowing minutes beyond two digits.
|
|
1100
|
+
*/
|
|
1101
|
+
function formatTileLabel(frameId, timestampMs) {
|
|
1102
|
+
const tenths = Math.floor(timestampMs / 100);
|
|
1103
|
+
return `#${frameId} ${String(Math.floor(tenths / 600)).padStart(2, "0")}:${String(Math.floor(tenths / 10) % 60).padStart(2, "0")}.${tenths % 10}`;
|
|
1104
|
+
}
|
|
1105
|
+
|
|
1106
|
+
//#endregion
|
|
1107
|
+
//#region src/core/utils/sheet/sample-tile-frames.ts
|
|
1108
|
+
/**
|
|
1109
|
+
* Sample a sequence uniformly while always retaining both endpoints.
|
|
1110
|
+
* @param items - Items in temporal order.
|
|
1111
|
+
* @param maxTiles - Validated integer limit of at least two.
|
|
1112
|
+
* @returns The original sequence when within the limit, otherwise evenly sampled items.
|
|
1113
|
+
*/
|
|
1114
|
+
function sampleTileFrames(items, maxTiles) {
|
|
1115
|
+
if (items.length <= maxTiles) return {
|
|
1116
|
+
items,
|
|
1117
|
+
sampled: false
|
|
1118
|
+
};
|
|
1119
|
+
return {
|
|
1120
|
+
items: Array.from({ length: maxTiles }, (_, i) => items[Math.round(i * (items.length - 1) / (maxTiles - 1))]),
|
|
1121
|
+
sampled: true
|
|
1122
|
+
};
|
|
1123
|
+
}
|
|
1124
|
+
|
|
1125
|
+
//#endregion
|
|
1126
|
+
//#region src/core/utils/sheet/render-contact-sheet.ts
|
|
1127
|
+
/**
|
|
1128
|
+
* Read selected frame images and render a row-major contact sheet.
|
|
1129
|
+
* @param input - Nonempty ordered selections, positive dimensions and validated sheet settings.
|
|
1130
|
+
* @returns JPEG bytes and the effective tile layout with one-based frame IDs.
|
|
1131
|
+
* @throws Propagates sharp image reading, compositing or encoding errors.
|
|
1132
|
+
*/
|
|
1133
|
+
async function renderContactSheet(input) {
|
|
1134
|
+
const { selected, resolution, options, quality } = input;
|
|
1135
|
+
const { items, sampled } = sampleTileFrames(selected, options.maxTiles);
|
|
1136
|
+
const columns = Math.min(options.columns, items.length);
|
|
1137
|
+
const rows = Math.ceil(items.length / columns);
|
|
1138
|
+
const tileWidth = options.tileWidth;
|
|
1139
|
+
const tileHeight = Math.max(1, Math.round(tileWidth * resolution.height / resolution.width));
|
|
1140
|
+
const gap = 4;
|
|
1141
|
+
const tiles = await Promise.all(map(items, async (frame, index) => {
|
|
1142
|
+
const tile = sharp(frame.extractPath).resize(tileWidth, tileHeight, { fit: "fill" });
|
|
1143
|
+
if (options.label) {
|
|
1144
|
+
const text = formatTileLabel(frame.id + 1, Math.round(frame.timestamp * 1e3));
|
|
1145
|
+
tile.composite([{
|
|
1146
|
+
input: buildTileLabelSvg(text, tileWidth, tileHeight),
|
|
1147
|
+
top: 0,
|
|
1148
|
+
left: 0
|
|
1149
|
+
}]);
|
|
1150
|
+
}
|
|
1151
|
+
return {
|
|
1152
|
+
input: await tile.png().toBuffer(),
|
|
1153
|
+
top: gap + Math.floor(index / columns) * (tileHeight + gap),
|
|
1154
|
+
left: gap + index % columns * (tileWidth + gap)
|
|
1155
|
+
};
|
|
1156
|
+
}));
|
|
1157
|
+
return {
|
|
1158
|
+
buffer: await sharp({ create: {
|
|
1159
|
+
width: columns * tileWidth + (columns + 1) * gap,
|
|
1160
|
+
height: rows * tileHeight + (rows + 1) * gap,
|
|
1161
|
+
channels: 3,
|
|
1162
|
+
background: "white"
|
|
1163
|
+
} }).composite(tiles).jpeg({
|
|
1164
|
+
quality,
|
|
1165
|
+
mozjpeg: true
|
|
1166
|
+
}).toBuffer(),
|
|
1167
|
+
metadata: {
|
|
1168
|
+
fileName: SHEET_FILE_NAME,
|
|
1169
|
+
columns,
|
|
1170
|
+
tileWidth,
|
|
1171
|
+
tileHeight,
|
|
1172
|
+
frameIds: map(items, (frame) => frame.id + 1),
|
|
1173
|
+
sampled
|
|
1174
|
+
}
|
|
1175
|
+
};
|
|
1176
|
+
}
|
|
1177
|
+
|
|
1178
|
+
//#endregion
|
|
1179
|
+
//#region src/core/utils/output/finalize-selection.ts
|
|
1180
|
+
/**
|
|
1181
|
+
* Read output dimensions once, render optional sheets and finalize the shared v2 document.
|
|
1182
|
+
* @param ctx - Pipeline state owned by the orchestrator; this function does not mutate it.
|
|
1183
|
+
* @param selected - Selected frames in temporal order.
|
|
1184
|
+
* @returns Mode-specific output, the document and zero-based API animations.
|
|
1185
|
+
* @throws Propagates image, rendering and output I/O errors to the orchestrator.
|
|
1186
|
+
*/
|
|
1187
|
+
async function finalizeSelection(ctx, selected) {
|
|
1188
|
+
const { video, animations } = await buildVideoMetadata(ctx, selected, ctx.analysisResolution);
|
|
1189
|
+
const sheet = ctx.options.sheet && selected.length > 0 && video.resolution.width > 0 && video.resolution.height > 0 ? await renderContactSheet({
|
|
1190
|
+
selected,
|
|
1191
|
+
resolution: video.resolution,
|
|
1192
|
+
options: ctx.options.sheet,
|
|
1193
|
+
quality: ctx.options.quality
|
|
1194
|
+
}) : void 0;
|
|
1195
|
+
const document = buildSieveMetadata({
|
|
1196
|
+
ctx,
|
|
1197
|
+
selected,
|
|
1198
|
+
video,
|
|
1199
|
+
animations,
|
|
1200
|
+
version: PACKAGE_VERSION,
|
|
1201
|
+
...sheet ? { sheet: sheet.metadata } : {}
|
|
1202
|
+
});
|
|
1203
|
+
if (ctx.options.mode === "file") return {
|
|
1204
|
+
outputFiles: await finalizeOutput(ctx, selected, document, sheet?.buffer),
|
|
1205
|
+
document,
|
|
1206
|
+
animations
|
|
1207
|
+
};
|
|
1208
|
+
return {
|
|
1209
|
+
outputFiles: [],
|
|
1210
|
+
outputBuffers: await readFramesAsBuffers(selected, ctx.options.quality),
|
|
1211
|
+
document,
|
|
1212
|
+
animations,
|
|
1213
|
+
...sheet ? { sheetBuffer: sheet.buffer } : {}
|
|
1214
|
+
};
|
|
682
1215
|
}
|
|
683
1216
|
|
|
684
1217
|
//#endregion
|
|
@@ -765,7 +1298,8 @@ async function getVideoMetadata(inputPath) {
|
|
|
765
1298
|
* @returns Zero-based candidates without a segment seek offset.
|
|
766
1299
|
*/
|
|
767
1300
|
async function buildFrameList(framesDir, effectiveFps) {
|
|
768
|
-
const
|
|
1301
|
+
const files = await readdir(framesDir);
|
|
1302
|
+
const jpgFiles = filter(files, (f) => f.endsWith(".jpg")).sort();
|
|
769
1303
|
if (jpgFiles.length === 0) return [];
|
|
770
1304
|
return map(jpgFiles, (file, index) => ({
|
|
771
1305
|
id: index,
|
|
@@ -901,114 +1435,19 @@ function validateOptions(options) {
|
|
|
901
1435
|
if (value === void 0) continue;
|
|
902
1436
|
if (!Number.isFinite(value) || integer && !Number.isInteger(value) || (exclusiveMin ? value <= min : value < min) || value > max) throw new Error(`${name} must be ${requirement}, received: ${value}`);
|
|
903
1437
|
}
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
const stagingDir = join(ctx.workspacePath, "output");
|
|
916
|
-
const outputPath = ctx.options.outputPath;
|
|
917
|
-
const quality = ctx.options.quality;
|
|
918
|
-
const outputFiles = [];
|
|
919
|
-
const framesMetadata = [];
|
|
920
|
-
const totalFramesCount = ctx.frames.length;
|
|
921
|
-
const padding = Math.max(4, String(totalFramesCount).length);
|
|
922
|
-
for (let i = 0; i < selectedFrames.length; i++) {
|
|
923
|
-
const frame = selectedFrames[i];
|
|
924
|
-
const fileName = `frame_${String(frame.id + 1).padStart(padding, "0")}.jpg`;
|
|
925
|
-
const destPath = join(stagingDir, fileName);
|
|
926
|
-
await sharp(frame.extractPath).jpeg({
|
|
927
|
-
quality,
|
|
928
|
-
mozjpeg: true
|
|
929
|
-
}).toFile(destPath);
|
|
930
|
-
outputFiles.push(join(outputPath, fileName));
|
|
931
|
-
framesMetadata.push({
|
|
932
|
-
step: i + 1,
|
|
933
|
-
fileName,
|
|
934
|
-
frameId: frame.id + 1,
|
|
935
|
-
timestampMs: Math.round(frame.timestamp * 1e3)
|
|
936
|
-
});
|
|
937
|
-
}
|
|
938
|
-
const { video, animations } = await buildVideoMetadata(ctx, selectedFrames, ctx.analysisResolution);
|
|
939
|
-
const metadata = {
|
|
940
|
-
video,
|
|
941
|
-
frames: framesMetadata,
|
|
942
|
-
animations: map(animations, (anim) => ({
|
|
943
|
-
...anim,
|
|
944
|
-
startFrameId: anim.startFrameId + 1,
|
|
945
|
-
endFrameId: anim.endFrameId + 1,
|
|
946
|
-
durationMs: Math.round(anim.durationMs)
|
|
947
|
-
}))
|
|
948
|
-
};
|
|
949
|
-
await writeFile(join(stagingDir, ".metadata.json"), JSON.stringify(metadata, null, 2));
|
|
950
|
-
outputFiles.push(join(outputPath, ".metadata.json"));
|
|
951
|
-
await ensureDir(join(outputPath, ".."));
|
|
952
|
-
await rm(outputPath, {
|
|
953
|
-
recursive: true,
|
|
954
|
-
force: true
|
|
955
|
-
});
|
|
956
|
-
await rename(stagingDir, outputPath);
|
|
957
|
-
return outputFiles;
|
|
958
|
-
}
|
|
959
|
-
async function createSegmentWorkspace(parentWorkspacePath, segmentIndex) {
|
|
960
|
-
const segmentPath = join(parentWorkspacePath, "segments", String(segmentIndex));
|
|
961
|
-
await ensureDir(join(segmentPath, "frames"));
|
|
962
|
-
return segmentPath;
|
|
963
|
-
}
|
|
964
|
-
async function cleanupWorkspace(workspacePath) {
|
|
965
|
-
if (!workspacePath) return;
|
|
966
|
-
try {
|
|
967
|
-
await rm(workspacePath, {
|
|
968
|
-
recursive: true,
|
|
969
|
-
force: true
|
|
970
|
-
});
|
|
971
|
-
} catch {}
|
|
972
|
-
}
|
|
973
|
-
/**
|
|
974
|
-
* Write a video buffer to a temp file in the workspace and return the path.
|
|
975
|
-
* Used by 'buffer' input mode.
|
|
976
|
-
*/
|
|
977
|
-
async function writeInputBuffer(buffer, workspacePath) {
|
|
978
|
-
const inputDir = join(workspacePath, "input");
|
|
979
|
-
await ensureDir(inputDir);
|
|
980
|
-
const tempPath = join(inputDir, "input.mp4");
|
|
981
|
-
await writeFile(tempPath, buffer);
|
|
982
|
-
return tempPath;
|
|
983
|
-
}
|
|
984
|
-
/**
|
|
985
|
-
* Write an array of frame Buffers as JPG files and return FrameNode[].
|
|
986
|
-
* Used by 'frames' input mode.
|
|
987
|
-
*/
|
|
988
|
-
async function writeInputFrames(frames, workspacePath) {
|
|
989
|
-
const framesDir = join(workspacePath, "frames");
|
|
990
|
-
await ensureDir(framesDir);
|
|
991
|
-
const frameNodes = [];
|
|
992
|
-
for (let i = 0; i < frames.length; i++) {
|
|
993
|
-
const extractPath = join(framesDir, `frame_${String(i).padStart(6, "0")}${FRAME_OUTPUT_EXTENSION}`);
|
|
994
|
-
await writeFile(extractPath, frames[i]);
|
|
995
|
-
frameNodes.push({
|
|
996
|
-
id: i,
|
|
997
|
-
timestamp: i,
|
|
998
|
-
extractPath
|
|
999
|
-
});
|
|
1438
|
+
const sheet = options.sheet;
|
|
1439
|
+
if (sheet === void 0 || typeof sheet === "boolean") return;
|
|
1440
|
+
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)}`);
|
|
1441
|
+
for (const [name, min] of [
|
|
1442
|
+
["columns", 1],
|
|
1443
|
+
["tileWidth", 16],
|
|
1444
|
+
["maxTiles", 2]
|
|
1445
|
+
]) {
|
|
1446
|
+
const value = sheet[name];
|
|
1447
|
+
if (value === void 0) continue;
|
|
1448
|
+
if (!Number.isInteger(value) || value < min) throw new Error(`sheet.${name} must be an integer >= ${min}, received: ${value}`);
|
|
1000
1449
|
}
|
|
1001
|
-
|
|
1002
|
-
}
|
|
1003
|
-
/**
|
|
1004
|
-
* Read selected FrameNode files as Buffers with JPEG compression.
|
|
1005
|
-
* Used to return output buffers in 'buffer' and 'frames' modes.
|
|
1006
|
-
*/
|
|
1007
|
-
async function readFramesAsBuffers(frameNodes, quality) {
|
|
1008
|
-
return Promise.all(map(frameNodes, (f) => sharp(f.extractPath).jpeg({
|
|
1009
|
-
quality,
|
|
1010
|
-
mozjpeg: true
|
|
1011
|
-
}).toBuffer()));
|
|
1450
|
+
if (sheet.label !== void 0 && typeof sheet.label !== "boolean") throw new Error(`sheet.label must be a boolean, received: ${sheet.label}`);
|
|
1012
1451
|
}
|
|
1013
1452
|
|
|
1014
1453
|
//#endregion
|
|
@@ -1025,12 +1464,14 @@ function resolveOptions(options) {
|
|
|
1025
1464
|
const inputPath = mode === "file" ? resolveAbsolute(options.inputPath) : void 0;
|
|
1026
1465
|
const outputPath = options.outputPath ?? (inputPath ? deriveOutputPath(inputPath) : join(process.cwd(), "scene-sieve-output"));
|
|
1027
1466
|
const threshold = options.threshold ?? .5;
|
|
1467
|
+
const pruneMode = "threshold-with-cap";
|
|
1468
|
+
const sheet = typeof options.sheet === "object" ? options.sheet : {};
|
|
1028
1469
|
return {
|
|
1029
1470
|
mode,
|
|
1030
1471
|
inputPath,
|
|
1031
1472
|
count: options.count ?? 20,
|
|
1032
1473
|
threshold,
|
|
1033
|
-
pruneMode
|
|
1474
|
+
pruneMode,
|
|
1034
1475
|
outputPath,
|
|
1035
1476
|
fps: options.fps ?? 5,
|
|
1036
1477
|
maxFrames: options.maxFrames ?? 300,
|
|
@@ -1040,7 +1481,14 @@ function resolveOptions(options) {
|
|
|
1040
1481
|
animationThreshold: options.animationThreshold ?? 5,
|
|
1041
1482
|
debug: options.debug ?? false,
|
|
1042
1483
|
maxSegmentDuration: options.maxSegmentDuration ?? 300,
|
|
1043
|
-
concurrency: options.concurrency ?? 2
|
|
1484
|
+
concurrency: options.concurrency ?? 2,
|
|
1485
|
+
sheet: options.sheet ? {
|
|
1486
|
+
columns: sheet.columns ?? 4,
|
|
1487
|
+
tileWidth: sheet.tileWidth ?? 320,
|
|
1488
|
+
maxTiles: sheet.maxTiles ?? 40,
|
|
1489
|
+
label: sheet.label ?? true
|
|
1490
|
+
} : null,
|
|
1491
|
+
includeEdges: options.includeEdges ?? false
|
|
1044
1492
|
};
|
|
1045
1493
|
}
|
|
1046
1494
|
/**
|
|
@@ -1145,7 +1593,7 @@ function normalizeScores(items) {
|
|
|
1145
1593
|
if (s <= 0) return 0;
|
|
1146
1594
|
return lowerBound(sorted, s) / sorted.length;
|
|
1147
1595
|
});
|
|
1148
|
-
return map(logisticZ, (z, i) => z *
|
|
1596
|
+
return map(logisticZ, (z, i) => z * .6 + cdf[i] * NORMALIZATION_ALPHA);
|
|
1149
1597
|
}
|
|
1150
1598
|
|
|
1151
1599
|
//#endregion
|
|
@@ -1554,16 +2002,16 @@ function remapEdges(segmentResults, globalIdMap) {
|
|
|
1554
2002
|
const existingIdx = edgeMap.get(edgeKey);
|
|
1555
2003
|
if (existingIdx !== void 0) {
|
|
1556
2004
|
if (edges[existingIdx].score < edge.score) edges[existingIdx] = {
|
|
2005
|
+
...edge,
|
|
1557
2006
|
sourceId: newSourceId,
|
|
1558
|
-
targetId: newTargetId
|
|
1559
|
-
score: edge.score
|
|
2007
|
+
targetId: newTargetId
|
|
1560
2008
|
};
|
|
1561
2009
|
} else {
|
|
1562
2010
|
edgeMap.set(edgeKey, edges.length);
|
|
1563
2011
|
edges.push({
|
|
2012
|
+
...edge,
|
|
1564
2013
|
sourceId: newSourceId,
|
|
1565
|
-
targetId: newTargetId
|
|
1566
|
-
score: edge.score
|
|
2014
|
+
targetId: newTargetId
|
|
1567
2015
|
});
|
|
1568
2016
|
}
|
|
1569
2017
|
}
|
|
@@ -1638,7 +2086,8 @@ function buildSegmentContext(segment, frames, segmentWorkspacePath, resolvedOpti
|
|
|
1638
2086
|
* Each segment uses an isolated workspace directory.
|
|
1639
2087
|
*/
|
|
1640
2088
|
async function processSegment(inputPath, segment, workspacePath, resolvedOptions, onProgress) {
|
|
1641
|
-
const
|
|
2089
|
+
const framesDir = join(workspacePath, "frames");
|
|
2090
|
+
const frames = await extractFramesForRange(inputPath, framesDir, segment.effectiveFps, resolvedOptions.scale, segment.extractStartTime, segment.extractDuration, segment.allocatedFrames);
|
|
1642
2091
|
if (frames.length < 2) return {
|
|
1643
2092
|
segment,
|
|
1644
2093
|
frames,
|
|
@@ -1649,7 +2098,8 @@ async function processSegment(inputPath, segment, workspacePath, resolvedOptions
|
|
|
1649
2098
|
height: 0
|
|
1650
2099
|
}
|
|
1651
2100
|
};
|
|
1652
|
-
const
|
|
2101
|
+
const ctx = buildSegmentContext(segment, frames, workspacePath, resolvedOptions, onProgress);
|
|
2102
|
+
const { edges, animations, analysisResolution } = await analyzeFrames(ctx);
|
|
1653
2103
|
return {
|
|
1654
2104
|
segment,
|
|
1655
2105
|
frames,
|
|
@@ -1714,20 +2164,20 @@ async function runSegmentedPipeline(options, resolvedOptions) {
|
|
|
1714
2164
|
status: "FINALIZING",
|
|
1715
2165
|
emitProgress: (percent) => options.onProgress?.("FINALIZING", percent)
|
|
1716
2166
|
};
|
|
1717
|
-
|
|
1718
|
-
let outputBuffers;
|
|
1719
|
-
if (resolvedOptions.mode === "buffer" || resolvedOptions.mode === "frames") outputBuffers = await readFramesAsBuffers(prunedFrames, resolvedOptions.quality);
|
|
1720
|
-
else outputFiles = await finalizeOutput(ctx, prunedFrames);
|
|
2167
|
+
const finalized = await finalizeSelection(ctx, prunedFrames);
|
|
1721
2168
|
options.onProgress?.("FINALIZING", 100);
|
|
1722
2169
|
logger.success(`Segmented pipeline: ${prunedFrames.length} scenes from ${frames.length} frames (${segments.length} segments)`);
|
|
1723
|
-
const outputMetadata = await buildVideoMetadata(ctx, prunedFrames, analysisResolution);
|
|
1724
2170
|
return {
|
|
1725
2171
|
success: true,
|
|
1726
2172
|
originalFramesCount: frames.length,
|
|
1727
2173
|
prunedFramesCount: prunedFrames.length,
|
|
1728
|
-
outputFiles,
|
|
1729
|
-
outputBuffers,
|
|
1730
|
-
|
|
2174
|
+
outputFiles: finalized.outputFiles,
|
|
2175
|
+
outputBuffers: finalized.outputBuffers,
|
|
2176
|
+
video: finalized.document.video,
|
|
2177
|
+
animations: finalized.animations,
|
|
2178
|
+
frames: finalized.document.frames,
|
|
2179
|
+
...finalized.document.sheet ? { sheet: finalized.document.sheet } : {},
|
|
2180
|
+
...finalized.sheetBuffer ? { sheetBuffer: finalized.sheetBuffer } : {},
|
|
1731
2181
|
executionTimeMs: Date.now() - pipelineStart
|
|
1732
2182
|
};
|
|
1733
2183
|
} catch (error) {
|
|
@@ -1742,6 +2192,12 @@ async function runSegmentedPipeline(options, resolvedOptions) {
|
|
|
1742
2192
|
|
|
1743
2193
|
//#endregion
|
|
1744
2194
|
//#region src/core/orchestrator/orchestrator.ts
|
|
2195
|
+
/**
|
|
2196
|
+
* Run the five pipeline stages, delegating segmented inputs to the segmenter.
|
|
2197
|
+
* @param options - Mode-specific input and optional pipeline settings.
|
|
2198
|
+
* @returns Selected outputs with v2 frame metadata and zero-based API animations.
|
|
2199
|
+
* @throws Propagates stage failures after cleaning the workspace unless debug is enabled.
|
|
2200
|
+
*/
|
|
1745
2201
|
async function runPipeline(options) {
|
|
1746
2202
|
setDebugMode(options.debug ?? false);
|
|
1747
2203
|
const resolvedOptions = resolveOptions(options);
|
|
@@ -1789,25 +2245,21 @@ async function runPipeline(options) {
|
|
|
1789
2245
|
const prunedFrames = filter(ctx.frames, (f) => survivingIds.has(f.id));
|
|
1790
2246
|
ctx.emitProgress(100);
|
|
1791
2247
|
ctx.status = "FINALIZING";
|
|
1792
|
-
|
|
1793
|
-
|
|
1794
|
-
if (resolvedOptions.mode === "buffer" || resolvedOptions.mode === "frames") {
|
|
1795
|
-
outputBuffers = await readFramesAsBuffers(prunedFrames, resolvedOptions.quality);
|
|
1796
|
-
ctx.emitProgress(100);
|
|
1797
|
-
} else {
|
|
1798
|
-
outputFiles = await finalizeOutput(ctx, prunedFrames);
|
|
1799
|
-
ctx.emitProgress(100);
|
|
1800
|
-
}
|
|
2248
|
+
const finalized = await finalizeSelection(ctx, prunedFrames);
|
|
2249
|
+
ctx.emitProgress(100);
|
|
1801
2250
|
ctx.status = "SUCCESS";
|
|
1802
2251
|
logger.success(`Extracted ${prunedFrames.length} scenes from ${ctx.frames.length} frames`);
|
|
1803
|
-
const metadata = await buildVideoMetadata(ctx, prunedFrames, analysisResolution);
|
|
1804
2252
|
return {
|
|
1805
2253
|
success: true,
|
|
1806
2254
|
originalFramesCount: ctx.frames.length,
|
|
1807
2255
|
prunedFramesCount: prunedFrames.length,
|
|
1808
|
-
outputFiles,
|
|
1809
|
-
outputBuffers,
|
|
1810
|
-
|
|
2256
|
+
outputFiles: finalized.outputFiles,
|
|
2257
|
+
outputBuffers: finalized.outputBuffers,
|
|
2258
|
+
video: finalized.document.video,
|
|
2259
|
+
animations: finalized.animations,
|
|
2260
|
+
frames: finalized.document.frames,
|
|
2261
|
+
...finalized.document.sheet ? { sheet: finalized.document.sheet } : {},
|
|
2262
|
+
...finalized.sheetBuffer ? { sheetBuffer: finalized.sheetBuffer } : {},
|
|
1811
2263
|
executionTimeMs: Date.now() - startTime
|
|
1812
2264
|
};
|
|
1813
2265
|
} catch (error) {
|