@lumy-pack/scene-sieve 0.0.8 → 0.0.10
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 +93 -0
- package/dist/cli.mjs +708 -86
- package/dist/index.cjs +382 -4
- package/dist/index.mjs +382 -4
- package/dist/pipeline-worker.mjs +382 -4
- package/package.json +3 -2
- package/dist/cli.d.ts +0 -1
- package/dist/commands/Sieve.d.ts +0 -15
- package/dist/components/PhaseStep.d.ts +0 -14
- package/dist/components/ProgressBar.d.ts +0 -7
- package/dist/constants.d.ts +0 -30
- package/dist/core/analyzer.d.ts +0 -62
- package/dist/core/dbscan.d.ts +0 -10
- package/dist/core/extractor.d.ts +0 -7
- package/dist/core/index.d.ts +0 -8
- package/dist/core/input-resolver.d.ts +0 -13
- package/dist/core/orchestrator.d.ts +0 -2
- package/dist/core/pipeline-worker.d.ts +0 -1
- package/dist/core/pruner.d.ts +0 -61
- package/dist/core/run-in-worker.d.ts +0 -9
- package/dist/core/workspace.d.ts +0 -24
- package/dist/index.d.ts +0 -2
- package/dist/types/index.d.ts +0 -107
- package/dist/utils/logger.d.ts +0 -8
- package/dist/utils/math.d.ts +0 -27
- package/dist/utils/min-heap.d.ts +0 -16
- package/dist/utils/paths.d.ts +0 -18
package/dist/pipeline-worker.mjs
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
import { parentPort, workerData } from "worker_threads";
|
|
3
3
|
|
|
4
4
|
// src/core/orchestrator.ts
|
|
5
|
-
import { randomUUID } from "crypto";
|
|
5
|
+
import { randomUUID as randomUUID2 } from "crypto";
|
|
6
6
|
|
|
7
7
|
// src/utils/logger.ts
|
|
8
8
|
import pc from "picocolors";
|
|
@@ -67,6 +67,8 @@ var PIXELDIFF_GAUSSIAN_KERNEL = 3;
|
|
|
67
67
|
var PIXELDIFF_BINARY_THRESHOLD = 30;
|
|
68
68
|
var PIXELDIFF_CONTOUR_MIN_AREA = 100;
|
|
69
69
|
var PIXELDIFF_SAMPLE_SPACING = 8;
|
|
70
|
+
var DEFAULT_MAX_SEGMENT_DURATION = 300;
|
|
71
|
+
var DEFAULT_SEGMENT_CONCURRENCY = 2;
|
|
70
72
|
function getTempWorkspaceDir(sessionId) {
|
|
71
73
|
return join(TEMP_BASE_DIR, `${WORKSPACE_PREFIX}${sessionId}`);
|
|
72
74
|
}
|
|
@@ -659,6 +661,23 @@ async function buildFrameList(framesDir, duration) {
|
|
|
659
661
|
extractPath: join2(framesDir, file)
|
|
660
662
|
}));
|
|
661
663
|
}
|
|
664
|
+
async function extractFramesForRange(inputPath, outputDir, fps, scale, startTime, duration) {
|
|
665
|
+
const outputPattern = join2(outputDir, FRAME_FILENAME_PATTERN);
|
|
666
|
+
await execa(ffmpegPath, [
|
|
667
|
+
"-ss",
|
|
668
|
+
String(startTime),
|
|
669
|
+
"-i",
|
|
670
|
+
inputPath,
|
|
671
|
+
"-t",
|
|
672
|
+
String(duration),
|
|
673
|
+
"-vf",
|
|
674
|
+
`fps=${fps},scale=-1:${scale}`,
|
|
675
|
+
"-q:v",
|
|
676
|
+
"2",
|
|
677
|
+
outputPattern
|
|
678
|
+
]);
|
|
679
|
+
return buildFrameList(outputDir, duration);
|
|
680
|
+
}
|
|
662
681
|
|
|
663
682
|
// src/core/input-resolver.ts
|
|
664
683
|
import { join as join4 } from "path";
|
|
@@ -721,6 +740,15 @@ async function finalizeOutput(ctx, selectedFrames) {
|
|
|
721
740
|
await rename(stagingDir, outputPath);
|
|
722
741
|
return outputFiles;
|
|
723
742
|
}
|
|
743
|
+
async function createSegmentWorkspace(parentWorkspacePath, segmentIndex) {
|
|
744
|
+
const segmentPath = join3(
|
|
745
|
+
parentWorkspacePath,
|
|
746
|
+
"segments",
|
|
747
|
+
String(segmentIndex)
|
|
748
|
+
);
|
|
749
|
+
await ensureDir(join3(segmentPath, "frames"));
|
|
750
|
+
return segmentPath;
|
|
751
|
+
}
|
|
724
752
|
async function cleanupWorkspace(workspacePath) {
|
|
725
753
|
if (!workspacePath) return;
|
|
726
754
|
try {
|
|
@@ -781,7 +809,9 @@ function resolveOptions(options2) {
|
|
|
781
809
|
quality: options2.quality ?? DEFAULT_QUALITY,
|
|
782
810
|
iouThreshold: options2.iouThreshold ?? IOU_THRESHOLD,
|
|
783
811
|
animationThreshold: options2.animationThreshold ?? ANIMATION_FRAME_THRESHOLD,
|
|
784
|
-
debug: options2.debug ?? false
|
|
812
|
+
debug: options2.debug ?? false,
|
|
813
|
+
maxSegmentDuration: options2.maxSegmentDuration ?? DEFAULT_MAX_SEGMENT_DURATION,
|
|
814
|
+
concurrency: options2.concurrency ?? DEFAULT_SEGMENT_CONCURRENCY
|
|
785
815
|
};
|
|
786
816
|
}
|
|
787
817
|
async function resolveInput(options2, workspacePath) {
|
|
@@ -1033,13 +1063,361 @@ function pruneByThresholdWithCap(graph, frames, threshold, maxCount) {
|
|
|
1033
1063
|
return pruneTo(syntheticEdges, survivingFrames, maxCount);
|
|
1034
1064
|
}
|
|
1035
1065
|
|
|
1066
|
+
// src/core/segmenter.ts
|
|
1067
|
+
import { randomUUID } from "crypto";
|
|
1068
|
+
import { join as join5 } from "path";
|
|
1069
|
+
|
|
1070
|
+
// src/utils/concurrency.ts
|
|
1071
|
+
function concurrencyLimit(limit) {
|
|
1072
|
+
limit = Math.max(1, limit);
|
|
1073
|
+
let active = 0;
|
|
1074
|
+
const queue = [];
|
|
1075
|
+
return async (fn) => {
|
|
1076
|
+
while (active >= limit) {
|
|
1077
|
+
await new Promise((resolve2) => queue.push(resolve2));
|
|
1078
|
+
}
|
|
1079
|
+
active++;
|
|
1080
|
+
try {
|
|
1081
|
+
return await fn();
|
|
1082
|
+
} finally {
|
|
1083
|
+
active--;
|
|
1084
|
+
queue.shift()?.();
|
|
1085
|
+
}
|
|
1086
|
+
};
|
|
1087
|
+
}
|
|
1088
|
+
|
|
1089
|
+
// src/core/segmenter.ts
|
|
1090
|
+
function shouldSegment(resolvedOptions, originalOptions) {
|
|
1091
|
+
if (resolvedOptions.mode === "frames") return false;
|
|
1092
|
+
if (originalOptions.mode === "file") {
|
|
1093
|
+
if (originalOptions.inputPath.toLowerCase().endsWith(".gif")) return false;
|
|
1094
|
+
}
|
|
1095
|
+
return true;
|
|
1096
|
+
}
|
|
1097
|
+
function computeSegmentPlan(totalDuration, maxSegmentDuration, maxFrames, fps) {
|
|
1098
|
+
const effectiveFps = Math.max(0.5, Math.min(fps, maxFrames / totalDuration));
|
|
1099
|
+
if (totalDuration <= maxSegmentDuration) {
|
|
1100
|
+
return [
|
|
1101
|
+
{
|
|
1102
|
+
index: 0,
|
|
1103
|
+
startTime: 0,
|
|
1104
|
+
endTime: totalDuration,
|
|
1105
|
+
duration: totalDuration,
|
|
1106
|
+
allocatedFrames: Math.min(
|
|
1107
|
+
Math.ceil(effectiveFps * totalDuration),
|
|
1108
|
+
maxFrames
|
|
1109
|
+
),
|
|
1110
|
+
effectiveFps,
|
|
1111
|
+
overlapBefore: 0,
|
|
1112
|
+
overlapAfter: 0,
|
|
1113
|
+
extractStartTime: 0,
|
|
1114
|
+
extractDuration: totalDuration
|
|
1115
|
+
}
|
|
1116
|
+
];
|
|
1117
|
+
}
|
|
1118
|
+
const segmentCount = Math.ceil(totalDuration / maxSegmentDuration);
|
|
1119
|
+
const overlapTime = 1 / effectiveFps;
|
|
1120
|
+
const segments = [];
|
|
1121
|
+
for (let i = 0; i < segmentCount; i++) {
|
|
1122
|
+
const startTime = i * maxSegmentDuration;
|
|
1123
|
+
const endTime = Math.min((i + 1) * maxSegmentDuration, totalDuration);
|
|
1124
|
+
const duration = endTime - startTime;
|
|
1125
|
+
const overlapBefore = i > 0 ? 1 : 0;
|
|
1126
|
+
const overlapAfter = i < segmentCount - 1 ? 1 : 0;
|
|
1127
|
+
const extractStartTime = Math.max(
|
|
1128
|
+
0,
|
|
1129
|
+
startTime - overlapBefore * overlapTime
|
|
1130
|
+
);
|
|
1131
|
+
const extractEndTime = Math.min(
|
|
1132
|
+
totalDuration,
|
|
1133
|
+
endTime + overlapAfter * overlapTime
|
|
1134
|
+
);
|
|
1135
|
+
const extractDuration = extractEndTime - extractStartTime;
|
|
1136
|
+
segments.push({
|
|
1137
|
+
index: i,
|
|
1138
|
+
startTime,
|
|
1139
|
+
endTime,
|
|
1140
|
+
duration,
|
|
1141
|
+
allocatedFrames: Math.ceil(effectiveFps * duration),
|
|
1142
|
+
effectiveFps,
|
|
1143
|
+
overlapBefore,
|
|
1144
|
+
overlapAfter,
|
|
1145
|
+
extractStartTime,
|
|
1146
|
+
extractDuration
|
|
1147
|
+
});
|
|
1148
|
+
}
|
|
1149
|
+
const totalAllocated = segments.reduce(
|
|
1150
|
+
(sum, s) => sum + s.allocatedFrames,
|
|
1151
|
+
0
|
|
1152
|
+
);
|
|
1153
|
+
if (totalAllocated > maxFrames) {
|
|
1154
|
+
segments[segments.length - 1].allocatedFrames -= totalAllocated - maxFrames;
|
|
1155
|
+
}
|
|
1156
|
+
return segments;
|
|
1157
|
+
}
|
|
1158
|
+
function collectAllFrames(segmentResults) {
|
|
1159
|
+
const allFrames = [];
|
|
1160
|
+
for (const result of segmentResults) {
|
|
1161
|
+
for (const frame of result.frames) {
|
|
1162
|
+
allFrames.push({
|
|
1163
|
+
frame: {
|
|
1164
|
+
...frame,
|
|
1165
|
+
// Use extractStartTime for timestamp correction (Section 18 note 1)
|
|
1166
|
+
timestamp: frame.timestamp + result.segment.extractStartTime
|
|
1167
|
+
},
|
|
1168
|
+
segmentIndex: result.segment.index,
|
|
1169
|
+
localId: frame.id
|
|
1170
|
+
});
|
|
1171
|
+
}
|
|
1172
|
+
}
|
|
1173
|
+
return allFrames;
|
|
1174
|
+
}
|
|
1175
|
+
function deduplicateFrames(frames, effectiveFps) {
|
|
1176
|
+
frames.sort((a, b) => a.frame.timestamp - b.frame.timestamp);
|
|
1177
|
+
const dupThreshold = 1 / (effectiveFps * 2);
|
|
1178
|
+
const unique = [];
|
|
1179
|
+
for (const entry of frames) {
|
|
1180
|
+
if (unique.length > 0) {
|
|
1181
|
+
const last = unique[unique.length - 1];
|
|
1182
|
+
if (Math.abs(entry.frame.timestamp - last.frame.timestamp) < dupThreshold) {
|
|
1183
|
+
continue;
|
|
1184
|
+
}
|
|
1185
|
+
}
|
|
1186
|
+
unique.push(entry);
|
|
1187
|
+
}
|
|
1188
|
+
return unique;
|
|
1189
|
+
}
|
|
1190
|
+
function remapFrameIds(uniqueFrames) {
|
|
1191
|
+
const globalIdMap = /* @__PURE__ */ new Map();
|
|
1192
|
+
const frames = uniqueFrames.map((entry, globalId) => {
|
|
1193
|
+
globalIdMap.set(`${entry.segmentIndex}:${entry.localId}`, globalId);
|
|
1194
|
+
return {
|
|
1195
|
+
id: globalId,
|
|
1196
|
+
timestamp: entry.frame.timestamp,
|
|
1197
|
+
extractPath: entry.frame.extractPath
|
|
1198
|
+
};
|
|
1199
|
+
});
|
|
1200
|
+
return { frames, globalIdMap };
|
|
1201
|
+
}
|
|
1202
|
+
function remapEdges(segmentResults, globalIdMap) {
|
|
1203
|
+
const edges = [];
|
|
1204
|
+
const edgeMap = /* @__PURE__ */ new Map();
|
|
1205
|
+
for (const result of segmentResults) {
|
|
1206
|
+
for (const edge of result.edges) {
|
|
1207
|
+
const newSourceId = globalIdMap.get(`${result.segment.index}:${edge.sourceId}`);
|
|
1208
|
+
const newTargetId = globalIdMap.get(`${result.segment.index}:${edge.targetId}`);
|
|
1209
|
+
if (newSourceId === void 0 || newTargetId === void 0) continue;
|
|
1210
|
+
const edgeKey = `${newSourceId}-${newTargetId}`;
|
|
1211
|
+
const existingIdx = edgeMap.get(edgeKey);
|
|
1212
|
+
if (existingIdx !== void 0) {
|
|
1213
|
+
if (edges[existingIdx].score < edge.score) {
|
|
1214
|
+
edges[existingIdx] = { sourceId: newSourceId, targetId: newTargetId, score: edge.score };
|
|
1215
|
+
}
|
|
1216
|
+
} else {
|
|
1217
|
+
edgeMap.set(edgeKey, edges.length);
|
|
1218
|
+
edges.push({ sourceId: newSourceId, targetId: newTargetId, score: edge.score });
|
|
1219
|
+
}
|
|
1220
|
+
}
|
|
1221
|
+
}
|
|
1222
|
+
return edges;
|
|
1223
|
+
}
|
|
1224
|
+
function remapAnimations(segmentResults, globalIdMap) {
|
|
1225
|
+
const animations = [];
|
|
1226
|
+
for (const result of segmentResults) {
|
|
1227
|
+
for (const anim of result.animations) {
|
|
1228
|
+
const newStartId = globalIdMap.get(`${result.segment.index}:${anim.startFrameId}`);
|
|
1229
|
+
const newEndId = globalIdMap.get(`${result.segment.index}:${anim.endFrameId}`);
|
|
1230
|
+
if (newStartId === void 0 || newEndId === void 0) continue;
|
|
1231
|
+
animations.push({ ...anim, startFrameId: newStartId, endFrameId: newEndId });
|
|
1232
|
+
}
|
|
1233
|
+
}
|
|
1234
|
+
return animations;
|
|
1235
|
+
}
|
|
1236
|
+
function mergeSegmentFrames(segmentResults) {
|
|
1237
|
+
if (segmentResults.length === 0) {
|
|
1238
|
+
return { frames: [], edges: [], animations: [] };
|
|
1239
|
+
}
|
|
1240
|
+
const effectiveFps = segmentResults[0].segment.effectiveFps;
|
|
1241
|
+
const allFrames = collectAllFrames(segmentResults);
|
|
1242
|
+
const uniqueFrames = deduplicateFrames(allFrames, effectiveFps);
|
|
1243
|
+
const { frames, globalIdMap } = remapFrameIds(uniqueFrames);
|
|
1244
|
+
const edges = remapEdges(segmentResults, globalIdMap);
|
|
1245
|
+
const animations = remapAnimations(segmentResults, globalIdMap);
|
|
1246
|
+
return { frames, edges, animations };
|
|
1247
|
+
}
|
|
1248
|
+
function buildSegmentContext(segment, frames, segmentWorkspacePath, resolvedOptions, onProgress) {
|
|
1249
|
+
return {
|
|
1250
|
+
options: {
|
|
1251
|
+
...resolvedOptions,
|
|
1252
|
+
fps: segment.effectiveFps,
|
|
1253
|
+
maxFrames: segment.allocatedFrames
|
|
1254
|
+
},
|
|
1255
|
+
workspacePath: segmentWorkspacePath,
|
|
1256
|
+
frames,
|
|
1257
|
+
graph: [],
|
|
1258
|
+
status: "ANALYZING",
|
|
1259
|
+
emitProgress: onProgress
|
|
1260
|
+
};
|
|
1261
|
+
}
|
|
1262
|
+
async function processSegment(inputPath, segment, workspacePath, resolvedOptions, onProgress) {
|
|
1263
|
+
const framesDir = join5(workspacePath, "frames");
|
|
1264
|
+
const frames = await extractFramesForRange(
|
|
1265
|
+
inputPath,
|
|
1266
|
+
framesDir,
|
|
1267
|
+
segment.effectiveFps,
|
|
1268
|
+
resolvedOptions.scale,
|
|
1269
|
+
segment.extractStartTime,
|
|
1270
|
+
segment.extractDuration
|
|
1271
|
+
);
|
|
1272
|
+
if (frames.length < 2) {
|
|
1273
|
+
return { segment, frames, edges: [], animations: [] };
|
|
1274
|
+
}
|
|
1275
|
+
const ctx = buildSegmentContext(
|
|
1276
|
+
segment,
|
|
1277
|
+
frames,
|
|
1278
|
+
workspacePath,
|
|
1279
|
+
resolvedOptions,
|
|
1280
|
+
onProgress
|
|
1281
|
+
);
|
|
1282
|
+
const { edges, animations } = await analyzeFrames(ctx);
|
|
1283
|
+
return { segment, frames, edges, animations };
|
|
1284
|
+
}
|
|
1285
|
+
async function runSegmentedPipeline(options2, resolvedOptions) {
|
|
1286
|
+
const pipelineStart = Date.now();
|
|
1287
|
+
const sessionId = randomUUID();
|
|
1288
|
+
let mainWorkspace = "";
|
|
1289
|
+
try {
|
|
1290
|
+
mainWorkspace = await createWorkspace(sessionId);
|
|
1291
|
+
logger.debug(`Segmented pipeline: workspace at ${mainWorkspace}`);
|
|
1292
|
+
const { resolvedInputPath } = await resolveInput(options2, mainWorkspace);
|
|
1293
|
+
const inputPath = resolvedInputPath ?? resolvedOptions.inputPath;
|
|
1294
|
+
if (!inputPath) {
|
|
1295
|
+
throw new Error("No input path available for segmented pipeline");
|
|
1296
|
+
}
|
|
1297
|
+
const metadata = await getVideoMetadata(inputPath);
|
|
1298
|
+
const totalDuration = parseFloat(metadata.format?.duration ?? "0");
|
|
1299
|
+
if (totalDuration <= 0) {
|
|
1300
|
+
throw new Error(`Invalid video duration: ${totalDuration}`);
|
|
1301
|
+
}
|
|
1302
|
+
logger.debug(`Video duration: ${totalDuration}s`);
|
|
1303
|
+
const segments = computeSegmentPlan(
|
|
1304
|
+
totalDuration,
|
|
1305
|
+
resolvedOptions.maxSegmentDuration,
|
|
1306
|
+
resolvedOptions.maxFrames,
|
|
1307
|
+
resolvedOptions.fps
|
|
1308
|
+
);
|
|
1309
|
+
logger.debug(`Segment plan: ${segments.length} segments`);
|
|
1310
|
+
const limit = concurrencyLimit(resolvedOptions.concurrency);
|
|
1311
|
+
const segmentProgresses = new Array(segments.length).fill(0);
|
|
1312
|
+
const weights = segments.map((s) => s.duration / totalDuration);
|
|
1313
|
+
const emitOverallProgress = (phase) => {
|
|
1314
|
+
if (!options2.onProgress) return;
|
|
1315
|
+
const overall = weights.reduce(
|
|
1316
|
+
(sum, w, i) => sum + w * (segmentProgresses[i] ?? 0),
|
|
1317
|
+
0
|
|
1318
|
+
);
|
|
1319
|
+
options2.onProgress(phase, Math.min(100, overall));
|
|
1320
|
+
};
|
|
1321
|
+
options2.onProgress?.("EXTRACTING", 0);
|
|
1322
|
+
const results = await Promise.all(
|
|
1323
|
+
segments.map(
|
|
1324
|
+
(segment) => limit(async () => {
|
|
1325
|
+
const segWorkspace = await createSegmentWorkspace(
|
|
1326
|
+
mainWorkspace,
|
|
1327
|
+
segment.index
|
|
1328
|
+
);
|
|
1329
|
+
const result = await processSegment(
|
|
1330
|
+
inputPath,
|
|
1331
|
+
segment,
|
|
1332
|
+
segWorkspace,
|
|
1333
|
+
resolvedOptions,
|
|
1334
|
+
(percent) => {
|
|
1335
|
+
segmentProgresses[segment.index] = percent;
|
|
1336
|
+
emitOverallProgress("ANALYZING");
|
|
1337
|
+
}
|
|
1338
|
+
);
|
|
1339
|
+
return result;
|
|
1340
|
+
})
|
|
1341
|
+
)
|
|
1342
|
+
);
|
|
1343
|
+
options2.onProgress?.("ANALYZING", 100);
|
|
1344
|
+
const { frames, edges, animations } = mergeSegmentFrames(results);
|
|
1345
|
+
logger.debug(
|
|
1346
|
+
`Merged: ${frames.length} frames, ${edges.length} edges, ${animations.length} animations`
|
|
1347
|
+
);
|
|
1348
|
+
options2.onProgress?.("PRUNING", 0);
|
|
1349
|
+
const survivingIds = pruneByThresholdWithCap(
|
|
1350
|
+
edges,
|
|
1351
|
+
frames,
|
|
1352
|
+
resolvedOptions.threshold,
|
|
1353
|
+
resolvedOptions.count
|
|
1354
|
+
);
|
|
1355
|
+
const prunedFrames = frames.filter((f) => survivingIds.has(f.id));
|
|
1356
|
+
options2.onProgress?.("PRUNING", 100);
|
|
1357
|
+
options2.onProgress?.("FINALIZING", 0);
|
|
1358
|
+
const ctx = {
|
|
1359
|
+
options: resolvedOptions,
|
|
1360
|
+
workspacePath: mainWorkspace,
|
|
1361
|
+
frames,
|
|
1362
|
+
graph: edges,
|
|
1363
|
+
animations,
|
|
1364
|
+
status: "FINALIZING",
|
|
1365
|
+
emitProgress: (percent) => options2.onProgress?.("FINALIZING", percent)
|
|
1366
|
+
};
|
|
1367
|
+
let outputFiles = [];
|
|
1368
|
+
let outputBuffers;
|
|
1369
|
+
if (resolvedOptions.mode === "buffer" || resolvedOptions.mode === "frames") {
|
|
1370
|
+
outputBuffers = await readFramesAsBuffers(
|
|
1371
|
+
prunedFrames,
|
|
1372
|
+
resolvedOptions.quality
|
|
1373
|
+
);
|
|
1374
|
+
} else {
|
|
1375
|
+
outputFiles = await finalizeOutput(ctx, prunedFrames);
|
|
1376
|
+
}
|
|
1377
|
+
options2.onProgress?.("FINALIZING", 100);
|
|
1378
|
+
logger.success(
|
|
1379
|
+
`Segmented pipeline: ${prunedFrames.length} scenes from ${frames.length} frames (${segments.length} segments)`
|
|
1380
|
+
);
|
|
1381
|
+
return {
|
|
1382
|
+
success: true,
|
|
1383
|
+
originalFramesCount: frames.length,
|
|
1384
|
+
prunedFramesCount: prunedFrames.length,
|
|
1385
|
+
outputFiles,
|
|
1386
|
+
outputBuffers,
|
|
1387
|
+
animations,
|
|
1388
|
+
video: {
|
|
1389
|
+
originalDurationMs: totalDuration * 1e3,
|
|
1390
|
+
fps: resolvedOptions.fps,
|
|
1391
|
+
resolution: {
|
|
1392
|
+
width: resolvedOptions.scale,
|
|
1393
|
+
height: Math.round(resolvedOptions.scale * 9 / 16)
|
|
1394
|
+
}
|
|
1395
|
+
},
|
|
1396
|
+
executionTimeMs: Date.now() - pipelineStart
|
|
1397
|
+
};
|
|
1398
|
+
} catch (error) {
|
|
1399
|
+
const err = error instanceof Error ? error : new Error(String(error));
|
|
1400
|
+
logger.error(`Segmented pipeline failed: ${err.message}`);
|
|
1401
|
+
throw err;
|
|
1402
|
+
} finally {
|
|
1403
|
+
if (!resolvedOptions.debug) {
|
|
1404
|
+
await cleanupWorkspace(mainWorkspace);
|
|
1405
|
+
} else {
|
|
1406
|
+
logger.debug(`Debug mode: workspace preserved at ${mainWorkspace}`);
|
|
1407
|
+
}
|
|
1408
|
+
}
|
|
1409
|
+
}
|
|
1410
|
+
|
|
1036
1411
|
// src/core/orchestrator.ts
|
|
1037
1412
|
async function runPipeline(options2) {
|
|
1038
|
-
const startTime = Date.now();
|
|
1039
|
-
const sessionId = randomUUID();
|
|
1040
1413
|
const debug = options2.debug ?? false;
|
|
1041
1414
|
if (debug) setDebugMode(true);
|
|
1042
1415
|
const resolvedOptions = resolveOptions(options2);
|
|
1416
|
+
if (shouldSegment(resolvedOptions, options2)) {
|
|
1417
|
+
return runSegmentedPipeline(options2, resolvedOptions);
|
|
1418
|
+
}
|
|
1419
|
+
const startTime = Date.now();
|
|
1420
|
+
const sessionId = randomUUID2();
|
|
1043
1421
|
const ctx = {
|
|
1044
1422
|
options: resolvedOptions,
|
|
1045
1423
|
workspacePath: "",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lumy-pack/scene-sieve",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.10",
|
|
4
4
|
"description": "CLI tool for extracting key frames from video and GIF files",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"cli",
|
|
@@ -33,7 +33,7 @@
|
|
|
33
33
|
"main": "dist/index.cjs",
|
|
34
34
|
"module": "dist/index.mjs",
|
|
35
35
|
"types": "dist/index.d.ts",
|
|
36
|
-
"bin": "
|
|
36
|
+
"bin": "dist/cli.mjs",
|
|
37
37
|
"files": [
|
|
38
38
|
"dist",
|
|
39
39
|
"!dist/tsconfig.tsbuildinfo"
|
|
@@ -55,6 +55,7 @@
|
|
|
55
55
|
},
|
|
56
56
|
"dependencies": {
|
|
57
57
|
"@ffprobe-installer/ffprobe": "^1.4.1",
|
|
58
|
+
"@lumy-pack/shared": "0.0.1",
|
|
58
59
|
"@techstark/opencv-js": "4.12.0-release.1",
|
|
59
60
|
"commander": "^12.1.0",
|
|
60
61
|
"execa": "^9.5.0",
|
package/dist/cli.d.ts
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export {};
|
package/dist/commands/Sieve.d.ts
DELETED
|
@@ -1,15 +0,0 @@
|
|
|
1
|
-
import React from 'react';
|
|
2
|
-
export interface SieveViewProps {
|
|
3
|
-
input: string;
|
|
4
|
-
count?: number;
|
|
5
|
-
threshold?: number;
|
|
6
|
-
output?: string;
|
|
7
|
-
fps: number;
|
|
8
|
-
maxFrames: number;
|
|
9
|
-
scale: number;
|
|
10
|
-
quality: number;
|
|
11
|
-
iouThreshold?: number;
|
|
12
|
-
animationThreshold?: number;
|
|
13
|
-
debug: boolean;
|
|
14
|
-
}
|
|
15
|
-
export declare const SieveView: React.FC<SieveViewProps>;
|
|
@@ -1,14 +0,0 @@
|
|
|
1
|
-
import React from 'react';
|
|
2
|
-
export type PhaseStatus = 'pending' | 'running' | 'done' | 'failed';
|
|
3
|
-
export interface PhaseState {
|
|
4
|
-
label: string;
|
|
5
|
-
status: PhaseStatus;
|
|
6
|
-
hasProgress: boolean;
|
|
7
|
-
percent: number;
|
|
8
|
-
durationMs?: number;
|
|
9
|
-
}
|
|
10
|
-
interface PhaseStepProps {
|
|
11
|
-
phase: PhaseState;
|
|
12
|
-
}
|
|
13
|
-
export declare const PhaseStep: React.FC<PhaseStepProps>;
|
|
14
|
-
export {};
|
package/dist/constants.d.ts
DELETED
|
@@ -1,30 +0,0 @@
|
|
|
1
|
-
export declare const APP_NAME = "scene-sieve";
|
|
2
|
-
export declare const DEFAULT_COUNT = 20;
|
|
3
|
-
export declare const DEFAULT_THRESHOLD = 0.5;
|
|
4
|
-
export declare const DEFAULT_FPS = 5;
|
|
5
|
-
export declare const DEFAULT_SCALE = 720;
|
|
6
|
-
export declare const DEFAULT_QUALITY = 80;
|
|
7
|
-
export declare const DEFAULT_MAX_FRAMES = 300;
|
|
8
|
-
export declare const NORMALIZATION_MIN_PERCENTILE = 0.1;
|
|
9
|
-
export declare const NORMALIZATION_MAX_PERCENTILE = 0.9;
|
|
10
|
-
export declare const NORMALIZATION_LOGISTIC_K = 3;
|
|
11
|
-
export declare const NORMALIZATION_ALPHA = 0.4;
|
|
12
|
-
export declare const NORMALIZATION_MAD_COEFFICIENT = 1.4826;
|
|
13
|
-
export declare const NORMALIZATION_MIN_SAMPLE_SIZE = 10;
|
|
14
|
-
export declare const WORKSPACE_PREFIX = "scene-sieve-";
|
|
15
|
-
export declare const TEMP_BASE_DIR: string;
|
|
16
|
-
export declare const FRAME_OUTPUT_EXTENSION = ".jpg";
|
|
17
|
-
export declare const FRAME_FILENAME_PATTERN = "frame_%06d.jpg";
|
|
18
|
-
export declare const OPENCV_BATCH_SIZE = 10;
|
|
19
|
-
export declare const MIN_IFRAME_COUNT = 3;
|
|
20
|
-
export declare const DBSCAN_ALPHA = 0.03;
|
|
21
|
-
export declare const DBSCAN_MIN_PTS = 4;
|
|
22
|
-
export declare const IOU_THRESHOLD = 0.9;
|
|
23
|
-
export declare const DECAY_LAMBDA = 0.95;
|
|
24
|
-
export declare const ANIMATION_FRAME_THRESHOLD = 5;
|
|
25
|
-
export declare const MATCH_DISTANCE_THRESHOLD = 0.25;
|
|
26
|
-
export declare const PIXELDIFF_GAUSSIAN_KERNEL = 3;
|
|
27
|
-
export declare const PIXELDIFF_BINARY_THRESHOLD = 30;
|
|
28
|
-
export declare const PIXELDIFF_CONTOUR_MIN_AREA = 100;
|
|
29
|
-
export declare const PIXELDIFF_SAMPLE_SPACING = 8;
|
|
30
|
-
export declare function getTempWorkspaceDir(sessionId: string): string;
|
package/dist/core/analyzer.d.ts
DELETED
|
@@ -1,62 +0,0 @@
|
|
|
1
|
-
import type { AnalysisResult, AnimationMetadata, BoundingBox, ProcessContext } from '../types/index.js';
|
|
2
|
-
import type { Point2D } from './dbscan.js';
|
|
3
|
-
type CvLib = typeof import('@techstark/opencv-js');
|
|
4
|
-
export declare function preprocessFrame(framePath: string, scale: number): Promise<{
|
|
5
|
-
data: Uint8Array;
|
|
6
|
-
width: number;
|
|
7
|
-
height: number;
|
|
8
|
-
}>;
|
|
9
|
-
export declare function computeIoU(a: BoundingBox, b: BoundingBox): number;
|
|
10
|
-
export declare class IoUTracker {
|
|
11
|
-
private fps;
|
|
12
|
-
private iouThreshold;
|
|
13
|
-
private animationThreshold;
|
|
14
|
-
private regions;
|
|
15
|
-
private extractedAnimations;
|
|
16
|
-
constructor(fps?: number, iouThreshold?: number, animationThreshold?: number);
|
|
17
|
-
update(boxes: BoundingBox[], pairIndex: number): Set<number>;
|
|
18
|
-
private collectAnimation;
|
|
19
|
-
flushAndGetAnimations(): AnimationMetadata[];
|
|
20
|
-
getAnimationWeight(boxIndex: number, boxes: BoundingBox[]): number;
|
|
21
|
-
}
|
|
22
|
-
export interface AKAZEResult {
|
|
23
|
-
sNew: Point2D[];
|
|
24
|
-
sLoss: Point2D[];
|
|
25
|
-
}
|
|
26
|
-
/**
|
|
27
|
-
* Pixel-level difference fallback for AKAZE blind spots.
|
|
28
|
-
*
|
|
29
|
-
* When AKAZE produces sparse results (typical for UI screen recordings
|
|
30
|
-
* where form fields, dropdowns, or overlays change), this function
|
|
31
|
-
* detects changed regions via cv.absdiff and generates synthetic
|
|
32
|
-
* Point2D[] that feed into the existing DBSCAN → IoU → G(t) pipeline.
|
|
33
|
-
*
|
|
34
|
-
* Algorithm:
|
|
35
|
-
* 1. absdiff(frame1, frame2) → grayscale difference
|
|
36
|
-
* 2. GaussianBlur → reduce JPEG compression noise
|
|
37
|
-
* 3. threshold → binary mask of significant changes
|
|
38
|
-
* 4. findContours → bounding rects of changed regions
|
|
39
|
-
* 5. Grid sampling within each bounding rect → Point2D[]
|
|
40
|
-
*/
|
|
41
|
-
export declare function computePixelDiff(cvLib: CvLib, frame1: {
|
|
42
|
-
data: Uint8Array;
|
|
43
|
-
width: number;
|
|
44
|
-
height: number;
|
|
45
|
-
}, frame2: {
|
|
46
|
-
data: Uint8Array;
|
|
47
|
-
width: number;
|
|
48
|
-
height: number;
|
|
49
|
-
}): Point2D[];
|
|
50
|
-
export declare function computeInformationGain(clusters: BoundingBox[], clusterPoints: number[], imageArea: number, animationIndices: Set<number>, animationWeights: number[]): number;
|
|
51
|
-
/**
|
|
52
|
-
* Analyze adjacent frame pairs to compute information gain scores (G(t)).
|
|
53
|
-
* Processes frames in batches for memory efficiency.
|
|
54
|
-
*
|
|
55
|
-
* Pipeline:
|
|
56
|
-
* 1. AKAZE Feature Set Difference
|
|
57
|
-
* 2. DBSCAN Spatial Clustering
|
|
58
|
-
* 3. Spatio-temporal IoU Tracking
|
|
59
|
-
* 4. G(t) Information Gain Scoring
|
|
60
|
-
*/
|
|
61
|
-
export declare function analyzeFrames(ctx: ProcessContext): Promise<AnalysisResult>;
|
|
62
|
-
export {};
|
package/dist/core/dbscan.d.ts
DELETED
|
@@ -1,10 +0,0 @@
|
|
|
1
|
-
import type { DBSCANResult } from '../types/index.js';
|
|
2
|
-
export interface Point2D {
|
|
3
|
-
x: number;
|
|
4
|
-
y: number;
|
|
5
|
-
}
|
|
6
|
-
/**
|
|
7
|
-
* DBSCAN clustering with resolution-independent eps.
|
|
8
|
-
* eps = alpha * sqrt(width^2 + height^2)
|
|
9
|
-
*/
|
|
10
|
-
export declare function dbscan(points: Point2D[], imageWidth: number, imageHeight: number, alpha?: number, minPts?: number): DBSCANResult;
|
package/dist/core/extractor.d.ts
DELETED
|
@@ -1,7 +0,0 @@
|
|
|
1
|
-
import type { FrameNode, ProcessContext } from '../types/index.js';
|
|
2
|
-
/**
|
|
3
|
-
* Extract frames from video/GIF using FFmpeg.
|
|
4
|
-
* Always uses FPS-based extraction. For long videos, FPS is automatically
|
|
5
|
-
* reduced to stay within maxFrames budget.
|
|
6
|
-
*/
|
|
7
|
-
export declare function extractFrames(ctx: ProcessContext): Promise<FrameNode[]>;
|
package/dist/core/index.d.ts
DELETED
|
@@ -1,8 +0,0 @@
|
|
|
1
|
-
export { runPipeline } from './orchestrator.js';
|
|
2
|
-
export { analyzeFrames, computeIoU, computeInformationGain, } from './analyzer.js';
|
|
3
|
-
export { extractFrames } from './extractor.js';
|
|
4
|
-
export { pruneTo, pruneByThreshold, pruneByThresholdWithCap, suppressConsecutiveRuns, } from './pruner.js';
|
|
5
|
-
export { dbscan } from './dbscan.js';
|
|
6
|
-
export type { Point2D } from './dbscan.js';
|
|
7
|
-
export { resolveInput, resolveOptions } from './input-resolver.js';
|
|
8
|
-
export { createWorkspace, cleanupWorkspace, finalizeOutput, readFramesAsBuffers, writeInputBuffer, writeInputFrames, } from './workspace.js';
|
|
@@ -1,13 +0,0 @@
|
|
|
1
|
-
import type { FrameNode, ResolvedOptions, SieveOptions } from '../types/index.js';
|
|
2
|
-
export declare function resolveOptions(options: SieveOptions): ResolvedOptions;
|
|
3
|
-
/**
|
|
4
|
-
* Resolve the input source to a list of FrameNode[].
|
|
5
|
-
*
|
|
6
|
-
* - 'file' mode: validate file exists and delegate to extractor (caller's responsibility)
|
|
7
|
-
* - 'buffer' mode: write buffer as temp video file, return path via FrameNode trick (empty list)
|
|
8
|
-
* - 'frames' mode: write frame buffers as JPGs, return FrameNode[]
|
|
9
|
-
*/
|
|
10
|
-
export declare function resolveInput(options: SieveOptions, workspacePath: string): Promise<{
|
|
11
|
-
frames: FrameNode[];
|
|
12
|
-
resolvedInputPath?: string;
|
|
13
|
-
}>;
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export {};
|