@lumy-pack/scene-sieve 0.1.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 +128 -42
- package/dist/{commands → cli/commands}/Sieve.d.ts +5 -0
- package/dist/{errors.d.ts → cli/errors/classify-error.d.ts} +5 -0
- package/dist/cli/index.d.ts +3 -0
- package/dist/{utils → cli/options}/parse-options.d.ts +11 -2
- package/dist/cli.mjs +1453 -505
- package/dist/constants/package-version.d.ts +2 -0
- package/dist/constants/pipeline-defaults.d.ts +33 -0
- package/dist/core/{analyzer.d.ts → analyzer/analyzer.d.ts} +11 -6
- package/dist/core/{dbscan.d.ts → analyzer/clustering/dbscan.d.ts} +1 -1
- package/dist/core/analyzer/constants/vision-tuning.d.ts +12 -0
- package/dist/core/analyzer/features/feature-diff.d.ts +14 -0
- package/dist/core/analyzer/features/frame-features.d.ts +32 -0
- package/dist/core/analyzer/index.d.ts +3 -0
- package/dist/core/constants/workspace-layout.d.ts +9 -0
- package/dist/core/{extractor.d.ts → extractor/extractor.d.ts} +6 -4
- package/dist/core/extractor/index.d.ts +1 -0
- package/dist/core/index.d.ts +9 -9
- package/dist/core/input-resolver/index.d.ts +1 -0
- package/dist/core/{input-resolver.d.ts → input-resolver/input-resolver.d.ts} +11 -1
- package/dist/core/input-resolver/validation/validate-options.d.ts +8 -0
- package/dist/core/orchestrator/index.d.ts +3 -0
- package/dist/core/orchestrator/orchestrator.d.ts +8 -0
- package/dist/core/{run-in-worker.d.ts → orchestrator/worker/run-in-worker.d.ts} +5 -1
- package/dist/core/pruner/index.d.ts +1 -0
- package/dist/core/{pruner.d.ts → pruner/pruner.d.ts} +2 -2
- package/dist/{utils/math.d.ts → core/pruner/scoring/normalize-scores.d.ts} +4 -0
- package/dist/core/segmenter/index.d.ts +1 -0
- package/dist/core/{segmenter.d.ts → segmenter/segmenter.d.ts} +11 -11
- 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/build-video-metadata.d.ts +14 -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/index.d.ts +1 -0
- package/dist/core/{workspace.d.ts → workspace/workspace.d.ts} +11 -2
- package/dist/index.cjs +1531 -559
- package/dist/index.d.ts +2 -2
- package/dist/index.mjs +1533 -558
- package/dist/pipeline-worker.mjs +1213 -404
- package/dist/types/index.d.ts +173 -0
- package/package.json +14 -11
- package/dist/constants.d.ts +0 -32
- package/dist/core/orchestrator.d.ts +0 -2
- /package/dist/{utils → cli/commands}/command-registry.d.ts +0 -0
- /package/dist/{components → cli/components}/PhaseStep.d.ts +0 -0
- /package/dist/{components → cli/components}/ProgressBar.d.ts +0 -0
- /package/dist/core/{pipeline-worker.d.ts → orchestrator/worker/pipeline-worker.d.ts} +0 -0
- /package/dist/{utils → core/pruner/heap}/min-heap.d.ts +0 -0
- /package/dist/{utils → core/segmenter/scheduling}/concurrency.d.ts +0 -0
- /package/dist/{utils → core/utils/filesystem}/paths.d.ts +0 -0
- /package/dist/{utils → logging}/logger.d.ts +0 -0
package/dist/index.mjs
CHANGED
|
@@ -3,65 +3,82 @@ import { randomUUID } from "node:crypto";
|
|
|
3
3
|
import { filter, map } from "@winglet/common-utils";
|
|
4
4
|
import pc from "picocolors";
|
|
5
5
|
import sharp from "sharp";
|
|
6
|
-
import {
|
|
7
|
-
import { basename, extname, join, resolve } from "node:path";
|
|
6
|
+
import { existsSync } from "node:fs";
|
|
8
7
|
import { mkdir, readdir, rename, rm, stat, writeFile } from "node:fs/promises";
|
|
8
|
+
import { basename, extname, join, resolve } from "node:path";
|
|
9
|
+
import { homedir, tmpdir } from "node:os";
|
|
9
10
|
import { path } from "@ffprobe-installer/ffprobe";
|
|
10
11
|
import { execa } from "execa";
|
|
11
12
|
import ffmpegPath from "ffmpeg-static";
|
|
12
13
|
|
|
13
|
-
//#region
|
|
14
|
-
|
|
15
|
-
|
|
14
|
+
//#region \0rolldown/runtime.js
|
|
15
|
+
var __esmMin = (fn, res, err) => () => {
|
|
16
|
+
if (err) throw err[0];
|
|
17
|
+
try {
|
|
18
|
+
return fn && (res = fn(fn = 0)), res;
|
|
19
|
+
} catch (e) {
|
|
20
|
+
throw err = [e], e;
|
|
21
|
+
}
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
//#endregion
|
|
25
|
+
//#region src/logging/logger.ts
|
|
16
26
|
function setDebugMode(enabled) {
|
|
17
27
|
debugMode = enabled;
|
|
18
28
|
}
|
|
19
29
|
function timestamp() {
|
|
20
30
|
return (/* @__PURE__ */ new Date()).toLocaleTimeString("en-US", { hour12: false });
|
|
21
31
|
}
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
32
|
+
var debugMode, jsonMode, logger;
|
|
33
|
+
var init_logger = __esmMin((() => {
|
|
34
|
+
debugMode = false;
|
|
35
|
+
jsonMode = false;
|
|
36
|
+
logger = {
|
|
37
|
+
info(message) {
|
|
38
|
+
if (jsonMode) process.stderr.write(`${pc.blue("info")} ${message}\n`);
|
|
39
|
+
else console.log(`${pc.blue("info")} ${message}`);
|
|
40
|
+
},
|
|
41
|
+
success(message) {
|
|
42
|
+
if (jsonMode) process.stderr.write(`${pc.green("done")} ${message}\n`);
|
|
43
|
+
else console.log(`\n${pc.green("done")} ${message}`);
|
|
44
|
+
},
|
|
45
|
+
warn(message) {
|
|
46
|
+
console.warn(`${pc.yellow("warn")} ${message}`);
|
|
47
|
+
},
|
|
48
|
+
error(message) {
|
|
49
|
+
console.error(`${pc.red("error")} ${message}`);
|
|
50
|
+
},
|
|
51
|
+
debug(message) {
|
|
52
|
+
if (debugMode) {
|
|
53
|
+
if (jsonMode) process.stderr.write(`${pc.gray(`[${timestamp()}] debug`)} ${message}\n`);
|
|
54
|
+
else console.log(`${pc.gray(`[${timestamp()}] debug`)} ${message}`);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
};
|
|
58
|
+
}));
|
|
42
59
|
|
|
43
60
|
//#endregion
|
|
44
|
-
//#region src/constants.ts
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
61
|
+
//#region src/constants/pipeline-defaults.ts
|
|
62
|
+
var DEFAULT_THRESHOLD, IOU_THRESHOLD, SHEET_LABEL_HEIGHT_RATIO, SHEET_FILE_NAME, METADATA_FILE_NAME;
|
|
63
|
+
var init_pipeline_defaults = __esmMin((() => {
|
|
64
|
+
DEFAULT_THRESHOLD = .5;
|
|
65
|
+
IOU_THRESHOLD = .9;
|
|
66
|
+
SHEET_LABEL_HEIGHT_RATIO = .07;
|
|
67
|
+
SHEET_FILE_NAME = "sheet.jpg";
|
|
68
|
+
METADATA_FILE_NAME = ".metadata.json";
|
|
69
|
+
}));
|
|
70
|
+
|
|
71
|
+
//#endregion
|
|
72
|
+
//#region src/core/analyzer/constants/vision-tuning.ts
|
|
73
|
+
var DBSCAN_ALPHA, DECAY_LAMBDA, MATCH_DISTANCE_THRESHOLD;
|
|
74
|
+
var init_vision_tuning = __esmMin((() => {
|
|
75
|
+
DBSCAN_ALPHA = .03;
|
|
76
|
+
DECAY_LAMBDA = .95;
|
|
77
|
+
MATCH_DISTANCE_THRESHOLD = .25;
|
|
78
|
+
}));
|
|
60
79
|
|
|
61
80
|
//#endregion
|
|
62
|
-
//#region src/core/dbscan.ts
|
|
63
|
-
const UNVISITED = -2;
|
|
64
|
-
const NOISE = -1;
|
|
81
|
+
//#region src/core/analyzer/clustering/dbscan.ts
|
|
65
82
|
/**
|
|
66
83
|
* DBSCAN clustering with resolution-independent eps.
|
|
67
84
|
* eps = alpha * sqrt(width^2 + height^2)
|
|
@@ -137,12 +154,118 @@ function findNeighbors(points, idx, epsSquared) {
|
|
|
137
154
|
}
|
|
138
155
|
return neighbors;
|
|
139
156
|
}
|
|
157
|
+
var UNVISITED, NOISE;
|
|
158
|
+
var init_dbscan = __esmMin((() => {
|
|
159
|
+
init_vision_tuning();
|
|
160
|
+
UNVISITED = -2;
|
|
161
|
+
NOISE = -1;
|
|
162
|
+
}));
|
|
163
|
+
|
|
164
|
+
//#endregion
|
|
165
|
+
//#region src/core/analyzer/features/feature-diff.ts
|
|
166
|
+
/**
|
|
167
|
+
* Match prev to next with Hamming k=2, crossCheck=false and strict ratio 0.25.
|
|
168
|
+
* @param cvLib - Initialized OpenCV runtime.
|
|
169
|
+
* @param prev - Previous frame's live features, owned by the caller.
|
|
170
|
+
* @param next - Next frame's live features, owned by the caller.
|
|
171
|
+
* @returns Unmatched next-frame coordinates without changing input ownership.
|
|
172
|
+
* @throws Propagates matching errors after releasing temporary native handles.
|
|
173
|
+
*/
|
|
174
|
+
function computeNewPoints(cvLib, prev, next) {
|
|
175
|
+
let matcher = null;
|
|
176
|
+
let matches = null;
|
|
177
|
+
try {
|
|
178
|
+
const matchedIndices = /* @__PURE__ */ new Set();
|
|
179
|
+
if (prev.descriptors.rows > 0 && next.descriptors.rows > 0) try {
|
|
180
|
+
matcher = new cvLib.BFMatcher(cvLib.NORM_HAMMING, false);
|
|
181
|
+
matches = new cvLib.DMatchVectorVector();
|
|
182
|
+
matcher.knnMatch(prev.descriptors, next.descriptors, matches, 2);
|
|
183
|
+
for (let i = 0; i < matches.size(); i++) {
|
|
184
|
+
const pair = matches.get(i);
|
|
185
|
+
try {
|
|
186
|
+
if (pair.size() < 2) continue;
|
|
187
|
+
const best = pair.get(0);
|
|
188
|
+
const second = pair.get(1);
|
|
189
|
+
if (best.distance < .25 * second.distance) matchedIndices.add(best.trainIdx);
|
|
190
|
+
} finally {
|
|
191
|
+
pair.delete();
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
} finally {
|
|
195
|
+
matcher?.delete();
|
|
196
|
+
}
|
|
197
|
+
const points = [];
|
|
198
|
+
for (let i = 0; i < next.keypoints.size(); i++) if (!matchedIndices.has(i)) {
|
|
199
|
+
const { x, y } = next.keypoints.get(i).pt;
|
|
200
|
+
points.push({
|
|
201
|
+
x,
|
|
202
|
+
y
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
return points;
|
|
206
|
+
} finally {
|
|
207
|
+
matches?.delete();
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
var init_feature_diff = __esmMin((() => {
|
|
211
|
+
init_vision_tuning();
|
|
212
|
+
}));
|
|
213
|
+
|
|
214
|
+
//#endregion
|
|
215
|
+
//#region src/core/analyzer/features/frame-features.ts
|
|
216
|
+
/**
|
|
217
|
+
* Detect one frame's features without retaining its image or mask.
|
|
218
|
+
* @param cvLib - Initialized OpenCV runtime.
|
|
219
|
+
* @param akaze - Detector owned and released by the caller.
|
|
220
|
+
* @param frame - Grayscale bytes with matching width and height.
|
|
221
|
+
* @returns Feature handles that the caller must delete.
|
|
222
|
+
* @throws Propagates native errors after releasing partial allocations.
|
|
223
|
+
*/
|
|
224
|
+
function computeFrameFeatures(cvLib, akaze, frame) {
|
|
225
|
+
let image = null;
|
|
226
|
+
let mask = null;
|
|
227
|
+
let keypoints = null;
|
|
228
|
+
let descriptors = null;
|
|
229
|
+
try {
|
|
230
|
+
image = new cvLib.Mat(frame.height, frame.width, cvLib.CV_8UC1);
|
|
231
|
+
image.data.set(frame.data);
|
|
232
|
+
mask = new cvLib.Mat();
|
|
233
|
+
keypoints = new cvLib.KeyPointVector();
|
|
234
|
+
descriptors = new cvLib.Mat();
|
|
235
|
+
akaze.detectAndCompute(image, mask, keypoints, descriptors);
|
|
236
|
+
const ownedKeypoints = keypoints;
|
|
237
|
+
const ownedDescriptors = descriptors;
|
|
238
|
+
let deleted = false;
|
|
239
|
+
const features = {
|
|
240
|
+
width: frame.width,
|
|
241
|
+
height: frame.height,
|
|
242
|
+
keypoints: ownedKeypoints,
|
|
243
|
+
descriptors: ownedDescriptors,
|
|
244
|
+
/** Release the transferred handles exactly once. */
|
|
245
|
+
delete() {
|
|
246
|
+
if (deleted) return;
|
|
247
|
+
deleted = true;
|
|
248
|
+
try {
|
|
249
|
+
ownedKeypoints.delete();
|
|
250
|
+
} finally {
|
|
251
|
+
ownedDescriptors.delete();
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
};
|
|
255
|
+
keypoints = null;
|
|
256
|
+
descriptors = null;
|
|
257
|
+
return features;
|
|
258
|
+
} finally {
|
|
259
|
+
image?.delete();
|
|
260
|
+
mask?.delete();
|
|
261
|
+
keypoints?.delete();
|
|
262
|
+
descriptors?.delete();
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
var init_frame_features = __esmMin((() => {}));
|
|
140
266
|
|
|
141
267
|
//#endregion
|
|
142
|
-
//#region src/core/analyzer.ts
|
|
143
|
-
const OPENCV_INIT_TIMEOUT_MS = 3e4;
|
|
144
|
-
const require = createRequire(import.meta.url);
|
|
145
|
-
let cvReady = null;
|
|
268
|
+
//#region src/core/analyzer/analyzer.ts
|
|
146
269
|
async function ensureOpenCV() {
|
|
147
270
|
if (!cvReady) cvReady = (async () => {
|
|
148
271
|
const cvObj = require("@techstark/opencv-js");
|
|
@@ -181,158 +304,6 @@ function computeIoU(a, b) {
|
|
|
181
304
|
const union = a.width * a.height + b.width * b.height - intersection;
|
|
182
305
|
return union === 0 ? 0 : intersection / union;
|
|
183
306
|
}
|
|
184
|
-
var IoUTracker = class {
|
|
185
|
-
fps;
|
|
186
|
-
iouThreshold;
|
|
187
|
-
animationThreshold;
|
|
188
|
-
regions = [];
|
|
189
|
-
extractedAnimations = [];
|
|
190
|
-
constructor(fps = 5, iouThreshold = IOU_THRESHOLD, animationThreshold = 5) {
|
|
191
|
-
this.fps = fps;
|
|
192
|
-
this.iouThreshold = iouThreshold;
|
|
193
|
-
this.animationThreshold = animationThreshold;
|
|
194
|
-
}
|
|
195
|
-
update(boxes, pairIndex) {
|
|
196
|
-
const animationIndices = /* @__PURE__ */ new Set();
|
|
197
|
-
const matched = /* @__PURE__ */ new Set();
|
|
198
|
-
for (let bi = 0; bi < boxes.length; bi++) {
|
|
199
|
-
const box = boxes[bi];
|
|
200
|
-
let bestIoU = 0;
|
|
201
|
-
let bestRegionIdx = -1;
|
|
202
|
-
for (let ri = 0; ri < this.regions.length; ri++) {
|
|
203
|
-
if (matched.has(ri)) continue;
|
|
204
|
-
const iou = computeIoU(box, this.regions[ri].box);
|
|
205
|
-
if (iou > bestIoU) {
|
|
206
|
-
bestIoU = iou;
|
|
207
|
-
bestRegionIdx = ri;
|
|
208
|
-
}
|
|
209
|
-
}
|
|
210
|
-
if (bestIoU > this.iouThreshold && bestRegionIdx !== -1) {
|
|
211
|
-
const region = this.regions[bestRegionIdx];
|
|
212
|
-
const gap = pairIndex - region.lastSeen;
|
|
213
|
-
region.box = box;
|
|
214
|
-
region.consecutiveCount++;
|
|
215
|
-
region.lastSeen = pairIndex;
|
|
216
|
-
region.weight *= Math.pow(DECAY_LAMBDA, gap);
|
|
217
|
-
matched.add(bestRegionIdx);
|
|
218
|
-
if (region.consecutiveCount >= this.animationThreshold) animationIndices.add(bi);
|
|
219
|
-
} else this.regions.push({
|
|
220
|
-
box,
|
|
221
|
-
consecutiveCount: 1,
|
|
222
|
-
firstSeen: pairIndex,
|
|
223
|
-
lastSeen: pairIndex,
|
|
224
|
-
weight: 1
|
|
225
|
-
});
|
|
226
|
-
}
|
|
227
|
-
for (let ri = 0; ri < this.regions.length; ri++) if (!matched.has(ri)) {
|
|
228
|
-
const gap = pairIndex - this.regions[ri].lastSeen;
|
|
229
|
-
this.regions[ri].weight *= Math.pow(DECAY_LAMBDA, gap);
|
|
230
|
-
}
|
|
231
|
-
for (let i = 0; i < this.regions.length; i++) {
|
|
232
|
-
const region = this.regions[i];
|
|
233
|
-
if (region.weight <= .01 && !matched.has(i)) this.collectAnimation(region);
|
|
234
|
-
}
|
|
235
|
-
this.regions = filter(this.regions, (r, i) => r.weight > .01 || matched.has(i));
|
|
236
|
-
return animationIndices;
|
|
237
|
-
}
|
|
238
|
-
collectAnimation(region) {
|
|
239
|
-
if (region.consecutiveCount >= this.animationThreshold) {
|
|
240
|
-
const durationMs = region.consecutiveCount / this.fps * 1e3;
|
|
241
|
-
this.extractedAnimations.push({
|
|
242
|
-
type: "loading_spinner",
|
|
243
|
-
boundingBox: region.box,
|
|
244
|
-
startFrameId: region.firstSeen,
|
|
245
|
-
endFrameId: region.lastSeen,
|
|
246
|
-
durationMs
|
|
247
|
-
});
|
|
248
|
-
}
|
|
249
|
-
}
|
|
250
|
-
flushAndGetAnimations() {
|
|
251
|
-
for (const region of this.regions) this.collectAnimation(region);
|
|
252
|
-
this.regions = [];
|
|
253
|
-
return this.extractedAnimations;
|
|
254
|
-
}
|
|
255
|
-
getAnimationWeight(boxIndex, boxes) {
|
|
256
|
-
if (boxIndex >= boxes.length) return 0;
|
|
257
|
-
const box = boxes[boxIndex];
|
|
258
|
-
let maxWeight = 0;
|
|
259
|
-
for (const region of this.regions) if (region.consecutiveCount >= this.animationThreshold) {
|
|
260
|
-
if (computeIoU(box, region.box) > this.iouThreshold) maxWeight = Math.max(maxWeight, region.weight);
|
|
261
|
-
}
|
|
262
|
-
return maxWeight;
|
|
263
|
-
}
|
|
264
|
-
};
|
|
265
|
-
async function computeAKAZEDiff(cvLib, frame1, frame2) {
|
|
266
|
-
const cv = cvLib;
|
|
267
|
-
const mat1 = new cv.Mat(frame1.height, frame1.width, cv.CV_8UC1);
|
|
268
|
-
mat1.data.set(frame1.data);
|
|
269
|
-
const mat2 = new cv.Mat(frame2.height, frame2.width, cv.CV_8UC1);
|
|
270
|
-
mat2.data.set(frame2.data);
|
|
271
|
-
const kp1 = new cvLib.KeyPointVector();
|
|
272
|
-
const kp2 = new cvLib.KeyPointVector();
|
|
273
|
-
const desc1 = new cvLib.Mat();
|
|
274
|
-
const desc2 = new cvLib.Mat();
|
|
275
|
-
const mask1 = new cvLib.Mat();
|
|
276
|
-
const mask2 = new cvLib.Mat();
|
|
277
|
-
const akaze = new cvLib.AKAZE();
|
|
278
|
-
let matches = null;
|
|
279
|
-
try {
|
|
280
|
-
akaze.detectAndCompute(mat1, mask1, kp1, desc1);
|
|
281
|
-
akaze.detectAndCompute(mat2, mask2, kp2, desc2);
|
|
282
|
-
const matchedKp1Indices = /* @__PURE__ */ new Set();
|
|
283
|
-
const matchedKp2Indices = /* @__PURE__ */ new Set();
|
|
284
|
-
if (desc1.rows > 0 && desc2.rows > 0) {
|
|
285
|
-
const matcher = new cvLib.BFMatcher(cvLib.NORM_HAMMING, false);
|
|
286
|
-
try {
|
|
287
|
-
matches = new cvLib.DMatchVectorVector();
|
|
288
|
-
matcher.knnMatch(desc1, desc2, matches, 2);
|
|
289
|
-
for (let i = 0; i < matches.size(); i++) {
|
|
290
|
-
const pair = matches.get(i);
|
|
291
|
-
if (pair.size() < 2) continue;
|
|
292
|
-
const m0 = pair.get(0);
|
|
293
|
-
const m1 = pair.get(1);
|
|
294
|
-
if (m0.distance < .25 * m1.distance) {
|
|
295
|
-
matchedKp1Indices.add(m0.queryIdx);
|
|
296
|
-
matchedKp2Indices.add(m0.trainIdx);
|
|
297
|
-
}
|
|
298
|
-
}
|
|
299
|
-
} finally {
|
|
300
|
-
matcher.delete();
|
|
301
|
-
}
|
|
302
|
-
}
|
|
303
|
-
const sNew = [];
|
|
304
|
-
for (let i = 0; i < kp2.size(); i++) if (!matchedKp2Indices.has(i)) {
|
|
305
|
-
const pt = kp2.get(i).pt;
|
|
306
|
-
sNew.push({
|
|
307
|
-
x: pt.x,
|
|
308
|
-
y: pt.y
|
|
309
|
-
});
|
|
310
|
-
}
|
|
311
|
-
const sLoss = [];
|
|
312
|
-
for (let i = 0; i < kp1.size(); i++) if (!matchedKp1Indices.has(i)) {
|
|
313
|
-
const pt = kp1.get(i).pt;
|
|
314
|
-
sLoss.push({
|
|
315
|
-
x: pt.x,
|
|
316
|
-
y: pt.y
|
|
317
|
-
});
|
|
318
|
-
}
|
|
319
|
-
return {
|
|
320
|
-
sNew,
|
|
321
|
-
sLoss
|
|
322
|
-
};
|
|
323
|
-
} finally {
|
|
324
|
-
mat1.delete();
|
|
325
|
-
mat2.delete();
|
|
326
|
-
kp1.delete();
|
|
327
|
-
kp2.delete();
|
|
328
|
-
desc1.delete();
|
|
329
|
-
desc2.delete();
|
|
330
|
-
mask1.delete();
|
|
331
|
-
mask2.delete();
|
|
332
|
-
akaze.delete();
|
|
333
|
-
if (matches) matches.delete();
|
|
334
|
-
}
|
|
335
|
-
}
|
|
336
307
|
/**
|
|
337
308
|
* Pixel-level difference fallback for AKAZE blind spots.
|
|
338
309
|
*
|
|
@@ -347,17 +318,30 @@ async function computeAKAZEDiff(cvLib, frame1, frame2) {
|
|
|
347
318
|
* 3. threshold → binary mask of significant changes
|
|
348
319
|
* 4. findContours → bounding rects of changed regions
|
|
349
320
|
* 5. Grid sampling within each bounding rect → Point2D[]
|
|
321
|
+
*
|
|
322
|
+
* @param cvLib - Initialized OpenCV runtime shared by the analyzer.
|
|
323
|
+
* @param frame1 - Previous grayscale frame, with the same dimensions as frame2.
|
|
324
|
+
* @param frame2 - Next grayscale frame, with the same dimensions as frame1.
|
|
325
|
+
* @returns Grid-sampled points from changed regions.
|
|
326
|
+
* @throws Propagates allocation or OpenCV errors after releasing acquired handles.
|
|
350
327
|
*/
|
|
351
328
|
function computePixelDiff(cvLib, frame1, frame2) {
|
|
352
329
|
const cv = cvLib;
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
330
|
+
let mat1 = null;
|
|
331
|
+
let mat2 = null;
|
|
332
|
+
let diff = null;
|
|
333
|
+
let blurred = null;
|
|
334
|
+
let binary = null;
|
|
335
|
+
let contours = null;
|
|
336
|
+
let hierarchy = null;
|
|
360
337
|
try {
|
|
338
|
+
mat1 = new cv.Mat(frame1.height, frame1.width, cv.CV_8UC1);
|
|
339
|
+
mat2 = new cv.Mat(frame2.height, frame2.width, cv.CV_8UC1);
|
|
340
|
+
diff = new cv.Mat();
|
|
341
|
+
blurred = new cv.Mat();
|
|
342
|
+
binary = new cv.Mat();
|
|
343
|
+
contours = new cv.MatVector();
|
|
344
|
+
hierarchy = new cv.Mat();
|
|
361
345
|
mat1.data.set(frame1.data);
|
|
362
346
|
mat2.data.set(frame2.data);
|
|
363
347
|
cv.absdiff(mat1, mat2, diff);
|
|
@@ -368,22 +352,26 @@ function computePixelDiff(cvLib, frame1, frame2) {
|
|
|
368
352
|
const points = [];
|
|
369
353
|
for (let c = 0; c < contours.size(); c++) {
|
|
370
354
|
const contour = contours.get(c);
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
x
|
|
375
|
-
|
|
376
|
-
|
|
355
|
+
try {
|
|
356
|
+
const rect = cv.boundingRect(contour);
|
|
357
|
+
if (rect.width * rect.height < 100) continue;
|
|
358
|
+
for (let y = rect.y; y < rect.y + rect.height; y += 8) for (let x = rect.x; x < rect.x + rect.width; x += 8) points.push({
|
|
359
|
+
x,
|
|
360
|
+
y
|
|
361
|
+
});
|
|
362
|
+
} finally {
|
|
363
|
+
contour.delete();
|
|
364
|
+
}
|
|
377
365
|
}
|
|
378
366
|
return points;
|
|
379
367
|
} finally {
|
|
380
|
-
mat1
|
|
381
|
-
mat2
|
|
382
|
-
diff
|
|
383
|
-
blurred
|
|
384
|
-
binary
|
|
385
|
-
contours
|
|
386
|
-
hierarchy
|
|
368
|
+
mat1?.delete();
|
|
369
|
+
mat2?.delete();
|
|
370
|
+
diff?.delete();
|
|
371
|
+
blurred?.delete();
|
|
372
|
+
binary?.delete();
|
|
373
|
+
contours?.delete();
|
|
374
|
+
hierarchy?.delete();
|
|
387
375
|
}
|
|
388
376
|
}
|
|
389
377
|
function computeInformationGain(clusters, clusterPoints, imageArea, animationIndices, animationWeights) {
|
|
@@ -402,47 +390,91 @@ function computeInformationGain(clusters, clusterPoints, imageArea, animationInd
|
|
|
402
390
|
}
|
|
403
391
|
return gain;
|
|
404
392
|
}
|
|
405
|
-
|
|
393
|
+
/**
|
|
394
|
+
* Analyze one batch, retaining its boundary frame for the following batch.
|
|
395
|
+
* @param cvLib - Initialized OpenCV runtime.
|
|
396
|
+
* @param akaze - Detector owned by analyzeFrames.
|
|
397
|
+
* @param frames - Boundary frame followed by new adjacent frames.
|
|
398
|
+
* @param carry - Boundary bytes and live features transferred from the previous batch.
|
|
399
|
+
* @param scale - Maximum preprocessing width.
|
|
400
|
+
* @param tracker - Stateful animation tracker shared across batches.
|
|
401
|
+
* @param pairOffset - Global position of this batch's first pair.
|
|
402
|
+
* @returns Scores, pair failure count, and ownership of the final frame's features.
|
|
403
|
+
*/
|
|
404
|
+
async function analyzeBatch(cvLib, akaze, frames, carry, scale, tracker, pairOffset) {
|
|
406
405
|
const edges = [];
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
const
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
406
|
+
let failures = 0;
|
|
407
|
+
let prev = carry?.features ?? null;
|
|
408
|
+
let next = null;
|
|
409
|
+
try {
|
|
410
|
+
const preprocessed = await Promise.all(map(frames, (f, index) => index === 0 && carry ? carry.preprocessed : preprocessFrame(f.extractPath, scale)));
|
|
411
|
+
const imageWidth = preprocessed[0]?.width ?? scale;
|
|
412
|
+
const imageHeight = preprocessed[0]?.height ?? Math.round(scale * 9 / 16);
|
|
413
|
+
const imageArea = imageWidth * imageHeight;
|
|
414
|
+
for (let i = 0; i < frames.length - 1; i++) {
|
|
415
|
+
const pairIndex = pairOffset + i;
|
|
416
|
+
try {
|
|
417
|
+
prev ??= computeFrameFeatures(cvLib, akaze, preprocessed[i]);
|
|
418
|
+
next = computeFrameFeatures(cvLib, akaze, preprocessed[i + 1]);
|
|
419
|
+
const sNew = computeNewPoints(cvLib, prev, next);
|
|
420
|
+
let dbscanResult = dbscan(sNew, imageWidth, imageHeight);
|
|
421
|
+
let clusters = dbscanResult.boundingBoxes;
|
|
422
|
+
if (clusters.length === 0) {
|
|
423
|
+
const pixelDiffPoints = computePixelDiff(cvLib, preprocessed[i], preprocessed[i + 1]);
|
|
424
|
+
if (pixelDiffPoints.length > 0) {
|
|
425
|
+
logger.debug(`Edge ${frames[i].id}->${frames[i + 1].id}: pixel-diff fallback (${pixelDiffPoints.length} points)`);
|
|
426
|
+
dbscanResult = dbscan(pixelDiffPoints, imageWidth, imageHeight, void 0, 2);
|
|
427
|
+
clusters = dbscanResult.boundingBoxes;
|
|
428
|
+
}
|
|
423
429
|
}
|
|
430
|
+
const clusterPointCounts = new Array(clusters.length).fill(0);
|
|
431
|
+
for (const label of dbscanResult.labels) if (label >= 0) clusterPointCounts[label]++;
|
|
432
|
+
const animationIndices = tracker.update(clusters, pairIndex);
|
|
433
|
+
const change = {
|
|
434
|
+
regions: filter(clusters, (_, ci) => !animationIndices.has(ci)),
|
|
435
|
+
animatedRegions: filter(clusters, (_, ci) => animationIndices.has(ci))
|
|
436
|
+
};
|
|
437
|
+
const animationWeights = map(clusters, (_, ci) => animationIndices.has(ci) ? tracker.getAnimationWeight(ci, clusters) : 0);
|
|
438
|
+
const score = computeInformationGain(clusters, clusterPointCounts, imageArea, animationIndices, animationWeights);
|
|
439
|
+
logger.debug(`Edge ${frames[i].id}->${frames[i + 1].id} G(t)=${score.toFixed(6)}`);
|
|
440
|
+
edges.push({
|
|
441
|
+
sourceId: frames[i].id,
|
|
442
|
+
targetId: frames[i + 1].id,
|
|
443
|
+
score,
|
|
444
|
+
change
|
|
445
|
+
});
|
|
446
|
+
} catch (err) {
|
|
447
|
+
logger.warn(`Frame pair analysis failed: ${String(err)}`);
|
|
448
|
+
failures++;
|
|
449
|
+
edges.push({
|
|
450
|
+
sourceId: frames[i].id,
|
|
451
|
+
targetId: frames[i + 1].id,
|
|
452
|
+
score: 0
|
|
453
|
+
});
|
|
454
|
+
} finally {
|
|
455
|
+
prev?.delete();
|
|
456
|
+
prev = next;
|
|
457
|
+
next = null;
|
|
424
458
|
}
|
|
425
|
-
const clusterPointCounts = new Array(clusters.length).fill(0);
|
|
426
|
-
for (const label of dbscanResult.labels) if (label >= 0) clusterPointCounts[label]++;
|
|
427
|
-
const animationIndices = tracker.update(clusters, pairIndex);
|
|
428
|
-
const animationWeights = map(clusters, (_, ci) => animationIndices.has(ci) ? tracker.getAnimationWeight(ci, clusters) : 0);
|
|
429
|
-
const score = computeInformationGain(clusters, clusterPointCounts, imageArea, animationIndices, animationWeights);
|
|
430
|
-
logger.debug(`Edge ${frames[i].id}->${frames[i + 1].id} G(t)=${score.toFixed(6)}`);
|
|
431
|
-
edges.push({
|
|
432
|
-
sourceId: frames[i].id,
|
|
433
|
-
targetId: frames[i + 1].id,
|
|
434
|
-
score
|
|
435
|
-
});
|
|
436
|
-
} catch (err) {
|
|
437
|
-
logger.debug(`Frame pair analysis failed: ${String(err)}`);
|
|
438
|
-
edges.push({
|
|
439
|
-
sourceId: frames[i].id,
|
|
440
|
-
targetId: frames[i + 1].id,
|
|
441
|
-
score: 0
|
|
442
|
-
});
|
|
443
459
|
}
|
|
460
|
+
const result = {
|
|
461
|
+
edges,
|
|
462
|
+
failures,
|
|
463
|
+
analysisResolution: {
|
|
464
|
+
width: imageWidth,
|
|
465
|
+
height: imageHeight
|
|
466
|
+
},
|
|
467
|
+
carry: prev ? {
|
|
468
|
+
preprocessed: preprocessed[preprocessed.length - 1],
|
|
469
|
+
features: prev
|
|
470
|
+
} : null
|
|
471
|
+
};
|
|
472
|
+
prev = null;
|
|
473
|
+
return result;
|
|
474
|
+
} finally {
|
|
475
|
+
prev?.delete();
|
|
476
|
+
next?.delete();
|
|
444
477
|
}
|
|
445
|
-
return edges;
|
|
446
478
|
}
|
|
447
479
|
/**
|
|
448
480
|
* Analyze adjacent frame pairs to compute information gain scores (G(t)).
|
|
@@ -453,35 +485,183 @@ async function analyzeBatch(cvLib, frames, scale, tracker, pairOffset) {
|
|
|
453
485
|
* 2. DBSCAN Spatial Clustering
|
|
454
486
|
* 3. Spatio-temporal IoU Tracking
|
|
455
487
|
* 4. G(t) Information Gain Scoring
|
|
488
|
+
* @param ctx - Frames, analysis options, and the progress callback for this run.
|
|
489
|
+
* @returns Adjacent scores and tracked animations in analysis coordinates.
|
|
490
|
+
* @throws Propagates runtime errors and rejects total failure of two or more pairs after cleanup.
|
|
456
491
|
*/
|
|
457
492
|
async function analyzeFrames(ctx) {
|
|
458
493
|
const { frames } = ctx;
|
|
459
494
|
if (frames.length < 2) return {
|
|
460
495
|
edges: [],
|
|
461
|
-
animations: []
|
|
496
|
+
animations: [],
|
|
497
|
+
analysisResolution: {
|
|
498
|
+
width: 0,
|
|
499
|
+
height: 0
|
|
500
|
+
}
|
|
462
501
|
};
|
|
463
502
|
logger.debug(`Analyzing ${frames.length} frames in batches of ${10}`);
|
|
464
503
|
const cvLib = await ensureOpenCV();
|
|
465
504
|
const edges = [];
|
|
466
|
-
const tracker = new IoUTracker(ctx.options.fps, ctx.options.iouThreshold, ctx.options.animationThreshold);
|
|
505
|
+
const tracker = new IoUTracker(ctx.effectiveFps ?? ctx.options.fps, ctx.options.iouThreshold, ctx.options.animationThreshold);
|
|
467
506
|
const scale = ctx.options.scale;
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
507
|
+
let akaze = null;
|
|
508
|
+
let carry = null;
|
|
509
|
+
let analysisResolution = {
|
|
510
|
+
width: 0,
|
|
511
|
+
height: 0
|
|
512
|
+
};
|
|
513
|
+
let failures = 0;
|
|
514
|
+
try {
|
|
515
|
+
akaze = new cvLib.AKAZE();
|
|
516
|
+
for (let i = 0; i < frames.length - 1; i += 10) {
|
|
517
|
+
const batch = [frames[i], ...frames.slice(i + 1, i + 1 + 10)];
|
|
518
|
+
const result = await analyzeBatch(cvLib, akaze, batch, carry, scale, tracker, i);
|
|
519
|
+
carry = result.carry;
|
|
520
|
+
failures += result.failures;
|
|
521
|
+
if (i === 0) analysisResolution = result.analysisResolution;
|
|
522
|
+
edges.push(...result.edges);
|
|
523
|
+
const progress = Math.min(100, (i + 10) / (frames.length - 1) * 100);
|
|
524
|
+
ctx.emitProgress(progress);
|
|
525
|
+
}
|
|
526
|
+
} finally {
|
|
527
|
+
carry?.features.delete();
|
|
528
|
+
akaze?.delete();
|
|
474
529
|
}
|
|
530
|
+
const pairs = frames.length - 1;
|
|
531
|
+
if (pairs >= 2 && failures === pairs) throw new Error(`All ${pairs} frame pairs failed analysis`);
|
|
475
532
|
const animations = tracker.flushAndGetAnimations();
|
|
476
533
|
logger.debug(`Computed ${edges.length} score edges and ${animations.length} animations`);
|
|
477
534
|
return {
|
|
478
535
|
edges,
|
|
479
|
-
animations
|
|
536
|
+
animations,
|
|
537
|
+
analysisResolution
|
|
538
|
+
};
|
|
539
|
+
}
|
|
540
|
+
var OPENCV_INIT_TIMEOUT_MS, require, cvReady, IoUTracker;
|
|
541
|
+
var init_analyzer$1 = __esmMin((() => {
|
|
542
|
+
init_pipeline_defaults();
|
|
543
|
+
init_vision_tuning();
|
|
544
|
+
init_logger();
|
|
545
|
+
init_dbscan();
|
|
546
|
+
init_feature_diff();
|
|
547
|
+
init_frame_features();
|
|
548
|
+
OPENCV_INIT_TIMEOUT_MS = 3e4;
|
|
549
|
+
require = createRequire(import.meta.url);
|
|
550
|
+
cvReady = null;
|
|
551
|
+
IoUTracker = class {
|
|
552
|
+
fps;
|
|
553
|
+
iouThreshold;
|
|
554
|
+
animationThreshold;
|
|
555
|
+
regions = [];
|
|
556
|
+
extractedAnimations = [];
|
|
557
|
+
constructor(fps = 5, iouThreshold = IOU_THRESHOLD, animationThreshold = 5) {
|
|
558
|
+
this.fps = fps;
|
|
559
|
+
this.iouThreshold = iouThreshold;
|
|
560
|
+
this.animationThreshold = animationThreshold;
|
|
561
|
+
}
|
|
562
|
+
update(boxes, pairIndex) {
|
|
563
|
+
const animationIndices = /* @__PURE__ */ new Set();
|
|
564
|
+
const matched = /* @__PURE__ */ new Set();
|
|
565
|
+
for (let bi = 0; bi < boxes.length; bi++) {
|
|
566
|
+
const box = boxes[bi];
|
|
567
|
+
let bestIoU = 0;
|
|
568
|
+
let bestRegionIdx = -1;
|
|
569
|
+
for (let ri = 0; ri < this.regions.length; ri++) {
|
|
570
|
+
if (matched.has(ri)) continue;
|
|
571
|
+
const iou = computeIoU(box, this.regions[ri].box);
|
|
572
|
+
if (iou > bestIoU) {
|
|
573
|
+
bestIoU = iou;
|
|
574
|
+
bestRegionIdx = ri;
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
if (bestIoU > this.iouThreshold && bestRegionIdx !== -1) {
|
|
578
|
+
const region = this.regions[bestRegionIdx];
|
|
579
|
+
const gap = pairIndex - region.lastSeen;
|
|
580
|
+
region.box = box;
|
|
581
|
+
region.consecutiveCount++;
|
|
582
|
+
region.lastSeen = pairIndex;
|
|
583
|
+
region.weight *= Math.pow(DECAY_LAMBDA, gap);
|
|
584
|
+
matched.add(bestRegionIdx);
|
|
585
|
+
if (region.consecutiveCount >= this.animationThreshold) animationIndices.add(bi);
|
|
586
|
+
} else this.regions.push({
|
|
587
|
+
box,
|
|
588
|
+
consecutiveCount: 1,
|
|
589
|
+
firstSeen: pairIndex,
|
|
590
|
+
lastSeen: pairIndex,
|
|
591
|
+
weight: 1
|
|
592
|
+
});
|
|
593
|
+
}
|
|
594
|
+
for (let ri = 0; ri < this.regions.length; ri++) if (!matched.has(ri)) {
|
|
595
|
+
const gap = pairIndex - this.regions[ri].lastSeen;
|
|
596
|
+
this.regions[ri].weight *= Math.pow(DECAY_LAMBDA, gap);
|
|
597
|
+
}
|
|
598
|
+
for (let i = 0; i < this.regions.length; i++) {
|
|
599
|
+
const region = this.regions[i];
|
|
600
|
+
if (region.weight <= .01 && !matched.has(i)) this.collectAnimation(region);
|
|
601
|
+
}
|
|
602
|
+
this.regions = filter(this.regions, (r, i) => r.weight > .01 || matched.has(i));
|
|
603
|
+
return animationIndices;
|
|
604
|
+
}
|
|
605
|
+
collectAnimation(region) {
|
|
606
|
+
if (region.consecutiveCount >= this.animationThreshold) {
|
|
607
|
+
const durationMs = region.consecutiveCount / this.fps * 1e3;
|
|
608
|
+
this.extractedAnimations.push({
|
|
609
|
+
type: "loading_spinner",
|
|
610
|
+
boundingBox: region.box,
|
|
611
|
+
startFrameId: region.firstSeen,
|
|
612
|
+
endFrameId: region.lastSeen,
|
|
613
|
+
durationMs
|
|
614
|
+
});
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
flushAndGetAnimations() {
|
|
618
|
+
for (const region of this.regions) this.collectAnimation(region);
|
|
619
|
+
this.regions = [];
|
|
620
|
+
return this.extractedAnimations;
|
|
621
|
+
}
|
|
622
|
+
getAnimationWeight(boxIndex, boxes) {
|
|
623
|
+
if (boxIndex >= boxes.length) return 0;
|
|
624
|
+
const box = boxes[boxIndex];
|
|
625
|
+
let maxWeight = 0;
|
|
626
|
+
for (const region of this.regions) if (region.consecutiveCount >= this.animationThreshold) {
|
|
627
|
+
if (computeIoU(box, region.box) > this.iouThreshold) maxWeight = Math.max(maxWeight, region.weight);
|
|
628
|
+
}
|
|
629
|
+
return maxWeight;
|
|
630
|
+
}
|
|
480
631
|
};
|
|
632
|
+
}));
|
|
633
|
+
|
|
634
|
+
//#endregion
|
|
635
|
+
//#region src/core/analyzer/index.ts
|
|
636
|
+
var init_analyzer = __esmMin((() => {
|
|
637
|
+
init_analyzer$1();
|
|
638
|
+
init_dbscan();
|
|
639
|
+
}));
|
|
640
|
+
|
|
641
|
+
//#endregion
|
|
642
|
+
//#region src/constants/package-version.ts
|
|
643
|
+
var manifestPath, PACKAGE_VERSION;
|
|
644
|
+
var init_package_version = __esmMin((() => {
|
|
645
|
+
manifestPath = existsSync(new URL("../package.json", import.meta.url)) ? "../package.json" : "../../package.json";
|
|
646
|
+
PACKAGE_VERSION = createRequire(import.meta.url)(manifestPath).version;
|
|
647
|
+
}));
|
|
648
|
+
|
|
649
|
+
//#endregion
|
|
650
|
+
//#region src/core/constants/workspace-layout.ts
|
|
651
|
+
function getTempWorkspaceDir(sessionId) {
|
|
652
|
+
return join(TEMP_BASE_DIR, `${WORKSPACE_PREFIX}${sessionId}`);
|
|
481
653
|
}
|
|
654
|
+
var APP_NAME, WORKSPACE_PREFIX, TEMP_BASE_DIR, FRAME_OUTPUT_EXTENSION, FRAME_FILENAME_PATTERN;
|
|
655
|
+
var init_workspace_layout = __esmMin((() => {
|
|
656
|
+
APP_NAME = "scene-sieve";
|
|
657
|
+
WORKSPACE_PREFIX = `${APP_NAME}-`;
|
|
658
|
+
TEMP_BASE_DIR = tmpdir();
|
|
659
|
+
FRAME_OUTPUT_EXTENSION = ".jpg";
|
|
660
|
+
FRAME_FILENAME_PATTERN = "frame_%06d.jpg";
|
|
661
|
+
}));
|
|
482
662
|
|
|
483
663
|
//#endregion
|
|
484
|
-
//#region src/utils/paths.ts
|
|
664
|
+
//#region src/core/utils/filesystem/paths.ts
|
|
485
665
|
async function ensureDir(dirPath) {
|
|
486
666
|
await mkdir(dirPath, { recursive: true });
|
|
487
667
|
}
|
|
@@ -515,17 +695,638 @@ function resolveAbsolute(p) {
|
|
|
515
695
|
* e.g., /path/to/video.mp4 -> /path/to/video_scenes
|
|
516
696
|
*/
|
|
517
697
|
function deriveOutputPath(inputPath) {
|
|
518
|
-
|
|
698
|
+
const dir = resolve(inputPath, "..");
|
|
699
|
+
const name = basename(inputPath, extname(inputPath));
|
|
700
|
+
return resolve(dir, `${name}_scenes`);
|
|
519
701
|
}
|
|
702
|
+
var init_paths = __esmMin((() => {}));
|
|
520
703
|
|
|
521
704
|
//#endregion
|
|
522
|
-
//#region src/core/
|
|
705
|
+
//#region src/core/workspace/workspace.ts
|
|
706
|
+
async function createWorkspace(sessionId) {
|
|
707
|
+
const workspacePath = getTempWorkspaceDir(sessionId);
|
|
708
|
+
await ensureDir(join(workspacePath, "frames"));
|
|
709
|
+
await ensureDir(join(workspacePath, "output"));
|
|
710
|
+
return workspacePath;
|
|
711
|
+
}
|
|
712
|
+
/**
|
|
713
|
+
* Write selected JPEGs and the supplied document before replacing the output directory.
|
|
714
|
+
* @param ctx - Workspace, quality and destination settings.
|
|
715
|
+
* @param selectedFrames - Frames paired by position with document.frames.
|
|
716
|
+
* @param document - Complete metadata, including the output file names.
|
|
717
|
+
* @param sheetBuffer - Optional JPEG contact sheet bytes to persist unchanged.
|
|
718
|
+
* @returns Selected JPEG paths, optional sheet path and finally the metadata path.
|
|
719
|
+
* @throws Rejects mismatched frame counts and propagates image or filesystem errors.
|
|
720
|
+
*/
|
|
721
|
+
async function finalizeOutput(ctx, selectedFrames, document, sheetBuffer) {
|
|
722
|
+
if (document.frames.length !== selectedFrames.length) throw new Error("metadata frame count must match selected frame count");
|
|
723
|
+
const stagingDir = join(ctx.workspacePath, "output");
|
|
724
|
+
const outputPath = ctx.options.outputPath;
|
|
725
|
+
const quality = ctx.options.quality;
|
|
726
|
+
const outputFiles = [];
|
|
727
|
+
for (let i = 0; i < selectedFrames.length; i++) {
|
|
728
|
+
const frame = selectedFrames[i];
|
|
729
|
+
const fileName = document.frames[i].fileName;
|
|
730
|
+
const destPath = join(stagingDir, fileName);
|
|
731
|
+
await sharp(frame.extractPath).jpeg({
|
|
732
|
+
quality,
|
|
733
|
+
mozjpeg: true
|
|
734
|
+
}).toFile(destPath);
|
|
735
|
+
outputFiles.push(join(outputPath, fileName));
|
|
736
|
+
}
|
|
737
|
+
if (sheetBuffer) {
|
|
738
|
+
await writeFile(join(stagingDir, SHEET_FILE_NAME), sheetBuffer);
|
|
739
|
+
outputFiles.push(join(outputPath, SHEET_FILE_NAME));
|
|
740
|
+
}
|
|
741
|
+
const metadataPath = join(stagingDir, METADATA_FILE_NAME);
|
|
742
|
+
await writeFile(metadataPath, JSON.stringify(document, null, 2));
|
|
743
|
+
outputFiles.push(join(outputPath, METADATA_FILE_NAME));
|
|
744
|
+
await ensureDir(join(outputPath, ".."));
|
|
745
|
+
await rm(outputPath, {
|
|
746
|
+
recursive: true,
|
|
747
|
+
force: true
|
|
748
|
+
});
|
|
749
|
+
await rename(stagingDir, outputPath);
|
|
750
|
+
return outputFiles;
|
|
751
|
+
}
|
|
752
|
+
async function createSegmentWorkspace(parentWorkspacePath, segmentIndex) {
|
|
753
|
+
const segmentPath = join(parentWorkspacePath, "segments", String(segmentIndex));
|
|
754
|
+
await ensureDir(join(segmentPath, "frames"));
|
|
755
|
+
return segmentPath;
|
|
756
|
+
}
|
|
757
|
+
async function cleanupWorkspace(workspacePath) {
|
|
758
|
+
if (!workspacePath) return;
|
|
759
|
+
try {
|
|
760
|
+
await rm(workspacePath, {
|
|
761
|
+
recursive: true,
|
|
762
|
+
force: true
|
|
763
|
+
});
|
|
764
|
+
} catch {}
|
|
765
|
+
}
|
|
766
|
+
/**
|
|
767
|
+
* Write a video buffer to a temp file in the workspace and return the path.
|
|
768
|
+
* Used by 'buffer' input mode.
|
|
769
|
+
*/
|
|
770
|
+
async function writeInputBuffer(buffer, workspacePath) {
|
|
771
|
+
const inputDir = join(workspacePath, "input");
|
|
772
|
+
await ensureDir(inputDir);
|
|
773
|
+
const tempPath = join(inputDir, "input.mp4");
|
|
774
|
+
await writeFile(tempPath, buffer);
|
|
775
|
+
return tempPath;
|
|
776
|
+
}
|
|
777
|
+
/**
|
|
778
|
+
* Write an array of frame Buffers as JPG files and return FrameNode[].
|
|
779
|
+
* Used by 'frames' input mode.
|
|
780
|
+
*/
|
|
781
|
+
async function writeInputFrames(frames, workspacePath) {
|
|
782
|
+
const framesDir = join(workspacePath, "frames");
|
|
783
|
+
await ensureDir(framesDir);
|
|
784
|
+
const frameNodes = [];
|
|
785
|
+
for (let i = 0; i < frames.length; i++) {
|
|
786
|
+
const filename = `frame_${String(i).padStart(6, "0")}${FRAME_OUTPUT_EXTENSION}`;
|
|
787
|
+
const extractPath = join(framesDir, filename);
|
|
788
|
+
await writeFile(extractPath, frames[i]);
|
|
789
|
+
frameNodes.push({
|
|
790
|
+
id: i,
|
|
791
|
+
timestamp: i,
|
|
792
|
+
extractPath
|
|
793
|
+
});
|
|
794
|
+
}
|
|
795
|
+
return frameNodes;
|
|
796
|
+
}
|
|
797
|
+
/**
|
|
798
|
+
* Read selected FrameNode files as Buffers with JPEG compression.
|
|
799
|
+
* Used to return output buffers in 'buffer' and 'frames' modes.
|
|
800
|
+
*/
|
|
801
|
+
async function readFramesAsBuffers(frameNodes, quality) {
|
|
802
|
+
return Promise.all(map(frameNodes, (f) => sharp(f.extractPath).jpeg({
|
|
803
|
+
quality,
|
|
804
|
+
mozjpeg: true
|
|
805
|
+
}).toBuffer()));
|
|
806
|
+
}
|
|
807
|
+
var init_workspace$1 = __esmMin((() => {
|
|
808
|
+
init_workspace_layout();
|
|
809
|
+
init_pipeline_defaults();
|
|
810
|
+
init_paths();
|
|
811
|
+
}));
|
|
812
|
+
|
|
813
|
+
//#endregion
|
|
814
|
+
//#region src/core/workspace/index.ts
|
|
815
|
+
var init_workspace = __esmMin((() => {
|
|
816
|
+
init_workspace$1();
|
|
817
|
+
}));
|
|
818
|
+
|
|
819
|
+
//#endregion
|
|
820
|
+
//#region src/core/utils/metadata/change/union-area/y-coverage-tree.ts
|
|
821
|
+
var YCoverageTree;
|
|
822
|
+
var init_y_coverage_tree = __esmMin((() => {
|
|
823
|
+
YCoverageTree = class {
|
|
824
|
+
bounds;
|
|
825
|
+
/** Number of whole-node covering intervals, independent of descendants. */
|
|
826
|
+
counts;
|
|
827
|
+
/** Covered geometric length for each node, including partially covered children. */
|
|
828
|
+
lengths;
|
|
829
|
+
/**
|
|
830
|
+
* Allocate linear storage for the elementary intervals between coordinates.
|
|
831
|
+
* @param bounds - At least two sorted unique finite y endpoints.
|
|
832
|
+
*/
|
|
833
|
+
constructor(bounds) {
|
|
834
|
+
this.bounds = bounds;
|
|
835
|
+
this.counts = Array(4 * bounds.length).fill(0);
|
|
836
|
+
this.lengths = Array(4 * bounds.length).fill(0);
|
|
837
|
+
}
|
|
838
|
+
/** Total active covered y length, in the input coordinate system. */
|
|
839
|
+
get coveredLength() {
|
|
840
|
+
return this.lengths[1];
|
|
841
|
+
}
|
|
842
|
+
/**
|
|
843
|
+
* Adjust a nonempty half-open interval and refresh its ancestors' lengths.
|
|
844
|
+
* @param start - Inclusive endpoint index; 0 <= start < end.
|
|
845
|
+
* @param end - Exclusive endpoint index; end < bounds.length.
|
|
846
|
+
* @param delta - One on entry, minus one for the matching departure.
|
|
847
|
+
* @param node - Internal tree slot; callers use the root default.
|
|
848
|
+
* @param left - Inclusive endpoint index of this node.
|
|
849
|
+
* @param right - Exclusive endpoint index of this node.
|
|
850
|
+
* @returns Nothing; mutates this tree's coverage in O(log n).
|
|
851
|
+
*/
|
|
852
|
+
update(start, end, delta, node = 1, left = 0, right = this.bounds.length - 1) {
|
|
853
|
+
if (start <= left && right <= end) this.counts[node] += delta;
|
|
854
|
+
else {
|
|
855
|
+
const middle = Math.floor((left + right) / 2);
|
|
856
|
+
if (start < middle) this.update(start, end, delta, node * 2, left, middle);
|
|
857
|
+
if (end > middle) this.update(start, end, delta, node * 2 + 1, middle, right);
|
|
858
|
+
}
|
|
859
|
+
this.lengths[node] = this.counts[node] > 0 ? this.bounds[right] - this.bounds[left] : right - left === 1 ? 0 : this.lengths[node * 2] + this.lengths[node * 2 + 1];
|
|
860
|
+
}
|
|
861
|
+
};
|
|
862
|
+
}));
|
|
863
|
+
|
|
864
|
+
//#endregion
|
|
865
|
+
//#region src/core/utils/metadata/change/union-area.ts
|
|
866
|
+
/**
|
|
867
|
+
* Measure rectangle union with an x sweep in O(n log n) time and O(n) space.
|
|
868
|
+
* @param boxes - Finite rectangles; nonpositive dimensions are ignored.
|
|
869
|
+
* @returns Covered area in the input coordinate system, or zero for empty input.
|
|
870
|
+
*/
|
|
871
|
+
function unionArea(boxes) {
|
|
872
|
+
const events = [];
|
|
873
|
+
const endpoints = [];
|
|
874
|
+
for (const box of boxes) {
|
|
875
|
+
if (!(box.width > 0 && box.height > 0)) continue;
|
|
876
|
+
const end = box.y + box.height;
|
|
877
|
+
if (end === box.y) continue;
|
|
878
|
+
events.push({
|
|
879
|
+
x: box.x,
|
|
880
|
+
start: box.y,
|
|
881
|
+
end,
|
|
882
|
+
delta: 1
|
|
883
|
+
});
|
|
884
|
+
events.push({
|
|
885
|
+
x: box.x + box.width,
|
|
886
|
+
start: box.y,
|
|
887
|
+
end,
|
|
888
|
+
delta: -1
|
|
889
|
+
});
|
|
890
|
+
endpoints.push(box.y, end);
|
|
891
|
+
}
|
|
892
|
+
if (events.length === 0) return 0;
|
|
893
|
+
events.sort((a, b) => a.x - b.x);
|
|
894
|
+
endpoints.sort((a, b) => a - b);
|
|
895
|
+
const bounds = endpoints.filter((value, index) => index === 0 || value !== endpoints[index - 1]);
|
|
896
|
+
const indices = new Map(bounds.map((value, index) => [value, index]));
|
|
897
|
+
const coverage = new YCoverageTree(bounds);
|
|
898
|
+
let area = 0;
|
|
899
|
+
let previousX = events[0].x;
|
|
900
|
+
for (const event of events) {
|
|
901
|
+
area += (event.x - previousX) * coverage.coveredLength;
|
|
902
|
+
coverage.update(indices.get(event.start), indices.get(event.end), event.delta);
|
|
903
|
+
previousX = event.x;
|
|
904
|
+
}
|
|
905
|
+
return area;
|
|
906
|
+
}
|
|
907
|
+
var init_union_area = __esmMin((() => {
|
|
908
|
+
init_y_coverage_tree();
|
|
909
|
+
}));
|
|
910
|
+
|
|
911
|
+
//#endregion
|
|
912
|
+
//#region src/core/utils/metadata/build-edge-metadata.ts
|
|
913
|
+
/**
|
|
914
|
+
* Serialize raw candidate edges in graph order.
|
|
915
|
+
* @param graph - Adjacent-pair edges, optionally carrying tracker partitions.
|
|
916
|
+
* @param analysisResolution - Analysis dimensions used for both area fractions.
|
|
917
|
+
* @returns One-based IDs with six-decimal scores and four-decimal clamped ratios.
|
|
918
|
+
*/
|
|
919
|
+
function buildEdgeMetadata(graph, analysisResolution) {
|
|
920
|
+
const area = analysisResolution.width * analysisResolution.height;
|
|
921
|
+
return map(graph, (edge) => ({
|
|
922
|
+
sourceFrameId: edge.sourceId + 1,
|
|
923
|
+
targetFrameId: edge.targetId + 1,
|
|
924
|
+
score: Math.round(edge.score * 1e6) / 1e6,
|
|
925
|
+
areaRatio: area > 0 ? Math.round(Math.max(0, Math.min(1, unionArea(edge.change?.regions ?? []) / area)) * 1e4) / 1e4 : 0,
|
|
926
|
+
animatedAreaRatio: area > 0 ? Math.round(Math.max(0, Math.min(1, unionArea(edge.change?.animatedRegions ?? []) / area)) * 1e4) / 1e4 : 0
|
|
927
|
+
}));
|
|
928
|
+
}
|
|
929
|
+
var init_build_edge_metadata = __esmMin((() => {
|
|
930
|
+
init_union_area();
|
|
931
|
+
}));
|
|
932
|
+
|
|
933
|
+
//#endregion
|
|
934
|
+
//#region src/core/utils/metadata/scale-bounding-box.ts
|
|
935
|
+
/**
|
|
936
|
+
* Convert an analysis box to clamped integer output pixels.
|
|
937
|
+
* @param box - Analysis-space rectangle; the input remains unchanged.
|
|
938
|
+
* @param sx - Horizontal output-to-analysis scale.
|
|
939
|
+
* @param sy - Vertical output-to-analysis scale.
|
|
940
|
+
* @param width - Nonnegative output image width.
|
|
941
|
+
* @param height - Nonnegative output image height.
|
|
942
|
+
* @returns A rectangle contained within the output dimensions.
|
|
943
|
+
*/
|
|
944
|
+
function scaleBoundingBox(box, sx, sy, width, height) {
|
|
945
|
+
const x = Math.max(0, Math.min(width, Math.round(box.x * sx)));
|
|
946
|
+
const y = Math.max(0, Math.min(height, Math.round(box.y * sy)));
|
|
947
|
+
return {
|
|
948
|
+
x,
|
|
949
|
+
y,
|
|
950
|
+
width: Math.max(0, Math.min(width - x, Math.round(box.width * sx))),
|
|
951
|
+
height: Math.max(0, Math.min(height - y, Math.round(box.height * sy)))
|
|
952
|
+
};
|
|
953
|
+
}
|
|
954
|
+
var init_scale_bounding_box = __esmMin((() => {}));
|
|
955
|
+
|
|
956
|
+
//#endregion
|
|
957
|
+
//#region src/core/utils/metadata/change/select-regions.ts
|
|
958
|
+
/**
|
|
959
|
+
* Select distinct positive-area boxes with deterministic area and coordinate ties.
|
|
960
|
+
* @param boxes - Already scaled output rectangles; the input is not mutated.
|
|
961
|
+
* @param limit - Nonnegative maximum number of regions.
|
|
962
|
+
* @returns Largest boxes ordered by area descending, then y, x and width ascending.
|
|
963
|
+
*/
|
|
964
|
+
function selectRegions(boxes, limit) {
|
|
965
|
+
return filter(boxes, (box, index) => box.width > 0 && box.height > 0 && boxes.findIndex((other) => other.x === box.x && other.y === box.y && other.width === box.width && other.height === box.height) === index).sort((a, b) => b.width * b.height - a.width * a.height || a.y - b.y || a.x - b.x || a.width - b.width).slice(0, limit);
|
|
966
|
+
}
|
|
967
|
+
var init_select_regions = __esmMin((() => {}));
|
|
968
|
+
|
|
969
|
+
//#endregion
|
|
970
|
+
//#region src/core/utils/metadata/change/build-frame-change.ts
|
|
971
|
+
/**
|
|
972
|
+
* Aggregate adjacent-pair evidence across one selected-frame span.
|
|
973
|
+
* @param input - Ordered raw edges, one-based previous ID, skipped count and dimensions.
|
|
974
|
+
* @returns Rounded raw scores, analysis-space union ratio and output-space regions.
|
|
975
|
+
*/
|
|
976
|
+
function buildFrameChange(input) {
|
|
977
|
+
const { spanEdges, fromFrameId, skippedCandidates, analysisResolution, outputResolution, regionLimit } = input;
|
|
978
|
+
const boxes = spanEdges.flatMap((edge) => edge.change?.regions ?? []);
|
|
979
|
+
const area = analysisResolution.width * analysisResolution.height;
|
|
980
|
+
const peakScore = spanEdges.reduce((peak, edge) => Math.max(peak, edge.score), 0);
|
|
981
|
+
const sumScore = spanEdges.reduce((sum, edge) => sum + edge.score, 0);
|
|
982
|
+
const areaRatio = area > 0 ? Math.max(0, Math.min(1, unionArea(boxes) / area)) : 0;
|
|
983
|
+
const regions = area > 0 ? selectRegions(map(boxes, (box) => scaleBoundingBox(box, outputResolution.width / analysisResolution.width, outputResolution.height / analysisResolution.height, outputResolution.width, outputResolution.height)), regionLimit) : [];
|
|
984
|
+
return {
|
|
985
|
+
fromFrameId,
|
|
986
|
+
skippedCandidates,
|
|
987
|
+
peakScore: Math.round(peakScore * 1e6) / 1e6,
|
|
988
|
+
sumScore: Math.round(sumScore * 1e6) / 1e6,
|
|
989
|
+
areaRatio: Math.round(areaRatio * 1e4) / 1e4,
|
|
990
|
+
regions
|
|
991
|
+
};
|
|
992
|
+
}
|
|
993
|
+
var init_build_frame_change = __esmMin((() => {
|
|
994
|
+
init_scale_bounding_box();
|
|
995
|
+
init_select_regions();
|
|
996
|
+
init_union_area();
|
|
997
|
+
}));
|
|
998
|
+
|
|
999
|
+
//#endregion
|
|
1000
|
+
//#region src/core/utils/metadata/build-frame-metadata.ts
|
|
1001
|
+
/**
|
|
1002
|
+
* Describe selected frames using candidate adjacency rather than synthetic pruning edges.
|
|
1003
|
+
* @param input - Chronological candidates and selections, raw graph and output dimensions.
|
|
1004
|
+
* @returns One-based frame summaries with rounded timestamps and nonnegative holds.
|
|
1005
|
+
*/
|
|
1006
|
+
function buildFrameMetadata(input) {
|
|
1007
|
+
const { frames, graph, selected, originalDurationMs, analysisResolution, outputResolution } = input;
|
|
1008
|
+
const edgesByPair = new Map(map(graph, (edge) => [`${edge.sourceId}:${edge.targetId}`, edge]));
|
|
1009
|
+
const candidateIndices = new Map(map(frames, (frame, index) => [frame.id, index]));
|
|
1010
|
+
const padding = Math.max(4, String(frames.length).length);
|
|
1011
|
+
return map(selected, (frame, index) => {
|
|
1012
|
+
const timestampMs = Math.round(frame.timestamp * 1e3);
|
|
1013
|
+
const nextTimestampMs = index + 1 < selected.length ? Math.round(selected[index + 1].timestamp * 1e3) : originalDurationMs;
|
|
1014
|
+
const previous = selected[index - 1];
|
|
1015
|
+
const previousIndex = previous ? candidateIndices.get(previous.id) : 0;
|
|
1016
|
+
const currentIndex = candidateIndices.get(frame.id);
|
|
1017
|
+
const spanEdges = [];
|
|
1018
|
+
if (previous) for (let i = previousIndex; i < currentIndex; i++) {
|
|
1019
|
+
const edge = edgesByPair.get(`${frames[i].id}:${frames[i + 1].id}`);
|
|
1020
|
+
if (edge) spanEdges.push(edge);
|
|
1021
|
+
}
|
|
1022
|
+
return {
|
|
1023
|
+
step: index + 1,
|
|
1024
|
+
fileName: `frame_${String(frame.id + 1).padStart(padding, "0")}.jpg`,
|
|
1025
|
+
frameId: frame.id + 1,
|
|
1026
|
+
timestampMs,
|
|
1027
|
+
holdsMs: Math.max(0, nextTimestampMs - timestampMs),
|
|
1028
|
+
change: previous ? buildFrameChange({
|
|
1029
|
+
spanEdges,
|
|
1030
|
+
fromFrameId: previous.id + 1,
|
|
1031
|
+
skippedCandidates: currentIndex - previousIndex - 1,
|
|
1032
|
+
analysisResolution,
|
|
1033
|
+
outputResolution,
|
|
1034
|
+
regionLimit: 5
|
|
1035
|
+
}) : null
|
|
1036
|
+
};
|
|
1037
|
+
});
|
|
1038
|
+
}
|
|
1039
|
+
var init_build_frame_metadata = __esmMin((() => {
|
|
1040
|
+
init_pipeline_defaults();
|
|
1041
|
+
init_build_frame_change();
|
|
1042
|
+
}));
|
|
1043
|
+
|
|
1044
|
+
//#endregion
|
|
1045
|
+
//#region src/core/utils/metadata/build-tool-metadata.ts
|
|
1046
|
+
/**
|
|
1047
|
+
* Record the tool and the nine selection and encoding settings in contract order.
|
|
1048
|
+
* @param options - Validated pipeline settings; operational options are excluded.
|
|
1049
|
+
* @param version - Runtime package version supplied by the I/O boundary.
|
|
1050
|
+
* @returns Deterministic tool provenance without paths or execution details.
|
|
1051
|
+
*/
|
|
1052
|
+
function buildToolMetadata(options, version) {
|
|
1053
|
+
return {
|
|
1054
|
+
name: "@lumy-pack/scene-sieve",
|
|
1055
|
+
version,
|
|
1056
|
+
params: {
|
|
1057
|
+
fps: options.fps,
|
|
1058
|
+
count: options.count,
|
|
1059
|
+
threshold: options.threshold,
|
|
1060
|
+
scale: options.scale,
|
|
1061
|
+
quality: options.quality,
|
|
1062
|
+
maxFrames: options.maxFrames,
|
|
1063
|
+
iouThreshold: options.iouThreshold,
|
|
1064
|
+
animationThreshold: options.animationThreshold,
|
|
1065
|
+
maxSegmentDuration: options.maxSegmentDuration
|
|
1066
|
+
}
|
|
1067
|
+
};
|
|
1068
|
+
}
|
|
1069
|
+
var init_build_tool_metadata = __esmMin((() => {}));
|
|
1070
|
+
|
|
1071
|
+
//#endregion
|
|
1072
|
+
//#region src/core/utils/metadata/build-sieve-metadata.ts
|
|
1073
|
+
/**
|
|
1074
|
+
* Assemble the complete v2 document without I/O or mutation.
|
|
1075
|
+
* @param input - Pipeline state, selections, output-space video and zero-based animations.
|
|
1076
|
+
* @returns Contract-ordered metadata, omitting unrequested optional keys entirely.
|
|
1077
|
+
*/
|
|
1078
|
+
function buildSieveMetadata(input) {
|
|
1079
|
+
const { ctx, selected, video, animations, version, sheet } = input;
|
|
1080
|
+
const analysisResolution = ctx.analysisResolution ?? {
|
|
1081
|
+
width: 0,
|
|
1082
|
+
height: 0
|
|
1083
|
+
};
|
|
1084
|
+
return {
|
|
1085
|
+
metadataVersion: 2,
|
|
1086
|
+
tool: buildToolMetadata(ctx.options, version),
|
|
1087
|
+
video,
|
|
1088
|
+
frames: buildFrameMetadata({
|
|
1089
|
+
frames: ctx.frames,
|
|
1090
|
+
graph: ctx.graph,
|
|
1091
|
+
selected,
|
|
1092
|
+
originalDurationMs: video.originalDurationMs,
|
|
1093
|
+
analysisResolution,
|
|
1094
|
+
outputResolution: video.resolution
|
|
1095
|
+
}),
|
|
1096
|
+
animations: map(animations, (animation) => ({
|
|
1097
|
+
...animation,
|
|
1098
|
+
startFrameId: animation.startFrameId + 1,
|
|
1099
|
+
endFrameId: animation.endFrameId + 1,
|
|
1100
|
+
durationMs: Math.round(animation.durationMs)
|
|
1101
|
+
})),
|
|
1102
|
+
...sheet ? { sheet } : {},
|
|
1103
|
+
...ctx.options.includeEdges ? { edges: buildEdgeMetadata(ctx.graph, analysisResolution) } : {}
|
|
1104
|
+
};
|
|
1105
|
+
}
|
|
1106
|
+
var init_build_sieve_metadata = __esmMin((() => {
|
|
1107
|
+
init_pipeline_defaults();
|
|
1108
|
+
init_build_edge_metadata();
|
|
1109
|
+
init_build_frame_metadata();
|
|
1110
|
+
init_build_tool_metadata();
|
|
1111
|
+
}));
|
|
1112
|
+
|
|
1113
|
+
//#endregion
|
|
1114
|
+
//#region src/core/utils/metadata/build-video-metadata.ts
|
|
1115
|
+
/**
|
|
1116
|
+
* Read output dimensions and build consistent video and animation metadata.
|
|
1117
|
+
* JPEG finalization does not resize, so the source dimensions match the output.
|
|
1118
|
+
* @param ctx Pipeline state with source duration, effective FPS and analysis-space animations.
|
|
1119
|
+
* @param selected Selected frames in output order; the first candidate is the fallback.
|
|
1120
|
+
* @param analysisResolution Analysis dimensions; absent or zero dimensions imply no scaling.
|
|
1121
|
+
* @returns Video metadata and new output-space animations, retaining zero-based frame IDs.
|
|
1122
|
+
* @throws If sharp cannot read the selected or fallback image. Empty input performs no image I/O.
|
|
1123
|
+
*/
|
|
1124
|
+
async function buildVideoMetadata(ctx, selected, analysisResolution) {
|
|
1125
|
+
const firstFrame = selected[0] ?? ctx.frames[0];
|
|
1126
|
+
const dimensions = firstFrame ? await sharp(firstFrame.extractPath).metadata() : void 0;
|
|
1127
|
+
const width = dimensions?.width ?? 0;
|
|
1128
|
+
const height = dimensions?.height ?? 0;
|
|
1129
|
+
const sx = analysisResolution?.width ? width / analysisResolution.width : 1;
|
|
1130
|
+
const sy = analysisResolution?.height ? height / analysisResolution.height : 1;
|
|
1131
|
+
const lastTimestamp = ctx.frames[ctx.frames.length - 1]?.timestamp ?? 0;
|
|
1132
|
+
const duration = ctx.options.mode === "frames" ? lastTimestamp : ctx.sourceDurationSec ?? lastTimestamp;
|
|
1133
|
+
return {
|
|
1134
|
+
video: {
|
|
1135
|
+
originalDurationMs: Math.round(duration * 1e3),
|
|
1136
|
+
fps: ctx.options.mode === "frames" ? 1 : ctx.effectiveFps ?? ctx.options.fps,
|
|
1137
|
+
resolution: {
|
|
1138
|
+
width,
|
|
1139
|
+
height
|
|
1140
|
+
},
|
|
1141
|
+
candidatesCount: ctx.frames.length,
|
|
1142
|
+
selectedCount: selected.length,
|
|
1143
|
+
source: {
|
|
1144
|
+
fileName: ctx.options.mode === "file" && ctx.options.inputPath ? basename(ctx.options.inputPath) : null,
|
|
1145
|
+
mode: ctx.options.mode
|
|
1146
|
+
}
|
|
1147
|
+
},
|
|
1148
|
+
animations: (ctx.animations ?? []).map((animation) => ({
|
|
1149
|
+
...animation,
|
|
1150
|
+
boundingBox: scaleBoundingBox(animation.boundingBox, sx, sy, width, height)
|
|
1151
|
+
}))
|
|
1152
|
+
};
|
|
1153
|
+
}
|
|
1154
|
+
var init_build_video_metadata = __esmMin((() => {
|
|
1155
|
+
init_scale_bounding_box();
|
|
1156
|
+
}));
|
|
1157
|
+
|
|
1158
|
+
//#endregion
|
|
1159
|
+
//#region src/core/utils/sheet/build-tile-label-svg.ts
|
|
1160
|
+
/**
|
|
1161
|
+
* Build a tile-sized SVG overlay with a translucent badge and an explicit text baseline.
|
|
1162
|
+
* @param text - A formatTileLabel result containing only digits, #, spaces, colons and periods.
|
|
1163
|
+
* @param tileWidth - Positive tile canvas width in pixels.
|
|
1164
|
+
* @param tileHeight - Positive tile canvas height in pixels.
|
|
1165
|
+
* @returns Encoded SVG bytes for a top-left sharp composite overlay.
|
|
1166
|
+
*/
|
|
1167
|
+
function buildTileLabelSvg(text, tileWidth, tileHeight) {
|
|
1168
|
+
const fontSize = Math.max(8, Math.round(tileHeight * SHEET_LABEL_HEIGHT_RATIO));
|
|
1169
|
+
const padX = Math.round(fontSize * .4);
|
|
1170
|
+
const padY = Math.round(fontSize * .2);
|
|
1171
|
+
const badgeWidth = Math.min(tileWidth, Math.ceil(text.length * fontSize * .6) + 2 * padX);
|
|
1172
|
+
return Buffer.from(`<svg xmlns="http://www.w3.org/2000/svg" width="${tileWidth}" height="${tileHeight}"><rect x="0" y="0" width="${badgeWidth}" height="${fontSize + 2 * padY}" fill="black" fill-opacity="0.5"/><text x="${padX}" y="${padY + Math.round(fontSize * .8)}" font-family="sans-serif" font-size="${fontSize}" fill="white">${text}</text></svg>`);
|
|
1173
|
+
}
|
|
1174
|
+
var init_build_tile_label_svg = __esmMin((() => {
|
|
1175
|
+
init_pipeline_defaults();
|
|
1176
|
+
}));
|
|
1177
|
+
|
|
1178
|
+
//#endregion
|
|
1179
|
+
//#region src/core/utils/sheet/format-tile-label.ts
|
|
1180
|
+
/**
|
|
1181
|
+
* Format a contact sheet label without rounding into the next tenth of a second.
|
|
1182
|
+
* @param frameId - One-based candidate frame ID.
|
|
1183
|
+
* @param timestampMs - Nonnegative candidate time in milliseconds.
|
|
1184
|
+
* @returns A label in the form "#<id> mm:ss.s", allowing minutes beyond two digits.
|
|
1185
|
+
*/
|
|
1186
|
+
function formatTileLabel(frameId, timestampMs) {
|
|
1187
|
+
const tenths = Math.floor(timestampMs / 100);
|
|
1188
|
+
return `#${frameId} ${String(Math.floor(tenths / 600)).padStart(2, "0")}:${String(Math.floor(tenths / 10) % 60).padStart(2, "0")}.${tenths % 10}`;
|
|
1189
|
+
}
|
|
1190
|
+
var init_format_tile_label = __esmMin((() => {}));
|
|
1191
|
+
|
|
1192
|
+
//#endregion
|
|
1193
|
+
//#region src/core/utils/sheet/sample-tile-frames.ts
|
|
1194
|
+
/**
|
|
1195
|
+
* Sample a sequence uniformly while always retaining both endpoints.
|
|
1196
|
+
* @param items - Items in temporal order.
|
|
1197
|
+
* @param maxTiles - Validated integer limit of at least two.
|
|
1198
|
+
* @returns The original sequence when within the limit, otherwise evenly sampled items.
|
|
1199
|
+
*/
|
|
1200
|
+
function sampleTileFrames(items, maxTiles) {
|
|
1201
|
+
if (items.length <= maxTiles) return {
|
|
1202
|
+
items,
|
|
1203
|
+
sampled: false
|
|
1204
|
+
};
|
|
1205
|
+
return {
|
|
1206
|
+
items: Array.from({ length: maxTiles }, (_, i) => items[Math.round(i * (items.length - 1) / (maxTiles - 1))]),
|
|
1207
|
+
sampled: true
|
|
1208
|
+
};
|
|
1209
|
+
}
|
|
1210
|
+
var init_sample_tile_frames = __esmMin((() => {}));
|
|
1211
|
+
|
|
1212
|
+
//#endregion
|
|
1213
|
+
//#region src/core/utils/sheet/render-contact-sheet.ts
|
|
1214
|
+
/**
|
|
1215
|
+
* Read selected frame images and render a row-major contact sheet.
|
|
1216
|
+
* @param input - Nonempty ordered selections, positive dimensions and validated sheet settings.
|
|
1217
|
+
* @returns JPEG bytes and the effective tile layout with one-based frame IDs.
|
|
1218
|
+
* @throws Propagates sharp image reading, compositing or encoding errors.
|
|
1219
|
+
*/
|
|
1220
|
+
async function renderContactSheet(input) {
|
|
1221
|
+
const { selected, resolution, options, quality } = input;
|
|
1222
|
+
const { items, sampled } = sampleTileFrames(selected, options.maxTiles);
|
|
1223
|
+
const columns = Math.min(options.columns, items.length);
|
|
1224
|
+
const rows = Math.ceil(items.length / columns);
|
|
1225
|
+
const tileWidth = options.tileWidth;
|
|
1226
|
+
const tileHeight = Math.max(1, Math.round(tileWidth * resolution.height / resolution.width));
|
|
1227
|
+
const gap = 4;
|
|
1228
|
+
const tiles = await Promise.all(map(items, async (frame, index) => {
|
|
1229
|
+
const tile = sharp(frame.extractPath).resize(tileWidth, tileHeight, { fit: "fill" });
|
|
1230
|
+
if (options.label) {
|
|
1231
|
+
const text = formatTileLabel(frame.id + 1, Math.round(frame.timestamp * 1e3));
|
|
1232
|
+
tile.composite([{
|
|
1233
|
+
input: buildTileLabelSvg(text, tileWidth, tileHeight),
|
|
1234
|
+
top: 0,
|
|
1235
|
+
left: 0
|
|
1236
|
+
}]);
|
|
1237
|
+
}
|
|
1238
|
+
return {
|
|
1239
|
+
input: await tile.png().toBuffer(),
|
|
1240
|
+
top: gap + Math.floor(index / columns) * (tileHeight + gap),
|
|
1241
|
+
left: gap + index % columns * (tileWidth + gap)
|
|
1242
|
+
};
|
|
1243
|
+
}));
|
|
1244
|
+
return {
|
|
1245
|
+
buffer: await sharp({ create: {
|
|
1246
|
+
width: columns * tileWidth + (columns + 1) * gap,
|
|
1247
|
+
height: rows * tileHeight + (rows + 1) * gap,
|
|
1248
|
+
channels: 3,
|
|
1249
|
+
background: "white"
|
|
1250
|
+
} }).composite(tiles).jpeg({
|
|
1251
|
+
quality,
|
|
1252
|
+
mozjpeg: true
|
|
1253
|
+
}).toBuffer(),
|
|
1254
|
+
metadata: {
|
|
1255
|
+
fileName: SHEET_FILE_NAME,
|
|
1256
|
+
columns,
|
|
1257
|
+
tileWidth,
|
|
1258
|
+
tileHeight,
|
|
1259
|
+
frameIds: map(items, (frame) => frame.id + 1),
|
|
1260
|
+
sampled
|
|
1261
|
+
}
|
|
1262
|
+
};
|
|
1263
|
+
}
|
|
1264
|
+
var init_render_contact_sheet = __esmMin((() => {
|
|
1265
|
+
init_pipeline_defaults();
|
|
1266
|
+
init_build_tile_label_svg();
|
|
1267
|
+
init_format_tile_label();
|
|
1268
|
+
init_sample_tile_frames();
|
|
1269
|
+
}));
|
|
1270
|
+
|
|
1271
|
+
//#endregion
|
|
1272
|
+
//#region src/core/utils/output/finalize-selection.ts
|
|
1273
|
+
/**
|
|
1274
|
+
* Read output dimensions once, render optional sheets and finalize the shared v2 document.
|
|
1275
|
+
* @param ctx - Pipeline state owned by the orchestrator; this function does not mutate it.
|
|
1276
|
+
* @param selected - Selected frames in temporal order.
|
|
1277
|
+
* @returns Mode-specific output, the document and zero-based API animations.
|
|
1278
|
+
* @throws Propagates image, rendering and output I/O errors to the orchestrator.
|
|
1279
|
+
*/
|
|
1280
|
+
async function finalizeSelection(ctx, selected) {
|
|
1281
|
+
const { video, animations } = await buildVideoMetadata(ctx, selected, ctx.analysisResolution);
|
|
1282
|
+
const sheet = ctx.options.sheet && selected.length > 0 && video.resolution.width > 0 && video.resolution.height > 0 ? await renderContactSheet({
|
|
1283
|
+
selected,
|
|
1284
|
+
resolution: video.resolution,
|
|
1285
|
+
options: ctx.options.sheet,
|
|
1286
|
+
quality: ctx.options.quality
|
|
1287
|
+
}) : void 0;
|
|
1288
|
+
const document = buildSieveMetadata({
|
|
1289
|
+
ctx,
|
|
1290
|
+
selected,
|
|
1291
|
+
video,
|
|
1292
|
+
animations,
|
|
1293
|
+
version: PACKAGE_VERSION,
|
|
1294
|
+
...sheet ? { sheet: sheet.metadata } : {}
|
|
1295
|
+
});
|
|
1296
|
+
if (ctx.options.mode === "file") return {
|
|
1297
|
+
outputFiles: await finalizeOutput(ctx, selected, document, sheet?.buffer),
|
|
1298
|
+
document,
|
|
1299
|
+
animations
|
|
1300
|
+
};
|
|
1301
|
+
return {
|
|
1302
|
+
outputFiles: [],
|
|
1303
|
+
outputBuffers: await readFramesAsBuffers(selected, ctx.options.quality),
|
|
1304
|
+
document,
|
|
1305
|
+
animations,
|
|
1306
|
+
...sheet ? { sheetBuffer: sheet.buffer } : {}
|
|
1307
|
+
};
|
|
1308
|
+
}
|
|
1309
|
+
var init_finalize_selection = __esmMin((() => {
|
|
1310
|
+
init_package_version();
|
|
1311
|
+
init_workspace();
|
|
1312
|
+
init_build_sieve_metadata();
|
|
1313
|
+
init_build_video_metadata();
|
|
1314
|
+
init_render_contact_sheet();
|
|
1315
|
+
}));
|
|
1316
|
+
|
|
1317
|
+
//#endregion
|
|
1318
|
+
//#region src/core/extractor/extractor.ts
|
|
523
1319
|
/**
|
|
524
1320
|
* Extract frames from video/GIF using FFmpeg.
|
|
525
|
-
*
|
|
526
|
-
*
|
|
1321
|
+
* @param ctx Pipeline context; records effectiveFps and sourceDurationSec for video input.
|
|
1322
|
+
* @returns Extracted candidates, or the unchanged input array in frames mode.
|
|
1323
|
+
* @throws When the input is missing, metadata has no video stream, or FFmpeg fails.
|
|
527
1324
|
*/
|
|
528
1325
|
async function extractFrames(ctx) {
|
|
1326
|
+
if (ctx.options.mode === "frames") {
|
|
1327
|
+
ctx.effectiveFps = 1;
|
|
1328
|
+
return ctx.frames;
|
|
1329
|
+
}
|
|
529
1330
|
const framesDir = join(ctx.workspacePath, "frames");
|
|
530
1331
|
const { inputPath, fps, maxFrames, scale } = ctx.options;
|
|
531
1332
|
if (!inputPath) throw new Error("inputPath is required for frame extraction");
|
|
@@ -540,19 +1341,30 @@ async function extractFrames(ctx) {
|
|
|
540
1341
|
if (!(metadata.streams?.some((s) => s.codec_type === "video") ?? false)) throw new Error(`No video stream found in file: ${inputPath} (detected format: ${formatName})`);
|
|
541
1342
|
logger.debug(`Detected format: ${formatName} (Duration: ${duration.toFixed(1)}s), path: ${inputPath}`);
|
|
542
1343
|
await ensureDir(framesDir);
|
|
1344
|
+
const frameLimit = Math.max(2, maxFrames);
|
|
543
1345
|
let effectiveFps = fps;
|
|
544
1346
|
if (duration > 0) {
|
|
545
|
-
const fpsCap =
|
|
1347
|
+
const fpsCap = frameLimit / duration;
|
|
546
1348
|
effectiveFps = Math.min(fps, fpsCap);
|
|
547
|
-
effectiveFps = Math.max(.5, effectiveFps);
|
|
548
1349
|
logger.debug(`FPS: ${fps} → effective: ${effectiveFps.toFixed(2)} (maxFrames: ${maxFrames})`);
|
|
549
1350
|
}
|
|
550
|
-
|
|
1351
|
+
ctx.effectiveFps = effectiveFps;
|
|
1352
|
+
ctx.sourceDurationSec = duration;
|
|
1353
|
+
const frames = await extractByFps(inputPath, framesDir, effectiveFps, scale, frameLimit);
|
|
551
1354
|
ctx.emitProgress(100);
|
|
552
1355
|
logger.debug(`Extracted ${frames.length} frames`);
|
|
553
1356
|
return frames;
|
|
554
1357
|
}
|
|
555
|
-
|
|
1358
|
+
/**
|
|
1359
|
+
* Write scaled JPEG candidates through the bundled FFmpeg runtime.
|
|
1360
|
+
* @param inputPath Readable video input.
|
|
1361
|
+
* @param outputDir Existing frame directory.
|
|
1362
|
+
* @param fps Positive effective sampling frequency.
|
|
1363
|
+
* @param scale Output image height.
|
|
1364
|
+
* @param frameLimit Maximum number of output frames.
|
|
1365
|
+
* @returns Candidates with local output-grid timestamps; rejects on extraction failure.
|
|
1366
|
+
*/
|
|
1367
|
+
async function extractByFps(inputPath, outputDir, fps, scale, frameLimit) {
|
|
556
1368
|
const outputPattern = join(outputDir, FRAME_FILENAME_PATTERN);
|
|
557
1369
|
await execa(ffmpegPath, [
|
|
558
1370
|
"-i",
|
|
@@ -561,9 +1373,11 @@ async function extractByFps(inputPath, outputDir, fps, scale, duration) {
|
|
|
561
1373
|
`fps=${fps},scale=-1:${scale}`,
|
|
562
1374
|
"-q:v",
|
|
563
1375
|
"2",
|
|
1376
|
+
"-frames:v",
|
|
1377
|
+
String(frameLimit),
|
|
564
1378
|
outputPattern
|
|
565
1379
|
]);
|
|
566
|
-
return buildFrameList(outputDir,
|
|
1380
|
+
return buildFrameList(outputDir, fps);
|
|
567
1381
|
}
|
|
568
1382
|
async function getVideoMetadata(inputPath) {
|
|
569
1383
|
const { stdout } = await execa(path, [
|
|
@@ -577,12 +1391,19 @@ async function getVideoMetadata(inputPath) {
|
|
|
577
1391
|
]);
|
|
578
1392
|
return JSON.parse(stdout);
|
|
579
1393
|
}
|
|
580
|
-
|
|
581
|
-
|
|
1394
|
+
/**
|
|
1395
|
+
* Read sorted JPEG paths and attach local output-grid times.
|
|
1396
|
+
* @param framesDir Extracted frame directory; filesystem errors propagate.
|
|
1397
|
+
* @param effectiveFps Positive frequency used by the fps filter.
|
|
1398
|
+
* @returns Zero-based candidates without a segment seek offset.
|
|
1399
|
+
*/
|
|
1400
|
+
async function buildFrameList(framesDir, effectiveFps) {
|
|
1401
|
+
const files = await readdir(framesDir);
|
|
1402
|
+
const jpgFiles = filter(files, (f) => f.endsWith(".jpg")).sort();
|
|
582
1403
|
if (jpgFiles.length === 0) return [];
|
|
583
1404
|
return map(jpgFiles, (file, index) => ({
|
|
584
1405
|
id: index,
|
|
585
|
-
timestamp:
|
|
1406
|
+
timestamp: index / effectiveFps,
|
|
586
1407
|
extractPath: join(framesDir, file)
|
|
587
1408
|
}));
|
|
588
1409
|
}
|
|
@@ -596,9 +1417,10 @@ async function buildFrameList(framesDir, duration) {
|
|
|
596
1417
|
* @param scale - Height scale for vision analysis
|
|
597
1418
|
* @param startTime - Start time in seconds
|
|
598
1419
|
* @param duration - Duration in seconds to extract
|
|
1420
|
+
* @param frameLimit - Positive output limit; defaults to the range's grid capacity
|
|
599
1421
|
* @returns Array of FrameNode with segment-local timestamps (starting from 0)
|
|
600
1422
|
*/
|
|
601
|
-
async function extractFramesForRange(inputPath, outputDir, fps, scale, startTime, duration) {
|
|
1423
|
+
async function extractFramesForRange(inputPath, outputDir, fps, scale, startTime, duration, frameLimit = Math.ceil(duration * fps)) {
|
|
602
1424
|
const outputPattern = join(outputDir, FRAME_FILENAME_PATTERN);
|
|
603
1425
|
await execa(ffmpegPath, [
|
|
604
1426
|
"-ss",
|
|
@@ -611,139 +1433,157 @@ async function extractFramesForRange(inputPath, outputDir, fps, scale, startTime
|
|
|
611
1433
|
`fps=${fps},scale=-1:${scale}`,
|
|
612
1434
|
"-q:v",
|
|
613
1435
|
"2",
|
|
1436
|
+
"-frames:v",
|
|
1437
|
+
String(frameLimit),
|
|
614
1438
|
outputPattern
|
|
615
1439
|
]);
|
|
616
|
-
return buildFrameList(outputDir,
|
|
1440
|
+
return buildFrameList(outputDir, fps);
|
|
617
1441
|
}
|
|
1442
|
+
var init_extractor$1 = __esmMin((() => {
|
|
1443
|
+
init_workspace_layout();
|
|
1444
|
+
init_logger();
|
|
1445
|
+
init_paths();
|
|
1446
|
+
}));
|
|
618
1447
|
|
|
619
1448
|
//#endregion
|
|
620
|
-
//#region src/core/
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
async function finalizeOutput(ctx, selectedFrames) {
|
|
628
|
-
const stagingDir = join(ctx.workspacePath, "output");
|
|
629
|
-
const outputPath = ctx.options.outputPath;
|
|
630
|
-
const quality = ctx.options.quality;
|
|
631
|
-
const outputFiles = [];
|
|
632
|
-
const framesMetadata = [];
|
|
633
|
-
const totalFramesCount = ctx.frames.length;
|
|
634
|
-
const padding = Math.max(4, String(totalFramesCount).length);
|
|
635
|
-
for (let i = 0; i < selectedFrames.length; i++) {
|
|
636
|
-
const frame = selectedFrames[i];
|
|
637
|
-
const fileName = `frame_${String(frame.id + 1).padStart(padding, "0")}.jpg`;
|
|
638
|
-
const destPath = join(stagingDir, fileName);
|
|
639
|
-
await sharp(frame.extractPath).jpeg({
|
|
640
|
-
quality,
|
|
641
|
-
mozjpeg: true
|
|
642
|
-
}).toFile(destPath);
|
|
643
|
-
outputFiles.push(join(outputPath, fileName));
|
|
644
|
-
framesMetadata.push({
|
|
645
|
-
step: i + 1,
|
|
646
|
-
fileName,
|
|
647
|
-
frameId: frame.id + 1,
|
|
648
|
-
timestampMs: Math.round(frame.timestamp * 1e3)
|
|
649
|
-
});
|
|
650
|
-
}
|
|
651
|
-
const metadata = {
|
|
652
|
-
video: {
|
|
653
|
-
originalDurationMs: Math.round((ctx.frames.length > 0 ? ctx.frames[ctx.frames.length - 1].timestamp : 0) * 1e3),
|
|
654
|
-
fps: ctx.options.fps,
|
|
655
|
-
resolution: {
|
|
656
|
-
width: ctx.options.scale,
|
|
657
|
-
height: Math.round(ctx.options.scale * 9 / 16)
|
|
658
|
-
}
|
|
659
|
-
},
|
|
660
|
-
frames: framesMetadata,
|
|
661
|
-
animations: map(ctx.animations || [], (anim) => ({
|
|
662
|
-
...anim,
|
|
663
|
-
startFrameId: anim.startFrameId + 1,
|
|
664
|
-
endFrameId: anim.endFrameId + 1,
|
|
665
|
-
durationMs: Math.round(anim.durationMs)
|
|
666
|
-
}))
|
|
667
|
-
};
|
|
668
|
-
await writeFile(join(stagingDir, ".metadata.json"), JSON.stringify(metadata, null, 2));
|
|
669
|
-
outputFiles.push(join(outputPath, ".metadata.json"));
|
|
670
|
-
await ensureDir(join(outputPath, ".."));
|
|
671
|
-
await rm(outputPath, {
|
|
672
|
-
recursive: true,
|
|
673
|
-
force: true
|
|
674
|
-
});
|
|
675
|
-
await rename(stagingDir, outputPath);
|
|
676
|
-
return outputFiles;
|
|
677
|
-
}
|
|
678
|
-
async function createSegmentWorkspace(parentWorkspacePath, segmentIndex) {
|
|
679
|
-
const segmentPath = join(parentWorkspacePath, "segments", String(segmentIndex));
|
|
680
|
-
await ensureDir(join(segmentPath, "frames"));
|
|
681
|
-
return segmentPath;
|
|
682
|
-
}
|
|
683
|
-
async function cleanupWorkspace(workspacePath) {
|
|
684
|
-
if (!workspacePath) return;
|
|
685
|
-
try {
|
|
686
|
-
await rm(workspacePath, {
|
|
687
|
-
recursive: true,
|
|
688
|
-
force: true
|
|
689
|
-
});
|
|
690
|
-
} catch {}
|
|
691
|
-
}
|
|
692
|
-
/**
|
|
693
|
-
* Write a video buffer to a temp file in the workspace and return the path.
|
|
694
|
-
* Used by 'buffer' input mode.
|
|
695
|
-
*/
|
|
696
|
-
async function writeInputBuffer(buffer, workspacePath) {
|
|
697
|
-
const inputDir = join(workspacePath, "input");
|
|
698
|
-
await ensureDir(inputDir);
|
|
699
|
-
const tempPath = join(inputDir, "input.mp4");
|
|
700
|
-
await writeFile(tempPath, buffer);
|
|
701
|
-
return tempPath;
|
|
702
|
-
}
|
|
1449
|
+
//#region src/core/extractor/index.ts
|
|
1450
|
+
var init_extractor = __esmMin((() => {
|
|
1451
|
+
init_extractor$1();
|
|
1452
|
+
}));
|
|
1453
|
+
|
|
1454
|
+
//#endregion
|
|
1455
|
+
//#region src/core/input-resolver/validation/validate-options.ts
|
|
703
1456
|
/**
|
|
704
|
-
*
|
|
705
|
-
*
|
|
1457
|
+
* Reject invalid numeric options before defaults or pipeline effects are applied.
|
|
1458
|
+
* @param options - Supplied options; omitted numeric fields use pipeline defaults.
|
|
1459
|
+
* @returns Nothing when all supplied numeric fields satisfy their contracts.
|
|
1460
|
+
* @throws An input error naming the invalid option and its received value.
|
|
706
1461
|
*/
|
|
707
|
-
|
|
708
|
-
const
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
1462
|
+
function validateOptions(options) {
|
|
1463
|
+
for (const [name, min, max, integer, exclusiveMin, requirement] of [
|
|
1464
|
+
[
|
|
1465
|
+
"count",
|
|
1466
|
+
1,
|
|
1467
|
+
Infinity,
|
|
1468
|
+
true,
|
|
1469
|
+
false,
|
|
1470
|
+
"an integer >= 1"
|
|
1471
|
+
],
|
|
1472
|
+
[
|
|
1473
|
+
"threshold",
|
|
1474
|
+
0,
|
|
1475
|
+
1,
|
|
1476
|
+
false,
|
|
1477
|
+
true,
|
|
1478
|
+
"in range (0, 1] and finite"
|
|
1479
|
+
],
|
|
1480
|
+
[
|
|
1481
|
+
"fps",
|
|
1482
|
+
0,
|
|
1483
|
+
Infinity,
|
|
1484
|
+
false,
|
|
1485
|
+
true,
|
|
1486
|
+
"finite and > 0"
|
|
1487
|
+
],
|
|
1488
|
+
[
|
|
1489
|
+
"maxFrames",
|
|
1490
|
+
2,
|
|
1491
|
+
Infinity,
|
|
1492
|
+
true,
|
|
1493
|
+
false,
|
|
1494
|
+
"an integer >= 2"
|
|
1495
|
+
],
|
|
1496
|
+
[
|
|
1497
|
+
"scale",
|
|
1498
|
+
16,
|
|
1499
|
+
Infinity,
|
|
1500
|
+
true,
|
|
1501
|
+
false,
|
|
1502
|
+
"an integer >= 16"
|
|
1503
|
+
],
|
|
1504
|
+
[
|
|
1505
|
+
"quality",
|
|
1506
|
+
1,
|
|
1507
|
+
100,
|
|
1508
|
+
true,
|
|
1509
|
+
false,
|
|
1510
|
+
"an integer in range [1, 100]"
|
|
1511
|
+
],
|
|
1512
|
+
[
|
|
1513
|
+
"iouThreshold",
|
|
1514
|
+
0,
|
|
1515
|
+
1,
|
|
1516
|
+
false,
|
|
1517
|
+
false,
|
|
1518
|
+
"finite and in range [0, 1]"
|
|
1519
|
+
],
|
|
1520
|
+
[
|
|
1521
|
+
"animationThreshold",
|
|
1522
|
+
1,
|
|
1523
|
+
Infinity,
|
|
1524
|
+
true,
|
|
1525
|
+
false,
|
|
1526
|
+
"an integer >= 1"
|
|
1527
|
+
],
|
|
1528
|
+
[
|
|
1529
|
+
"maxSegmentDuration",
|
|
1530
|
+
0,
|
|
1531
|
+
Infinity,
|
|
1532
|
+
false,
|
|
1533
|
+
true,
|
|
1534
|
+
"finite and > 0"
|
|
1535
|
+
],
|
|
1536
|
+
[
|
|
1537
|
+
"concurrency",
|
|
1538
|
+
1,
|
|
1539
|
+
Infinity,
|
|
1540
|
+
true,
|
|
1541
|
+
false,
|
|
1542
|
+
"an integer >= 1"
|
|
1543
|
+
]
|
|
1544
|
+
]) {
|
|
1545
|
+
const value = options[name];
|
|
1546
|
+
if (value === void 0) continue;
|
|
1547
|
+
if (!Number.isFinite(value) || integer && !Number.isInteger(value) || (exclusiveMin ? value <= min : value < min) || value > max) throw new Error(`${name} must be ${requirement}, received: ${value}`);
|
|
719
1548
|
}
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
1549
|
+
const sheet = options.sheet;
|
|
1550
|
+
if (sheet === void 0 || typeof sheet === "boolean") return;
|
|
1551
|
+
if (sheet === null || typeof sheet !== "object" || Array.isArray(sheet)) throw new Error(`sheet must be a boolean or an object, received: ${Array.isArray(sheet) ? "array" : String(sheet)}`);
|
|
1552
|
+
for (const [name, min] of [
|
|
1553
|
+
["columns", 1],
|
|
1554
|
+
["tileWidth", 16],
|
|
1555
|
+
["maxTiles", 2]
|
|
1556
|
+
]) {
|
|
1557
|
+
const value = sheet[name];
|
|
1558
|
+
if (value === void 0) continue;
|
|
1559
|
+
if (!Number.isInteger(value) || value < min) throw new Error(`sheet.${name} must be an integer >= ${min}, received: ${value}`);
|
|
1560
|
+
}
|
|
1561
|
+
if (sheet.label !== void 0 && typeof sheet.label !== "boolean") throw new Error(`sheet.label must be a boolean, received: ${sheet.label}`);
|
|
731
1562
|
}
|
|
1563
|
+
var init_validate_options = __esmMin((() => {}));
|
|
732
1564
|
|
|
733
1565
|
//#endregion
|
|
734
|
-
//#region src/core/input-resolver.ts
|
|
1566
|
+
//#region src/core/input-resolver/input-resolver.ts
|
|
1567
|
+
/**
|
|
1568
|
+
* Validate supplied options and resolve defaults and paths for the pipeline.
|
|
1569
|
+
* @param options - Mode-specific input and optional numeric settings.
|
|
1570
|
+
* @returns Complete pipeline settings with absolute file input paths.
|
|
1571
|
+
* @throws An input error if a supplied numeric setting is invalid.
|
|
1572
|
+
*/
|
|
735
1573
|
function resolveOptions(options) {
|
|
1574
|
+
validateOptions(options);
|
|
736
1575
|
const mode = options.mode;
|
|
737
1576
|
const inputPath = mode === "file" ? resolveAbsolute(options.inputPath) : void 0;
|
|
738
1577
|
const outputPath = options.outputPath ?? (inputPath ? deriveOutputPath(inputPath) : join(process.cwd(), "scene-sieve-output"));
|
|
739
1578
|
const threshold = options.threshold ?? .5;
|
|
740
|
-
|
|
1579
|
+
const pruneMode = "threshold-with-cap";
|
|
1580
|
+
const sheet = typeof options.sheet === "object" ? options.sheet : {};
|
|
741
1581
|
return {
|
|
742
1582
|
mode,
|
|
743
1583
|
inputPath,
|
|
744
1584
|
count: options.count ?? 20,
|
|
745
1585
|
threshold,
|
|
746
|
-
pruneMode
|
|
1586
|
+
pruneMode,
|
|
747
1587
|
outputPath,
|
|
748
1588
|
fps: options.fps ?? 5,
|
|
749
1589
|
maxFrames: options.maxFrames ?? 300,
|
|
@@ -753,7 +1593,14 @@ function resolveOptions(options) {
|
|
|
753
1593
|
animationThreshold: options.animationThreshold ?? 5,
|
|
754
1594
|
debug: options.debug ?? false,
|
|
755
1595
|
maxSegmentDuration: options.maxSegmentDuration ?? 300,
|
|
756
|
-
concurrency: options.concurrency ?? 2
|
|
1596
|
+
concurrency: options.concurrency ?? 2,
|
|
1597
|
+
sheet: options.sheet ? {
|
|
1598
|
+
columns: sheet.columns ?? 4,
|
|
1599
|
+
tileWidth: sheet.tileWidth ?? 320,
|
|
1600
|
+
maxTiles: sheet.maxTiles ?? 40,
|
|
1601
|
+
label: sheet.label ?? true
|
|
1602
|
+
} : null,
|
|
1603
|
+
includeEdges: options.includeEdges ?? false
|
|
757
1604
|
};
|
|
758
1605
|
}
|
|
759
1606
|
/**
|
|
@@ -762,6 +1609,10 @@ function resolveOptions(options) {
|
|
|
762
1609
|
* - 'file' mode: validate file exists and delegate to extractor (caller's responsibility)
|
|
763
1610
|
* - 'buffer' mode: write buffer as temp video file, return path via FrameNode trick (empty list)
|
|
764
1611
|
* - 'frames' mode: write frame buffers as JPGs, return FrameNode[]
|
|
1612
|
+
* @param options - Input source; encoded frames must have matching dimensions.
|
|
1613
|
+
* @param workspacePath - Workspace receiving temporary input files.
|
|
1614
|
+
* @returns Frame nodes or a resolved video path for extraction.
|
|
1615
|
+
* @throws Propagates metadata or write errors and rejects mismatched frame sizes.
|
|
765
1616
|
*/
|
|
766
1617
|
async function resolveInput(options, workspacePath) {
|
|
767
1618
|
if (options.mode === "file") return {
|
|
@@ -772,12 +1623,51 @@ async function resolveInput(options, workspacePath) {
|
|
|
772
1623
|
frames: [],
|
|
773
1624
|
resolvedInputPath: await writeInputBuffer(options.inputBuffer, workspacePath)
|
|
774
1625
|
};
|
|
775
|
-
if (options.mode === "frames")
|
|
1626
|
+
if (options.mode === "frames") {
|
|
1627
|
+
let dimensions;
|
|
1628
|
+
for (const buffer of options.inputFrames) {
|
|
1629
|
+
const { width, height } = await sharp(buffer).metadata();
|
|
1630
|
+
if (dimensions && (width !== dimensions.width || height !== dimensions.height)) throw new Error(`inputFrames must be the same size (${dimensions.width}x${dimensions.height}), received: ${width}x${height}`);
|
|
1631
|
+
dimensions = {
|
|
1632
|
+
width,
|
|
1633
|
+
height
|
|
1634
|
+
};
|
|
1635
|
+
}
|
|
1636
|
+
return { frames: await writeInputFrames(options.inputFrames, workspacePath) };
|
|
1637
|
+
}
|
|
776
1638
|
throw new Error(`Unsupported input mode: ${options.mode}`);
|
|
777
1639
|
}
|
|
1640
|
+
var init_input_resolver$1 = __esmMin((() => {
|
|
1641
|
+
init_pipeline_defaults();
|
|
1642
|
+
init_paths();
|
|
1643
|
+
init_validate_options();
|
|
1644
|
+
init_workspace();
|
|
1645
|
+
}));
|
|
1646
|
+
|
|
1647
|
+
//#endregion
|
|
1648
|
+
//#region src/core/input-resolver/index.ts
|
|
1649
|
+
var init_input_resolver = __esmMin((() => {
|
|
1650
|
+
init_input_resolver$1();
|
|
1651
|
+
}));
|
|
778
1652
|
|
|
779
1653
|
//#endregion
|
|
780
|
-
//#region src/
|
|
1654
|
+
//#region src/core/pruner/scoring/normalize-scores.ts
|
|
1655
|
+
/**
|
|
1656
|
+
* Find the first position whose score is at least the requested value.
|
|
1657
|
+
* @param sorted - Finite positive scores sorted in ascending order.
|
|
1658
|
+
* @param value - A finite positive score present in sorted.
|
|
1659
|
+
* @returns The first matching rank, including the first position of any tie.
|
|
1660
|
+
*/
|
|
1661
|
+
function lowerBound(sorted, value) {
|
|
1662
|
+
let low = 0;
|
|
1663
|
+
let high = sorted.length;
|
|
1664
|
+
while (low < high) {
|
|
1665
|
+
const mid = Math.floor((low + high) / 2);
|
|
1666
|
+
if (sorted[mid] < value) low = mid + 1;
|
|
1667
|
+
else high = mid;
|
|
1668
|
+
}
|
|
1669
|
+
return low;
|
|
1670
|
+
}
|
|
781
1671
|
/**
|
|
782
1672
|
* Normalize raw scores to [0, 1] range via Robust Hybrid Normalization.
|
|
783
1673
|
*
|
|
@@ -822,64 +1712,67 @@ function normalizeScores(items) {
|
|
|
822
1712
|
});
|
|
823
1713
|
const cdf = map(safeScores, (s) => {
|
|
824
1714
|
if (s <= 0) return 0;
|
|
825
|
-
return sorted
|
|
1715
|
+
return lowerBound(sorted, s) / sorted.length;
|
|
826
1716
|
});
|
|
827
|
-
return map(logisticZ, (z, i) => z *
|
|
1717
|
+
return map(logisticZ, (z, i) => z * .6 + cdf[i] * NORMALIZATION_ALPHA);
|
|
828
1718
|
}
|
|
1719
|
+
var NORMALIZATION_ALPHA, NORMALIZATION_MAD_COEFFICIENT, NORMALIZATION_MIN_SAMPLE_SIZE;
|
|
1720
|
+
var init_normalize_scores = __esmMin((() => {
|
|
1721
|
+
NORMALIZATION_ALPHA = .4;
|
|
1722
|
+
NORMALIZATION_MAD_COEFFICIENT = 1.4826;
|
|
1723
|
+
NORMALIZATION_MIN_SAMPLE_SIZE = 10;
|
|
1724
|
+
}));
|
|
829
1725
|
|
|
830
1726
|
//#endregion
|
|
831
|
-
//#region src/
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
var MinHeap = class {
|
|
839
|
-
h = [];
|
|
840
|
-
get size() {
|
|
841
|
-
return this.h.length;
|
|
842
|
-
}
|
|
843
|
-
push(entry) {
|
|
844
|
-
this.h.push(entry);
|
|
845
|
-
this.siftUp(this.h.length - 1);
|
|
846
|
-
}
|
|
847
|
-
pop() {
|
|
848
|
-
const n = this.h.length;
|
|
849
|
-
if (n === 0) return void 0;
|
|
850
|
-
const top = this.h[0];
|
|
851
|
-
const last = this.h.pop();
|
|
852
|
-
if (n > 1) {
|
|
853
|
-
this.h[0] = last;
|
|
854
|
-
this.siftDown(0);
|
|
1727
|
+
//#region src/core/pruner/heap/min-heap.ts
|
|
1728
|
+
var MinHeap;
|
|
1729
|
+
var init_min_heap = __esmMin((() => {
|
|
1730
|
+
MinHeap = class {
|
|
1731
|
+
h = [];
|
|
1732
|
+
get size() {
|
|
1733
|
+
return this.h.length;
|
|
855
1734
|
}
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
while (i > 0) {
|
|
860
|
-
const p = i - 1 >> 1;
|
|
861
|
-
if (this.h[p].score <= this.h[i].score) break;
|
|
862
|
-
[this.h[p], this.h[i]] = [this.h[i], this.h[p]];
|
|
863
|
-
i = p;
|
|
1735
|
+
push(entry) {
|
|
1736
|
+
this.h.push(entry);
|
|
1737
|
+
this.siftUp(this.h.length - 1);
|
|
864
1738
|
}
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
[this.h[m], this.h[i]] = [this.h[i], this.h[m]];
|
|
876
|
-
i = m;
|
|
1739
|
+
pop() {
|
|
1740
|
+
const n = this.h.length;
|
|
1741
|
+
if (n === 0) return void 0;
|
|
1742
|
+
const top = this.h[0];
|
|
1743
|
+
const last = this.h.pop();
|
|
1744
|
+
if (n > 1) {
|
|
1745
|
+
this.h[0] = last;
|
|
1746
|
+
this.siftDown(0);
|
|
1747
|
+
}
|
|
1748
|
+
return top;
|
|
877
1749
|
}
|
|
878
|
-
|
|
879
|
-
|
|
1750
|
+
siftUp(i) {
|
|
1751
|
+
while (i > 0) {
|
|
1752
|
+
const p = i - 1 >> 1;
|
|
1753
|
+
if (this.h[p].score <= this.h[i].score) break;
|
|
1754
|
+
[this.h[p], this.h[i]] = [this.h[i], this.h[p]];
|
|
1755
|
+
i = p;
|
|
1756
|
+
}
|
|
1757
|
+
}
|
|
1758
|
+
siftDown(i) {
|
|
1759
|
+
const n = this.h.length;
|
|
1760
|
+
for (;;) {
|
|
1761
|
+
let m = i;
|
|
1762
|
+
const l = 2 * i + 1;
|
|
1763
|
+
const r = 2 * i + 2;
|
|
1764
|
+
if (l < n && this.h[l].score < this.h[m].score) m = l;
|
|
1765
|
+
if (r < n && this.h[r].score < this.h[m].score) m = r;
|
|
1766
|
+
if (m === i) break;
|
|
1767
|
+
[this.h[m], this.h[i]] = [this.h[i], this.h[m]];
|
|
1768
|
+
i = m;
|
|
1769
|
+
}
|
|
1770
|
+
}
|
|
1771
|
+
};
|
|
1772
|
+
}));
|
|
880
1773
|
|
|
881
1774
|
//#endregion
|
|
882
|
-
//#region src/core/pruner.ts
|
|
1775
|
+
//#region src/core/pruner/pruner.ts
|
|
883
1776
|
/**
|
|
884
1777
|
* Edge-aware greedy merge with re-linking — O(N log N).
|
|
885
1778
|
*
|
|
@@ -993,7 +1886,7 @@ function suppressConsecutiveRuns(graph, passingIndices, normalizedScores) {
|
|
|
993
1886
|
return result;
|
|
994
1887
|
}
|
|
995
1888
|
/**
|
|
996
|
-
* Threshold-based pruning with NMS -- O(N).
|
|
1889
|
+
* Threshold-based pruning with NMS -- including normalization, O(N log N).
|
|
997
1890
|
*
|
|
998
1891
|
* 1. Scores are normalized to [0, 1] via percentile normalization.
|
|
999
1892
|
* 2. Edges with normalized score >= threshold are collected.
|
|
@@ -1056,9 +1949,19 @@ function pruneByThresholdWithCap(graph, frames, threshold, maxCount) {
|
|
|
1056
1949
|
}
|
|
1057
1950
|
return pruneTo(syntheticEdges, survivingFrames, maxCount);
|
|
1058
1951
|
}
|
|
1952
|
+
var init_pruner$1 = __esmMin((() => {
|
|
1953
|
+
init_normalize_scores();
|
|
1954
|
+
init_min_heap();
|
|
1955
|
+
}));
|
|
1059
1956
|
|
|
1060
1957
|
//#endregion
|
|
1061
|
-
//#region src/
|
|
1958
|
+
//#region src/core/pruner/index.ts
|
|
1959
|
+
var init_pruner = __esmMin((() => {
|
|
1960
|
+
init_pruner$1();
|
|
1961
|
+
}));
|
|
1962
|
+
|
|
1963
|
+
//#endregion
|
|
1964
|
+
//#region src/core/segmenter/scheduling/concurrency.ts
|
|
1062
1965
|
/**
|
|
1063
1966
|
* Creates a concurrency limiter that runs at most `limit` tasks in parallel.
|
|
1064
1967
|
* Lightweight replacement for p-limit to avoid external dependency.
|
|
@@ -1078,9 +1981,10 @@ function concurrencyLimit(limit) {
|
|
|
1078
1981
|
}
|
|
1079
1982
|
};
|
|
1080
1983
|
}
|
|
1984
|
+
var init_concurrency = __esmMin((() => {}));
|
|
1081
1985
|
|
|
1082
1986
|
//#endregion
|
|
1083
|
-
//#region src/core/segmenter.ts
|
|
1987
|
+
//#region src/core/segmenter/segmenter.ts
|
|
1084
1988
|
/**
|
|
1085
1989
|
* Determine whether segmentation should be used.
|
|
1086
1990
|
* Returns false for frames mode and GIF files.
|
|
@@ -1094,53 +1998,62 @@ function shouldSegment(resolvedOptions, originalOptions) {
|
|
|
1094
1998
|
return true;
|
|
1095
1999
|
}
|
|
1096
2000
|
/**
|
|
1097
|
-
*
|
|
1098
|
-
*
|
|
1099
|
-
*
|
|
1100
|
-
*
|
|
1101
|
-
*
|
|
1102
|
-
*
|
|
2001
|
+
* Partition the global extraction grid into nonempty logical segments.
|
|
2002
|
+
* @param totalDuration Positive source duration in seconds.
|
|
2003
|
+
* @param maxSegmentDuration Positive logical segment width in seconds.
|
|
2004
|
+
* @param maxFrames Candidate budget, defensively raised to at least two.
|
|
2005
|
+
* @param fps Positive requested sampling frequency.
|
|
2006
|
+
* @returns Contiguous plan indices with grid-aligned seeks and overlap-inclusive limits.
|
|
1103
2007
|
*/
|
|
1104
2008
|
function computeSegmentPlan(totalDuration, maxSegmentDuration, maxFrames, fps) {
|
|
1105
|
-
const
|
|
2009
|
+
const frameLimit = Math.max(2, maxFrames);
|
|
2010
|
+
const effectiveFps = Math.min(fps, frameLimit / totalDuration);
|
|
1106
2011
|
if (totalDuration <= maxSegmentDuration) return [{
|
|
1107
2012
|
index: 0,
|
|
1108
2013
|
startTime: 0,
|
|
1109
2014
|
endTime: totalDuration,
|
|
1110
2015
|
duration: totalDuration,
|
|
1111
|
-
allocatedFrames:
|
|
2016
|
+
allocatedFrames: frameLimit,
|
|
1112
2017
|
effectiveFps,
|
|
1113
2018
|
overlapBefore: 0,
|
|
1114
2019
|
overlapAfter: 0,
|
|
1115
2020
|
extractStartTime: 0,
|
|
1116
2021
|
extractDuration: totalDuration
|
|
1117
2022
|
}];
|
|
1118
|
-
const segmentCount = Math.ceil(totalDuration / maxSegmentDuration);
|
|
1119
|
-
const overlapTime = 1 / effectiveFps;
|
|
1120
2023
|
const segments = [];
|
|
1121
|
-
for (let
|
|
1122
|
-
const
|
|
1123
|
-
|
|
1124
|
-
const
|
|
1125
|
-
const
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
2024
|
+
for (let slot = 0; slot < frameLimit; slot++) {
|
|
2025
|
+
const timestamp = slot / effectiveFps;
|
|
2026
|
+
if (timestamp >= totalDuration) break;
|
|
2027
|
+
const startTime = Math.floor(timestamp / maxSegmentDuration) * maxSegmentDuration;
|
|
2028
|
+
const previous = segments[segments.length - 1];
|
|
2029
|
+
if (previous?.startTime === startTime) {
|
|
2030
|
+
previous.allocatedFrames++;
|
|
2031
|
+
continue;
|
|
2032
|
+
}
|
|
2033
|
+
const endTime = Math.min(startTime + maxSegmentDuration, totalDuration);
|
|
1129
2034
|
segments.push({
|
|
1130
|
-
index:
|
|
2035
|
+
index: segments.length,
|
|
1131
2036
|
startTime,
|
|
1132
2037
|
endTime,
|
|
1133
|
-
duration,
|
|
1134
|
-
allocatedFrames:
|
|
2038
|
+
duration: endTime - startTime,
|
|
2039
|
+
allocatedFrames: 1,
|
|
1135
2040
|
effectiveFps,
|
|
1136
|
-
overlapBefore,
|
|
1137
|
-
overlapAfter,
|
|
1138
|
-
extractStartTime,
|
|
1139
|
-
extractDuration
|
|
2041
|
+
overlapBefore: 0,
|
|
2042
|
+
overlapAfter: 0,
|
|
2043
|
+
extractStartTime: timestamp,
|
|
2044
|
+
extractDuration: 0
|
|
1140
2045
|
});
|
|
1141
2046
|
}
|
|
1142
|
-
|
|
1143
|
-
|
|
2047
|
+
let firstSlot = 0;
|
|
2048
|
+
for (const segment of segments) {
|
|
2049
|
+
const nextSlot = firstSlot + segment.allocatedFrames;
|
|
2050
|
+
segment.overlapBefore = segment.index > 0 ? 1 : 0;
|
|
2051
|
+
segment.overlapAfter = segment.index < segments.length - 1 ? 1 : 0;
|
|
2052
|
+
segment.extractStartTime = (firstSlot - segment.overlapBefore) / effectiveFps;
|
|
2053
|
+
segment.extractDuration = (segment.overlapAfter ? Math.min(totalDuration, (nextSlot + 1) / effectiveFps) : totalDuration) - segment.extractStartTime;
|
|
2054
|
+
segment.allocatedFrames += segment.overlapBefore + segment.overlapAfter;
|
|
2055
|
+
firstSlot = nextSlot;
|
|
2056
|
+
}
|
|
1144
2057
|
return segments;
|
|
1145
2058
|
}
|
|
1146
2059
|
/**
|
|
@@ -1159,44 +2072,58 @@ function collectAllFrames(segmentResults) {
|
|
|
1159
2072
|
return allFrames;
|
|
1160
2073
|
}
|
|
1161
2074
|
/**
|
|
1162
|
-
* Sort frames
|
|
1163
|
-
*
|
|
1164
|
-
*
|
|
2075
|
+
* Sort frames in place and alias overlap duplicates to the first survivor.
|
|
2076
|
+
* @param frames Collected entries whose segment index and local ID identify a frame.
|
|
2077
|
+
* @param effectiveFps Positive sampling frequency; half a frame interval is the threshold.
|
|
2078
|
+
* @returns Timestamp-ordered survivors and duplicate keys pointing directly to survivor keys.
|
|
1165
2079
|
*/
|
|
1166
2080
|
function deduplicateFrames(frames, effectiveFps) {
|
|
1167
2081
|
frames.sort((a, b) => a.frame.timestamp - b.frame.timestamp);
|
|
1168
2082
|
const dupThreshold = 1 / (effectiveFps * 2);
|
|
1169
2083
|
const unique = [];
|
|
2084
|
+
const aliases = /* @__PURE__ */ new Map();
|
|
1170
2085
|
for (const entry of frames) {
|
|
1171
2086
|
if (unique.length > 0) {
|
|
1172
2087
|
const last = unique[unique.length - 1];
|
|
1173
|
-
if (Math.abs(entry.frame.timestamp - last.frame.timestamp) < dupThreshold)
|
|
2088
|
+
if (Math.abs(entry.frame.timestamp - last.frame.timestamp) < dupThreshold) {
|
|
2089
|
+
aliases.set(`${entry.segmentIndex}:${entry.localId}`, `${last.segmentIndex}:${last.localId}`);
|
|
2090
|
+
continue;
|
|
2091
|
+
}
|
|
1174
2092
|
}
|
|
1175
2093
|
unique.push(entry);
|
|
1176
2094
|
}
|
|
1177
|
-
return
|
|
2095
|
+
return {
|
|
2096
|
+
unique,
|
|
2097
|
+
aliases
|
|
2098
|
+
};
|
|
1178
2099
|
}
|
|
1179
2100
|
/**
|
|
1180
|
-
* Assign sequential global IDs
|
|
1181
|
-
*
|
|
2101
|
+
* Assign sequential global IDs and retain duplicate local IDs as aliases.
|
|
2102
|
+
* @param uniqueFrames Timestamp-ordered survivors with distinct segment/local keys.
|
|
2103
|
+
* @param aliases Duplicate keys pointing directly to keys in uniqueFrames.
|
|
2104
|
+
* @returns Remapped frames and a global ID lookup covering survivors and duplicates.
|
|
1182
2105
|
*/
|
|
1183
|
-
function remapFrameIds(uniqueFrames) {
|
|
2106
|
+
function remapFrameIds(uniqueFrames, aliases) {
|
|
1184
2107
|
const globalIdMap = /* @__PURE__ */ new Map();
|
|
2108
|
+
const frames = uniqueFrames.map((entry, globalId) => {
|
|
2109
|
+
globalIdMap.set(`${entry.segmentIndex}:${entry.localId}`, globalId);
|
|
2110
|
+
return {
|
|
2111
|
+
id: globalId,
|
|
2112
|
+
timestamp: entry.frame.timestamp,
|
|
2113
|
+
extractPath: entry.frame.extractPath
|
|
2114
|
+
};
|
|
2115
|
+
});
|
|
2116
|
+
for (const [alias, survivor] of aliases) globalIdMap.set(alias, globalIdMap.get(survivor));
|
|
1185
2117
|
return {
|
|
1186
|
-
frames
|
|
1187
|
-
globalIdMap.set(`${entry.segmentIndex}:${entry.localId}`, globalId);
|
|
1188
|
-
return {
|
|
1189
|
-
id: globalId,
|
|
1190
|
-
timestamp: entry.frame.timestamp,
|
|
1191
|
-
extractPath: entry.frame.extractPath
|
|
1192
|
-
};
|
|
1193
|
-
}),
|
|
2118
|
+
frames,
|
|
1194
2119
|
globalIdMap
|
|
1195
2120
|
};
|
|
1196
2121
|
}
|
|
1197
2122
|
/**
|
|
1198
|
-
* Remap
|
|
1199
|
-
*
|
|
2123
|
+
* Remap edges, dropping missing endpoints and self loops while keeping the highest pair score.
|
|
2124
|
+
* @param segmentResults Segment-local edges in encounter order.
|
|
2125
|
+
* @param globalIdMap Survivor and duplicate local keys mapped to global IDs.
|
|
2126
|
+
* @returns One edge per surviving directed pair without changing its score.
|
|
1200
2127
|
*/
|
|
1201
2128
|
function remapEdges(segmentResults, globalIdMap) {
|
|
1202
2129
|
const edges = [];
|
|
@@ -1205,62 +2132,73 @@ function remapEdges(segmentResults, globalIdMap) {
|
|
|
1205
2132
|
const newSourceId = globalIdMap.get(`${result.segment.index}:${edge.sourceId}`);
|
|
1206
2133
|
const newTargetId = globalIdMap.get(`${result.segment.index}:${edge.targetId}`);
|
|
1207
2134
|
if (newSourceId === void 0 || newTargetId === void 0) continue;
|
|
2135
|
+
if (newSourceId === newTargetId) continue;
|
|
1208
2136
|
const edgeKey = `${newSourceId}-${newTargetId}`;
|
|
1209
2137
|
const existingIdx = edgeMap.get(edgeKey);
|
|
1210
2138
|
if (existingIdx !== void 0) {
|
|
1211
2139
|
if (edges[existingIdx].score < edge.score) edges[existingIdx] = {
|
|
2140
|
+
...edge,
|
|
1212
2141
|
sourceId: newSourceId,
|
|
1213
|
-
targetId: newTargetId
|
|
1214
|
-
score: edge.score
|
|
2142
|
+
targetId: newTargetId
|
|
1215
2143
|
};
|
|
1216
2144
|
} else {
|
|
1217
2145
|
edgeMap.set(edgeKey, edges.length);
|
|
1218
2146
|
edges.push({
|
|
2147
|
+
...edge,
|
|
1219
2148
|
sourceId: newSourceId,
|
|
1220
|
-
targetId: newTargetId
|
|
1221
|
-
score: edge.score
|
|
2149
|
+
targetId: newTargetId
|
|
1222
2150
|
});
|
|
1223
2151
|
}
|
|
1224
2152
|
}
|
|
1225
2153
|
return edges;
|
|
1226
2154
|
}
|
|
1227
2155
|
/**
|
|
1228
|
-
* Remap
|
|
1229
|
-
*
|
|
2156
|
+
* Remap animations, dropping missing or collapsed endpoints and retaining the first pair entry.
|
|
2157
|
+
* @param segmentResults Segment-local tracker entries in encounter order.
|
|
2158
|
+
* @param globalIdMap Survivor and duplicate local keys mapped to global IDs.
|
|
2159
|
+
* @returns One animation per directed pair with its original tracker duration and metadata.
|
|
1230
2160
|
*/
|
|
1231
2161
|
function remapAnimations(segmentResults, globalIdMap) {
|
|
1232
|
-
const animations =
|
|
2162
|
+
const animations = /* @__PURE__ */ new Map();
|
|
1233
2163
|
for (const result of segmentResults) for (const anim of result.animations) {
|
|
1234
2164
|
const newStartId = globalIdMap.get(`${result.segment.index}:${anim.startFrameId}`);
|
|
1235
2165
|
const newEndId = globalIdMap.get(`${result.segment.index}:${anim.endFrameId}`);
|
|
1236
2166
|
if (newStartId === void 0 || newEndId === void 0) continue;
|
|
1237
|
-
|
|
2167
|
+
if (newStartId === newEndId) continue;
|
|
2168
|
+
const animationKey = `${newStartId}-${newEndId}`;
|
|
2169
|
+
if (animations.has(animationKey)) continue;
|
|
2170
|
+
animations.set(animationKey, {
|
|
1238
2171
|
...anim,
|
|
1239
2172
|
startFrameId: newStartId,
|
|
1240
2173
|
endFrameId: newEndId
|
|
1241
2174
|
});
|
|
1242
2175
|
}
|
|
1243
|
-
return animations;
|
|
2176
|
+
return [...animations.values()];
|
|
1244
2177
|
}
|
|
1245
2178
|
/**
|
|
1246
2179
|
* Merge multiple segment results into a single unified frame/edge/animation set.
|
|
1247
|
-
*
|
|
1248
|
-
* -
|
|
1249
|
-
*
|
|
1250
|
-
* - Duplicate edges keep higher score
|
|
2180
|
+
* @param segmentResults Local frames, edges and tracker entries with distinct segment indices.
|
|
2181
|
+
* @returns Global timestamp-ordered frames, aliased edges and animations without self loops.
|
|
2182
|
+
* Duplicate edges keep the higher score; duplicate animations keep the first tracker entry.
|
|
1251
2183
|
*/
|
|
1252
2184
|
function mergeSegmentFrames(segmentResults) {
|
|
1253
2185
|
if (segmentResults.length === 0) return {
|
|
1254
2186
|
frames: [],
|
|
1255
2187
|
edges: [],
|
|
1256
|
-
animations: []
|
|
2188
|
+
animations: [],
|
|
2189
|
+
analysisResolution: {
|
|
2190
|
+
width: 0,
|
|
2191
|
+
height: 0
|
|
2192
|
+
}
|
|
1257
2193
|
};
|
|
1258
2194
|
const effectiveFps = segmentResults[0].segment.effectiveFps;
|
|
1259
|
-
const {
|
|
2195
|
+
const { unique, aliases } = deduplicateFrames(collectAllFrames(segmentResults), effectiveFps);
|
|
2196
|
+
const { frames, globalIdMap } = remapFrameIds(unique, aliases);
|
|
1260
2197
|
return {
|
|
1261
2198
|
frames,
|
|
1262
2199
|
edges: remapEdges(segmentResults, globalIdMap),
|
|
1263
|
-
animations: remapAnimations(segmentResults, globalIdMap)
|
|
2200
|
+
animations: remapAnimations(segmentResults, globalIdMap),
|
|
2201
|
+
analysisResolution: segmentResults[0].analysisResolution
|
|
1264
2202
|
};
|
|
1265
2203
|
}
|
|
1266
2204
|
function buildSegmentContext(segment, frames, segmentWorkspacePath, resolvedOptions, onProgress) {
|
|
@@ -1271,6 +2209,7 @@ function buildSegmentContext(segment, frames, segmentWorkspacePath, resolvedOpti
|
|
|
1271
2209
|
maxFrames: segment.allocatedFrames
|
|
1272
2210
|
},
|
|
1273
2211
|
workspacePath: segmentWorkspacePath,
|
|
2212
|
+
effectiveFps: segment.effectiveFps,
|
|
1274
2213
|
frames,
|
|
1275
2214
|
graph: [],
|
|
1276
2215
|
status: "ANALYZING",
|
|
@@ -1282,19 +2221,26 @@ function buildSegmentContext(segment, frames, segmentWorkspacePath, resolvedOpti
|
|
|
1282
2221
|
* Each segment uses an isolated workspace directory.
|
|
1283
2222
|
*/
|
|
1284
2223
|
async function processSegment(inputPath, segment, workspacePath, resolvedOptions, onProgress) {
|
|
1285
|
-
const
|
|
2224
|
+
const framesDir = join(workspacePath, "frames");
|
|
2225
|
+
const frames = await extractFramesForRange(inputPath, framesDir, segment.effectiveFps, resolvedOptions.scale, segment.extractStartTime, segment.extractDuration, segment.allocatedFrames);
|
|
1286
2226
|
if (frames.length < 2) return {
|
|
1287
2227
|
segment,
|
|
1288
2228
|
frames,
|
|
1289
2229
|
edges: [],
|
|
1290
|
-
animations: []
|
|
2230
|
+
animations: [],
|
|
2231
|
+
analysisResolution: {
|
|
2232
|
+
width: 0,
|
|
2233
|
+
height: 0
|
|
2234
|
+
}
|
|
1291
2235
|
};
|
|
1292
|
-
const
|
|
2236
|
+
const ctx = buildSegmentContext(segment, frames, workspacePath, resolvedOptions, onProgress);
|
|
2237
|
+
const { edges, animations, analysisResolution } = await analyzeFrames(ctx);
|
|
1293
2238
|
return {
|
|
1294
2239
|
segment,
|
|
1295
2240
|
frames,
|
|
1296
2241
|
edges,
|
|
1297
|
-
animations
|
|
2242
|
+
animations,
|
|
2243
|
+
analysisResolution
|
|
1298
2244
|
};
|
|
1299
2245
|
}
|
|
1300
2246
|
/**
|
|
@@ -1334,7 +2280,7 @@ async function runSegmentedPipeline(options, resolvedOptions) {
|
|
|
1334
2280
|
});
|
|
1335
2281
|
})));
|
|
1336
2282
|
options.onProgress?.("ANALYZING", 100);
|
|
1337
|
-
const { frames, edges, animations } = mergeSegmentFrames(results);
|
|
2283
|
+
const { frames, edges, animations, analysisResolution } = mergeSegmentFrames(results);
|
|
1338
2284
|
logger.debug(`Merged: ${frames.length} frames, ${edges.length} edges, ${animations.length} animations`);
|
|
1339
2285
|
options.onProgress?.("PRUNING", 0);
|
|
1340
2286
|
const survivingIds = pruneByThresholdWithCap(edges, frames, resolvedOptions.threshold, resolvedOptions.count);
|
|
@@ -1343,6 +2289,9 @@ async function runSegmentedPipeline(options, resolvedOptions) {
|
|
|
1343
2289
|
options.onProgress?.("FINALIZING", 0);
|
|
1344
2290
|
const ctx = {
|
|
1345
2291
|
options: resolvedOptions,
|
|
2292
|
+
effectiveFps: segments[0]?.effectiveFps,
|
|
2293
|
+
sourceDurationSec: totalDuration,
|
|
2294
|
+
analysisResolution,
|
|
1346
2295
|
workspacePath: mainWorkspace,
|
|
1347
2296
|
frames,
|
|
1348
2297
|
graph: edges,
|
|
@@ -1350,27 +2299,20 @@ async function runSegmentedPipeline(options, resolvedOptions) {
|
|
|
1350
2299
|
status: "FINALIZING",
|
|
1351
2300
|
emitProgress: (percent) => options.onProgress?.("FINALIZING", percent)
|
|
1352
2301
|
};
|
|
1353
|
-
|
|
1354
|
-
let outputBuffers;
|
|
1355
|
-
if (resolvedOptions.mode === "buffer" || resolvedOptions.mode === "frames") outputBuffers = await readFramesAsBuffers(prunedFrames, resolvedOptions.quality);
|
|
1356
|
-
else outputFiles = await finalizeOutput(ctx, prunedFrames);
|
|
2302
|
+
const finalized = await finalizeSelection(ctx, prunedFrames);
|
|
1357
2303
|
options.onProgress?.("FINALIZING", 100);
|
|
1358
2304
|
logger.success(`Segmented pipeline: ${prunedFrames.length} scenes from ${frames.length} frames (${segments.length} segments)`);
|
|
1359
2305
|
return {
|
|
1360
2306
|
success: true,
|
|
1361
2307
|
originalFramesCount: frames.length,
|
|
1362
2308
|
prunedFramesCount: prunedFrames.length,
|
|
1363
|
-
outputFiles,
|
|
1364
|
-
outputBuffers,
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
width: resolvedOptions.scale,
|
|
1371
|
-
height: Math.round(resolvedOptions.scale * 9 / 16)
|
|
1372
|
-
}
|
|
1373
|
-
},
|
|
2309
|
+
outputFiles: finalized.outputFiles,
|
|
2310
|
+
outputBuffers: finalized.outputBuffers,
|
|
2311
|
+
video: finalized.document.video,
|
|
2312
|
+
animations: finalized.animations,
|
|
2313
|
+
frames: finalized.document.frames,
|
|
2314
|
+
...finalized.document.sheet ? { sheet: finalized.document.sheet } : {},
|
|
2315
|
+
...finalized.sheetBuffer ? { sheetBuffer: finalized.sheetBuffer } : {},
|
|
1374
2316
|
executionTimeMs: Date.now() - pipelineStart
|
|
1375
2317
|
};
|
|
1376
2318
|
} catch (error) {
|
|
@@ -1382,11 +2324,33 @@ async function runSegmentedPipeline(options, resolvedOptions) {
|
|
|
1382
2324
|
else logger.debug(`Debug mode: workspace preserved at ${mainWorkspace}`);
|
|
1383
2325
|
}
|
|
1384
2326
|
}
|
|
2327
|
+
var init_segmenter$1 = __esmMin((() => {
|
|
2328
|
+
init_concurrency();
|
|
2329
|
+
init_logger();
|
|
2330
|
+
init_analyzer();
|
|
2331
|
+
init_finalize_selection();
|
|
2332
|
+
init_extractor();
|
|
2333
|
+
init_input_resolver();
|
|
2334
|
+
init_pruner();
|
|
2335
|
+
init_workspace();
|
|
2336
|
+
}));
|
|
2337
|
+
|
|
2338
|
+
//#endregion
|
|
2339
|
+
//#region src/core/segmenter/index.ts
|
|
2340
|
+
var init_segmenter = __esmMin((() => {
|
|
2341
|
+
init_segmenter$1();
|
|
2342
|
+
}));
|
|
1385
2343
|
|
|
1386
2344
|
//#endregion
|
|
1387
|
-
//#region src/core/orchestrator.ts
|
|
2345
|
+
//#region src/core/orchestrator/orchestrator.ts
|
|
2346
|
+
/**
|
|
2347
|
+
* Run the five pipeline stages, delegating segmented inputs to the segmenter.
|
|
2348
|
+
* @param options - Mode-specific input and optional pipeline settings.
|
|
2349
|
+
* @returns Selected outputs with v2 frame metadata and zero-based API animations.
|
|
2350
|
+
* @throws Propagates stage failures after cleaning the workspace unless debug is enabled.
|
|
2351
|
+
*/
|
|
1388
2352
|
async function runPipeline(options) {
|
|
1389
|
-
|
|
2353
|
+
setDebugMode(options.debug ?? false);
|
|
1390
2354
|
const resolvedOptions = resolveOptions(options);
|
|
1391
2355
|
if (shouldSegment(resolvedOptions, options)) return runSegmentedPipeline(options, resolvedOptions);
|
|
1392
2356
|
const startTime = Date.now();
|
|
@@ -1406,50 +2370,47 @@ async function runPipeline(options) {
|
|
|
1406
2370
|
logger.debug(`Workspace created: ${ctx.workspacePath}`);
|
|
1407
2371
|
ctx.status = "EXTRACTING";
|
|
1408
2372
|
const { frames: resolvedFrames, resolvedInputPath } = await resolveInput(options, ctx.workspacePath);
|
|
1409
|
-
if (resolvedOptions.mode === "frames")
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
2373
|
+
if (resolvedOptions.mode === "frames") {
|
|
2374
|
+
ctx.frames = resolvedFrames;
|
|
2375
|
+
ctx.effectiveFps = 1;
|
|
2376
|
+
} else {
|
|
2377
|
+
const extractCtx = {
|
|
2378
|
+
...ctx,
|
|
2379
|
+
options: {
|
|
2380
|
+
...resolvedOptions,
|
|
2381
|
+
inputPath: resolvedInputPath
|
|
2382
|
+
}
|
|
2383
|
+
};
|
|
2384
|
+
ctx.frames = await extractFrames(extractCtx);
|
|
2385
|
+
ctx.effectiveFps = extractCtx.effectiveFps;
|
|
2386
|
+
ctx.sourceDurationSec = extractCtx.sourceDurationSec;
|
|
2387
|
+
}
|
|
1417
2388
|
ctx.emitProgress(100);
|
|
1418
2389
|
ctx.status = "ANALYZING";
|
|
1419
|
-
const { edges, animations } = await analyzeFrames(ctx);
|
|
2390
|
+
const { edges, animations, analysisResolution } = await analyzeFrames(ctx);
|
|
1420
2391
|
ctx.graph = edges;
|
|
1421
2392
|
ctx.animations = animations;
|
|
2393
|
+
ctx.analysisResolution = analysisResolution;
|
|
1422
2394
|
ctx.status = "PRUNING";
|
|
1423
2395
|
const survivingIds = pruneByThresholdWithCap(ctx.graph, ctx.frames, resolvedOptions.threshold, resolvedOptions.count);
|
|
1424
2396
|
const prunedFrames = filter(ctx.frames, (f) => survivingIds.has(f.id));
|
|
1425
2397
|
ctx.emitProgress(100);
|
|
1426
2398
|
ctx.status = "FINALIZING";
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
if (resolvedOptions.mode === "buffer" || resolvedOptions.mode === "frames") {
|
|
1430
|
-
outputBuffers = await readFramesAsBuffers(prunedFrames, resolvedOptions.quality);
|
|
1431
|
-
ctx.emitProgress(100);
|
|
1432
|
-
} else {
|
|
1433
|
-
outputFiles = await finalizeOutput(ctx, prunedFrames);
|
|
1434
|
-
ctx.emitProgress(100);
|
|
1435
|
-
}
|
|
2399
|
+
const finalized = await finalizeSelection(ctx, prunedFrames);
|
|
2400
|
+
ctx.emitProgress(100);
|
|
1436
2401
|
ctx.status = "SUCCESS";
|
|
1437
2402
|
logger.success(`Extracted ${prunedFrames.length} scenes from ${ctx.frames.length} frames`);
|
|
1438
2403
|
return {
|
|
1439
2404
|
success: true,
|
|
1440
2405
|
originalFramesCount: ctx.frames.length,
|
|
1441
2406
|
prunedFramesCount: prunedFrames.length,
|
|
1442
|
-
outputFiles,
|
|
1443
|
-
outputBuffers,
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
width: ctx.options.scale,
|
|
1450
|
-
height: Math.round(ctx.options.scale * 9 / 16)
|
|
1451
|
-
}
|
|
1452
|
-
},
|
|
2407
|
+
outputFiles: finalized.outputFiles,
|
|
2408
|
+
outputBuffers: finalized.outputBuffers,
|
|
2409
|
+
video: finalized.document.video,
|
|
2410
|
+
animations: finalized.animations,
|
|
2411
|
+
frames: finalized.document.frames,
|
|
2412
|
+
...finalized.document.sheet ? { sheet: finalized.document.sheet } : {},
|
|
2413
|
+
...finalized.sheetBuffer ? { sheetBuffer: finalized.sheetBuffer } : {},
|
|
1453
2414
|
executionTimeMs: Date.now() - startTime
|
|
1454
2415
|
};
|
|
1455
2416
|
} catch (error) {
|
|
@@ -1462,6 +2423,20 @@ async function runPipeline(options) {
|
|
|
1462
2423
|
else logger.debug(`Debug mode: workspace preserved at ${ctx.workspacePath}`);
|
|
1463
2424
|
}
|
|
1464
2425
|
}
|
|
2426
|
+
var init_orchestrator = __esmMin((() => {
|
|
2427
|
+
init_logger();
|
|
2428
|
+
init_analyzer();
|
|
2429
|
+
init_finalize_selection();
|
|
2430
|
+
init_extractor();
|
|
2431
|
+
init_input_resolver();
|
|
2432
|
+
init_pruner();
|
|
2433
|
+
init_segmenter();
|
|
2434
|
+
init_workspace();
|
|
2435
|
+
}));
|
|
2436
|
+
|
|
2437
|
+
//#endregion
|
|
2438
|
+
//#region src/index.ts
|
|
2439
|
+
init_orchestrator();
|
|
1465
2440
|
|
|
1466
2441
|
//#endregion
|
|
1467
2442
|
export { runPipeline as extractScenes };
|