@lumy-pack/scene-sieve 0.0.7 → 0.0.9
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 +459 -23
- package/dist/commands/Sieve.d.ts +2 -0
- package/dist/constants.d.ts +8 -1
- package/dist/core/extractor.d.ts +23 -0
- package/dist/core/index.d.ts +2 -1
- package/dist/core/segmenter.d.ts +38 -0
- package/dist/core/workspace.d.ts +1 -0
- package/dist/index.cjs +423 -19
- package/dist/index.mjs +423 -19
- package/dist/pipeline-worker.mjs +423 -19
- package/dist/types/index.d.ts +22 -0
- package/dist/utils/concurrency.d.ts +5 -0
- package/dist/utils/math.d.ts +27 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -99,6 +99,99 @@ scene-sieve <input> [options]
|
|
|
99
99
|
| Video | `.mp4`, `.mov`, `.avi`, `.mkv`, `.webm` |
|
|
100
100
|
| Animation | `.gif` |
|
|
101
101
|
|
|
102
|
+
### Parameter Tuning Guide
|
|
103
|
+
|
|
104
|
+
Not sure where to start? Here's how each parameter affects the output, based on real benchmarks with a ~19s screen recording (MOV) and a GIF animation.
|
|
105
|
+
|
|
106
|
+
#### `--count` — How many frames to keep
|
|
107
|
+
|
|
108
|
+
| Setting | Extracted | Selected | Notes |
|
|
109
|
+
|---------|-----------|----------|-------|
|
|
110
|
+
| `-n 3` | 90 | 3 | First and last frames are always preserved (boundary protection) |
|
|
111
|
+
| `-n 10` | 90 | 10 | Good for short summaries |
|
|
112
|
+
| `-n 20` (default) | 90 | 20 | Balanced for most videos |
|
|
113
|
+
| `-n 50` | 90 | 22 | Only 22 frames passed the score threshold — count above actual scenes has no effect |
|
|
114
|
+
|
|
115
|
+
#### `--threshold` — Minimum score to keep a frame
|
|
116
|
+
|
|
117
|
+
Higher values = stricter filtering = fewer frames.
|
|
118
|
+
|
|
119
|
+
| Setting | Selected | Notes |
|
|
120
|
+
|---------|----------|-------|
|
|
121
|
+
| `-t 0.1` | 20 | Very permissive — most scene changes pass |
|
|
122
|
+
| `-t 0.3` | 20 | Still permissive for screen recordings |
|
|
123
|
+
| `-t 0.5` (default) | 20 | Capped by the default count of 20 |
|
|
124
|
+
| `-t 0.7` | 19 | Starts filtering subtle changes |
|
|
125
|
+
| `-t 0.9` | 12 | Only major scene transitions survive |
|
|
126
|
+
|
|
127
|
+
> **Tip**: Use `-t` alone for "give me everything important". Combine with `-n` to set an upper bound (e.g., `-t 0.3 -n 10`).
|
|
128
|
+
|
|
129
|
+
#### `--fps` and `--max-frames` — Extraction density
|
|
130
|
+
|
|
131
|
+
These control how many frames are pulled from the video before analysis. More frames = more precision but longer processing.
|
|
132
|
+
|
|
133
|
+
| Setting | Extracted | Selected | Time |
|
|
134
|
+
|---------|-----------|----------|------|
|
|
135
|
+
| `--fps 1` | 18 | 6 | ~5s |
|
|
136
|
+
| `--fps 5` (default) | 90 | 20 | ~25s |
|
|
137
|
+
| `--fps 10` | 180 | 20 | ~47s |
|
|
138
|
+
| `-mf 50` | 47 | 13 | ~13s |
|
|
139
|
+
|
|
140
|
+
> **Tip**: For quick previews, `--fps 1` is 5x faster. For frame-accurate analysis, `--fps 10` captures finer transitions.
|
|
141
|
+
|
|
142
|
+
#### `--scale` — Analysis resolution
|
|
143
|
+
|
|
144
|
+
Controls the resolution used for vision analysis (not output resolution). Lower = faster but less sensitive.
|
|
145
|
+
|
|
146
|
+
| Setting | Selected | Time | Output Size |
|
|
147
|
+
|---------|----------|------|-------------|
|
|
148
|
+
| `-s 360` | 7 | ~6s | 72 KB |
|
|
149
|
+
| `-s 720` (default) | 20 | ~25s | 634 KB |
|
|
150
|
+
| `-s 1080` | 20 | ~54s | 1,172 KB |
|
|
151
|
+
|
|
152
|
+
> **Tip**: `360` is good for quick scans. `720` provides the best speed/quality balance. `1080` is only needed when detecting very subtle UI changes.
|
|
153
|
+
|
|
154
|
+
#### `--iou-threshold` and `--anim-threshold` — Animation sensitivity
|
|
155
|
+
|
|
156
|
+
These control how aggressively repeating animations (spinners, blinking cursors) are detected and suppressed.
|
|
157
|
+
|
|
158
|
+
| Setting | Animations Detected | Notes |
|
|
159
|
+
|---------|-------------------|-------|
|
|
160
|
+
| `-it 0.5 -at 3` | 7 (MOV), 4 (GIF) | Sensitive — catches most repeating motion |
|
|
161
|
+
| `-it 0.9 -at 5` (default) | 0 | Conservative — only obvious loops |
|
|
162
|
+
| `-it 0.95 -at 10` | 0 | Very conservative |
|
|
163
|
+
|
|
164
|
+
> **Tip**: If your video has loading spinners or repeated UI animations, try `-it 0.5 -at 3` to suppress them.
|
|
165
|
+
|
|
166
|
+
#### `--quality` — Output JPEG quality
|
|
167
|
+
|
|
168
|
+
Only affects file size, **not** scene detection. The same frames are selected regardless of quality.
|
|
169
|
+
|
|
170
|
+
| Setting | File Size (5 frames) |
|
|
171
|
+
|---------|---------------------|
|
|
172
|
+
| `-q 30` | 62 KB |
|
|
173
|
+
| `-q 80` (default) | 151 KB |
|
|
174
|
+
| `-q 100` | 407 KB |
|
|
175
|
+
|
|
176
|
+
### Recommended Presets
|
|
177
|
+
|
|
178
|
+
```bash
|
|
179
|
+
# Quick preview — fast, rough selection
|
|
180
|
+
scene-sieve input.mp4 --fps 1 -s 360 -n 10
|
|
181
|
+
|
|
182
|
+
# Balanced (default) — good for most use cases
|
|
183
|
+
scene-sieve input.mp4
|
|
184
|
+
|
|
185
|
+
# High precision — catches subtle transitions
|
|
186
|
+
scene-sieve input.mp4 --fps 10 -s 1080 -t 0.3
|
|
187
|
+
|
|
188
|
+
# UI recording — suppress animations, keep key states
|
|
189
|
+
scene-sieve recording.mov -it 0.5 -at 3 -t 0.3 -n 15
|
|
190
|
+
|
|
191
|
+
# Minimal summary — just the major scenes
|
|
192
|
+
scene-sieve input.mp4 -t 0.9 -n 5
|
|
193
|
+
```
|
|
194
|
+
|
|
102
195
|
### Examples
|
|
103
196
|
|
|
104
197
|
```bash
|
package/dist/cli.mjs
CHANGED
|
@@ -51,7 +51,7 @@ import { join } from "path";
|
|
|
51
51
|
function getTempWorkspaceDir(sessionId) {
|
|
52
52
|
return join(TEMP_BASE_DIR, `${WORKSPACE_PREFIX}${sessionId}`);
|
|
53
53
|
}
|
|
54
|
-
var APP_NAME, DEFAULT_COUNT, DEFAULT_THRESHOLD, DEFAULT_FPS, DEFAULT_SCALE, DEFAULT_QUALITY, DEFAULT_MAX_FRAMES,
|
|
54
|
+
var APP_NAME, DEFAULT_COUNT, DEFAULT_THRESHOLD, DEFAULT_FPS, DEFAULT_SCALE, DEFAULT_QUALITY, DEFAULT_MAX_FRAMES, NORMALIZATION_LOGISTIC_K, NORMALIZATION_ALPHA, NORMALIZATION_MAD_COEFFICIENT, NORMALIZATION_MIN_SAMPLE_SIZE, WORKSPACE_PREFIX, TEMP_BASE_DIR, FRAME_OUTPUT_EXTENSION, FRAME_FILENAME_PATTERN, OPENCV_BATCH_SIZE, DBSCAN_ALPHA, DBSCAN_MIN_PTS, IOU_THRESHOLD, DECAY_LAMBDA, ANIMATION_FRAME_THRESHOLD, MATCH_DISTANCE_THRESHOLD, PIXELDIFF_GAUSSIAN_KERNEL, PIXELDIFF_BINARY_THRESHOLD, PIXELDIFF_CONTOUR_MIN_AREA, PIXELDIFF_SAMPLE_SPACING, DEFAULT_MAX_SEGMENT_DURATION, DEFAULT_SEGMENT_CONCURRENCY;
|
|
55
55
|
var init_constants = __esm({
|
|
56
56
|
"src/constants.ts"() {
|
|
57
57
|
"use strict";
|
|
@@ -62,7 +62,10 @@ var init_constants = __esm({
|
|
|
62
62
|
DEFAULT_SCALE = 720;
|
|
63
63
|
DEFAULT_QUALITY = 80;
|
|
64
64
|
DEFAULT_MAX_FRAMES = 300;
|
|
65
|
-
|
|
65
|
+
NORMALIZATION_LOGISTIC_K = 3;
|
|
66
|
+
NORMALIZATION_ALPHA = 0.4;
|
|
67
|
+
NORMALIZATION_MAD_COEFFICIENT = 1.4826;
|
|
68
|
+
NORMALIZATION_MIN_SAMPLE_SIZE = 10;
|
|
66
69
|
WORKSPACE_PREFIX = `${APP_NAME}-`;
|
|
67
70
|
TEMP_BASE_DIR = tmpdir();
|
|
68
71
|
FRAME_OUTPUT_EXTENSION = ".jpg";
|
|
@@ -78,6 +81,8 @@ var init_constants = __esm({
|
|
|
78
81
|
PIXELDIFF_BINARY_THRESHOLD = 30;
|
|
79
82
|
PIXELDIFF_CONTOUR_MIN_AREA = 100;
|
|
80
83
|
PIXELDIFF_SAMPLE_SPACING = 8;
|
|
84
|
+
DEFAULT_MAX_SEGMENT_DURATION = 300;
|
|
85
|
+
DEFAULT_SEGMENT_CONCURRENCY = 2;
|
|
81
86
|
}
|
|
82
87
|
});
|
|
83
88
|
|
|
@@ -690,6 +695,23 @@ async function buildFrameList(framesDir, duration) {
|
|
|
690
695
|
extractPath: join2(framesDir, file)
|
|
691
696
|
}));
|
|
692
697
|
}
|
|
698
|
+
async function extractFramesForRange(inputPath, outputDir, fps, scale, startTime, duration) {
|
|
699
|
+
const outputPattern = join2(outputDir, FRAME_FILENAME_PATTERN);
|
|
700
|
+
await execa(ffmpegPath, [
|
|
701
|
+
"-ss",
|
|
702
|
+
String(startTime),
|
|
703
|
+
"-i",
|
|
704
|
+
inputPath,
|
|
705
|
+
"-t",
|
|
706
|
+
String(duration),
|
|
707
|
+
"-vf",
|
|
708
|
+
`fps=${fps},scale=-1:${scale}`,
|
|
709
|
+
"-q:v",
|
|
710
|
+
"2",
|
|
711
|
+
outputPattern
|
|
712
|
+
]);
|
|
713
|
+
return buildFrameList(outputDir, duration);
|
|
714
|
+
}
|
|
693
715
|
var init_extractor = __esm({
|
|
694
716
|
"src/core/extractor.ts"() {
|
|
695
717
|
"use strict";
|
|
@@ -757,6 +779,15 @@ async function finalizeOutput(ctx, selectedFrames) {
|
|
|
757
779
|
await rename(stagingDir, outputPath);
|
|
758
780
|
return outputFiles;
|
|
759
781
|
}
|
|
782
|
+
async function createSegmentWorkspace(parentWorkspacePath, segmentIndex) {
|
|
783
|
+
const segmentPath = join3(
|
|
784
|
+
parentWorkspacePath,
|
|
785
|
+
"segments",
|
|
786
|
+
String(segmentIndex)
|
|
787
|
+
);
|
|
788
|
+
await ensureDir(join3(segmentPath, "frames"));
|
|
789
|
+
return segmentPath;
|
|
790
|
+
}
|
|
760
791
|
async function cleanupWorkspace(workspacePath) {
|
|
761
792
|
if (!workspacePath) return;
|
|
762
793
|
try {
|
|
@@ -841,7 +872,9 @@ function resolveOptions(options) {
|
|
|
841
872
|
quality: options.quality ?? DEFAULT_QUALITY,
|
|
842
873
|
iouThreshold: options.iouThreshold ?? IOU_THRESHOLD,
|
|
843
874
|
animationThreshold: options.animationThreshold ?? ANIMATION_FRAME_THRESHOLD,
|
|
844
|
-
debug: options.debug ?? false
|
|
875
|
+
debug: options.debug ?? false,
|
|
876
|
+
maxSegmentDuration: options.maxSegmentDuration ?? DEFAULT_MAX_SEGMENT_DURATION,
|
|
877
|
+
concurrency: options.concurrency ?? DEFAULT_SEGMENT_CONCURRENCY
|
|
845
878
|
};
|
|
846
879
|
}
|
|
847
880
|
async function resolveInput(options, workspacePath) {
|
|
@@ -873,6 +906,49 @@ var init_input_resolver = __esm({
|
|
|
873
906
|
}
|
|
874
907
|
});
|
|
875
908
|
|
|
909
|
+
// src/utils/math.ts
|
|
910
|
+
function normalizeScores(items) {
|
|
911
|
+
if (items.length === 0) return [];
|
|
912
|
+
const safeScores = items.map(
|
|
913
|
+
(e) => Number.isFinite(e.score) && e.score > 0 ? e.score : 0
|
|
914
|
+
);
|
|
915
|
+
const positiveScores = safeScores.filter((s) => s > 0);
|
|
916
|
+
if (positiveScores.length === 0) return safeScores;
|
|
917
|
+
const sorted = [...positiveScores].sort((a, b) => a - b);
|
|
918
|
+
if (positiveScores.length <= NORMALIZATION_MIN_SAMPLE_SIZE) {
|
|
919
|
+
const min = sorted[0];
|
|
920
|
+
const max = sorted[sorted.length - 1];
|
|
921
|
+
if (max === min) return safeScores.map((s) => s > 0 ? 1 : 0);
|
|
922
|
+
return safeScores.map(
|
|
923
|
+
(s) => s <= 0 ? 0 : Math.max(0, Math.min((s - min) / (max - min), 1))
|
|
924
|
+
);
|
|
925
|
+
}
|
|
926
|
+
const median = sorted[Math.floor(sorted.length / 2)];
|
|
927
|
+
const absoluteDiffs = positiveScores.map((v) => Math.abs(v - median));
|
|
928
|
+
const mad = [...absoluteDiffs].sort((a, b) => a - b)[Math.floor(absoluteDiffs.length / 2)];
|
|
929
|
+
const scale = mad === 0 ? median : mad * NORMALIZATION_MAD_COEFFICIENT;
|
|
930
|
+
const logisticZ = safeScores.map((s) => {
|
|
931
|
+
if (s <= 0) return 0;
|
|
932
|
+
if (scale === 0) return 1;
|
|
933
|
+
const z = (s - median) / scale;
|
|
934
|
+
return 1 / (1 + Math.exp(-NORMALIZATION_LOGISTIC_K * z));
|
|
935
|
+
});
|
|
936
|
+
const cdf = safeScores.map((s) => {
|
|
937
|
+
if (s <= 0) return 0;
|
|
938
|
+
const rank = sorted.findIndex((v) => v >= s);
|
|
939
|
+
return rank / sorted.length;
|
|
940
|
+
});
|
|
941
|
+
return logisticZ.map(
|
|
942
|
+
(z, i) => z * (1 - NORMALIZATION_ALPHA) + cdf[i] * NORMALIZATION_ALPHA
|
|
943
|
+
);
|
|
944
|
+
}
|
|
945
|
+
var init_math = __esm({
|
|
946
|
+
"src/utils/math.ts"() {
|
|
947
|
+
"use strict";
|
|
948
|
+
init_constants();
|
|
949
|
+
}
|
|
950
|
+
});
|
|
951
|
+
|
|
876
952
|
// src/utils/min-heap.ts
|
|
877
953
|
var MinHeap;
|
|
878
954
|
var init_min_heap = __esm({
|
|
@@ -973,20 +1049,6 @@ function pruneTo(graph, frames, targetCount) {
|
|
|
973
1049
|
}
|
|
974
1050
|
return surviving;
|
|
975
1051
|
}
|
|
976
|
-
function normalizeScores(graph) {
|
|
977
|
-
if (graph.length === 0) return [];
|
|
978
|
-
const safeScores = graph.map(
|
|
979
|
-
(e) => Number.isFinite(e.score) && e.score > 0 ? e.score : 0
|
|
980
|
-
);
|
|
981
|
-
const sorted = [...safeScores].filter((s) => s > 0).sort((a, b) => a - b);
|
|
982
|
-
if (sorted.length === 0) return safeScores;
|
|
983
|
-
const pIdx = Math.min(
|
|
984
|
-
Math.floor(sorted.length * NORMALIZATION_PERCENTILE),
|
|
985
|
-
sorted.length - 1
|
|
986
|
-
);
|
|
987
|
-
const refScore = sorted[pIdx];
|
|
988
|
-
return safeScores.map((s) => Math.min(s / refScore, 1));
|
|
989
|
-
}
|
|
990
1052
|
function suppressConsecutiveRuns(graph, passingIndices, normalizedScores) {
|
|
991
1053
|
const result = /* @__PURE__ */ new Set();
|
|
992
1054
|
let runStart = 0;
|
|
@@ -1086,23 +1148,386 @@ function pruneByThresholdWithCap(graph, frames, threshold, maxCount) {
|
|
|
1086
1148
|
var init_pruner = __esm({
|
|
1087
1149
|
"src/core/pruner.ts"() {
|
|
1088
1150
|
"use strict";
|
|
1089
|
-
|
|
1151
|
+
init_math();
|
|
1090
1152
|
init_min_heap();
|
|
1091
1153
|
}
|
|
1092
1154
|
});
|
|
1093
1155
|
|
|
1156
|
+
// src/utils/concurrency.ts
|
|
1157
|
+
function concurrencyLimit(limit) {
|
|
1158
|
+
limit = Math.max(1, limit);
|
|
1159
|
+
let active = 0;
|
|
1160
|
+
const queue = [];
|
|
1161
|
+
return async (fn) => {
|
|
1162
|
+
while (active >= limit) {
|
|
1163
|
+
await new Promise((resolve2) => queue.push(resolve2));
|
|
1164
|
+
}
|
|
1165
|
+
active++;
|
|
1166
|
+
try {
|
|
1167
|
+
return await fn();
|
|
1168
|
+
} finally {
|
|
1169
|
+
active--;
|
|
1170
|
+
queue.shift()?.();
|
|
1171
|
+
}
|
|
1172
|
+
};
|
|
1173
|
+
}
|
|
1174
|
+
var init_concurrency = __esm({
|
|
1175
|
+
"src/utils/concurrency.ts"() {
|
|
1176
|
+
"use strict";
|
|
1177
|
+
}
|
|
1178
|
+
});
|
|
1179
|
+
|
|
1180
|
+
// src/core/segmenter.ts
|
|
1181
|
+
import { randomUUID } from "crypto";
|
|
1182
|
+
import { join as join5 } from "path";
|
|
1183
|
+
function shouldSegment(resolvedOptions, originalOptions) {
|
|
1184
|
+
if (resolvedOptions.mode === "frames") return false;
|
|
1185
|
+
if (originalOptions.mode === "file") {
|
|
1186
|
+
if (originalOptions.inputPath.toLowerCase().endsWith(".gif")) return false;
|
|
1187
|
+
}
|
|
1188
|
+
return true;
|
|
1189
|
+
}
|
|
1190
|
+
function computeSegmentPlan(totalDuration, maxSegmentDuration, maxFrames, fps) {
|
|
1191
|
+
const effectiveFps = Math.max(0.5, Math.min(fps, maxFrames / totalDuration));
|
|
1192
|
+
if (totalDuration <= maxSegmentDuration) {
|
|
1193
|
+
return [
|
|
1194
|
+
{
|
|
1195
|
+
index: 0,
|
|
1196
|
+
startTime: 0,
|
|
1197
|
+
endTime: totalDuration,
|
|
1198
|
+
duration: totalDuration,
|
|
1199
|
+
allocatedFrames: Math.min(
|
|
1200
|
+
Math.ceil(effectiveFps * totalDuration),
|
|
1201
|
+
maxFrames
|
|
1202
|
+
),
|
|
1203
|
+
effectiveFps,
|
|
1204
|
+
overlapBefore: 0,
|
|
1205
|
+
overlapAfter: 0,
|
|
1206
|
+
extractStartTime: 0,
|
|
1207
|
+
extractDuration: totalDuration
|
|
1208
|
+
}
|
|
1209
|
+
];
|
|
1210
|
+
}
|
|
1211
|
+
const segmentCount = Math.ceil(totalDuration / maxSegmentDuration);
|
|
1212
|
+
const overlapTime = 1 / effectiveFps;
|
|
1213
|
+
const segments = [];
|
|
1214
|
+
for (let i = 0; i < segmentCount; i++) {
|
|
1215
|
+
const startTime = i * maxSegmentDuration;
|
|
1216
|
+
const endTime = Math.min((i + 1) * maxSegmentDuration, totalDuration);
|
|
1217
|
+
const duration = endTime - startTime;
|
|
1218
|
+
const overlapBefore = i > 0 ? 1 : 0;
|
|
1219
|
+
const overlapAfter = i < segmentCount - 1 ? 1 : 0;
|
|
1220
|
+
const extractStartTime = Math.max(
|
|
1221
|
+
0,
|
|
1222
|
+
startTime - overlapBefore * overlapTime
|
|
1223
|
+
);
|
|
1224
|
+
const extractEndTime = Math.min(
|
|
1225
|
+
totalDuration,
|
|
1226
|
+
endTime + overlapAfter * overlapTime
|
|
1227
|
+
);
|
|
1228
|
+
const extractDuration = extractEndTime - extractStartTime;
|
|
1229
|
+
segments.push({
|
|
1230
|
+
index: i,
|
|
1231
|
+
startTime,
|
|
1232
|
+
endTime,
|
|
1233
|
+
duration,
|
|
1234
|
+
allocatedFrames: Math.ceil(effectiveFps * duration),
|
|
1235
|
+
effectiveFps,
|
|
1236
|
+
overlapBefore,
|
|
1237
|
+
overlapAfter,
|
|
1238
|
+
extractStartTime,
|
|
1239
|
+
extractDuration
|
|
1240
|
+
});
|
|
1241
|
+
}
|
|
1242
|
+
const totalAllocated = segments.reduce(
|
|
1243
|
+
(sum, s) => sum + s.allocatedFrames,
|
|
1244
|
+
0
|
|
1245
|
+
);
|
|
1246
|
+
if (totalAllocated > maxFrames) {
|
|
1247
|
+
segments[segments.length - 1].allocatedFrames -= totalAllocated - maxFrames;
|
|
1248
|
+
}
|
|
1249
|
+
return segments;
|
|
1250
|
+
}
|
|
1251
|
+
function mergeSegmentFrames(segmentResults) {
|
|
1252
|
+
if (segmentResults.length === 0) {
|
|
1253
|
+
return { frames: [], edges: [], animations: [] };
|
|
1254
|
+
}
|
|
1255
|
+
const allFrames = [];
|
|
1256
|
+
for (const result of segmentResults) {
|
|
1257
|
+
for (const frame of result.frames) {
|
|
1258
|
+
allFrames.push({
|
|
1259
|
+
frame: {
|
|
1260
|
+
...frame,
|
|
1261
|
+
// Use extractStartTime for timestamp correction (Section 18 note 1)
|
|
1262
|
+
timestamp: frame.timestamp + result.segment.extractStartTime
|
|
1263
|
+
},
|
|
1264
|
+
segmentIndex: result.segment.index,
|
|
1265
|
+
localId: frame.id
|
|
1266
|
+
});
|
|
1267
|
+
}
|
|
1268
|
+
}
|
|
1269
|
+
allFrames.sort((a, b) => a.frame.timestamp - b.frame.timestamp);
|
|
1270
|
+
const effectiveFps = segmentResults[0].segment.effectiveFps;
|
|
1271
|
+
const dupThreshold = 1 / (effectiveFps * 2);
|
|
1272
|
+
const uniqueFrames = [];
|
|
1273
|
+
for (const entry of allFrames) {
|
|
1274
|
+
if (uniqueFrames.length > 0) {
|
|
1275
|
+
const last = uniqueFrames[uniqueFrames.length - 1];
|
|
1276
|
+
if (Math.abs(entry.frame.timestamp - last.frame.timestamp) < dupThreshold) {
|
|
1277
|
+
continue;
|
|
1278
|
+
}
|
|
1279
|
+
}
|
|
1280
|
+
uniqueFrames.push(entry);
|
|
1281
|
+
}
|
|
1282
|
+
const globalIdMap = /* @__PURE__ */ new Map();
|
|
1283
|
+
const frames = uniqueFrames.map((entry, globalId) => {
|
|
1284
|
+
globalIdMap.set(`${entry.segmentIndex}:${entry.localId}`, globalId);
|
|
1285
|
+
return {
|
|
1286
|
+
id: globalId,
|
|
1287
|
+
timestamp: entry.frame.timestamp,
|
|
1288
|
+
extractPath: entry.frame.extractPath
|
|
1289
|
+
};
|
|
1290
|
+
});
|
|
1291
|
+
const edges = [];
|
|
1292
|
+
const edgeMap = /* @__PURE__ */ new Map();
|
|
1293
|
+
for (const result of segmentResults) {
|
|
1294
|
+
for (const edge of result.edges) {
|
|
1295
|
+
const newSourceId = globalIdMap.get(
|
|
1296
|
+
`${result.segment.index}:${edge.sourceId}`
|
|
1297
|
+
);
|
|
1298
|
+
const newTargetId = globalIdMap.get(
|
|
1299
|
+
`${result.segment.index}:${edge.targetId}`
|
|
1300
|
+
);
|
|
1301
|
+
if (newSourceId === void 0 || newTargetId === void 0) continue;
|
|
1302
|
+
const edgeKey = `${newSourceId}-${newTargetId}`;
|
|
1303
|
+
const existingIdx = edgeMap.get(edgeKey);
|
|
1304
|
+
if (existingIdx !== void 0) {
|
|
1305
|
+
if (edges[existingIdx].score < edge.score) {
|
|
1306
|
+
edges[existingIdx] = {
|
|
1307
|
+
sourceId: newSourceId,
|
|
1308
|
+
targetId: newTargetId,
|
|
1309
|
+
score: edge.score
|
|
1310
|
+
};
|
|
1311
|
+
}
|
|
1312
|
+
} else {
|
|
1313
|
+
edgeMap.set(edgeKey, edges.length);
|
|
1314
|
+
edges.push({
|
|
1315
|
+
sourceId: newSourceId,
|
|
1316
|
+
targetId: newTargetId,
|
|
1317
|
+
score: edge.score
|
|
1318
|
+
});
|
|
1319
|
+
}
|
|
1320
|
+
}
|
|
1321
|
+
}
|
|
1322
|
+
const animations = [];
|
|
1323
|
+
for (const result of segmentResults) {
|
|
1324
|
+
for (const anim of result.animations) {
|
|
1325
|
+
const newStartId = globalIdMap.get(
|
|
1326
|
+
`${result.segment.index}:${anim.startFrameId}`
|
|
1327
|
+
);
|
|
1328
|
+
const newEndId = globalIdMap.get(
|
|
1329
|
+
`${result.segment.index}:${anim.endFrameId}`
|
|
1330
|
+
);
|
|
1331
|
+
if (newStartId === void 0 || newEndId === void 0) continue;
|
|
1332
|
+
animations.push({
|
|
1333
|
+
...anim,
|
|
1334
|
+
startFrameId: newStartId,
|
|
1335
|
+
endFrameId: newEndId
|
|
1336
|
+
});
|
|
1337
|
+
}
|
|
1338
|
+
}
|
|
1339
|
+
return { frames, edges, animations };
|
|
1340
|
+
}
|
|
1341
|
+
function buildSegmentContext(segment, frames, segmentWorkspacePath, resolvedOptions, onProgress) {
|
|
1342
|
+
return {
|
|
1343
|
+
options: {
|
|
1344
|
+
...resolvedOptions,
|
|
1345
|
+
fps: segment.effectiveFps,
|
|
1346
|
+
maxFrames: segment.allocatedFrames
|
|
1347
|
+
},
|
|
1348
|
+
workspacePath: segmentWorkspacePath,
|
|
1349
|
+
frames,
|
|
1350
|
+
graph: [],
|
|
1351
|
+
status: "ANALYZING",
|
|
1352
|
+
emitProgress: onProgress
|
|
1353
|
+
};
|
|
1354
|
+
}
|
|
1355
|
+
async function processSegment(inputPath, segment, workspacePath, resolvedOptions, onProgress) {
|
|
1356
|
+
const framesDir = join5(workspacePath, "frames");
|
|
1357
|
+
const frames = await extractFramesForRange(
|
|
1358
|
+
inputPath,
|
|
1359
|
+
framesDir,
|
|
1360
|
+
segment.effectiveFps,
|
|
1361
|
+
resolvedOptions.scale,
|
|
1362
|
+
segment.extractStartTime,
|
|
1363
|
+
segment.extractDuration
|
|
1364
|
+
);
|
|
1365
|
+
if (frames.length < 2) {
|
|
1366
|
+
return { segment, frames, edges: [], animations: [] };
|
|
1367
|
+
}
|
|
1368
|
+
const ctx = buildSegmentContext(
|
|
1369
|
+
segment,
|
|
1370
|
+
frames,
|
|
1371
|
+
workspacePath,
|
|
1372
|
+
resolvedOptions,
|
|
1373
|
+
onProgress
|
|
1374
|
+
);
|
|
1375
|
+
const { edges, animations } = await analyzeFrames(ctx);
|
|
1376
|
+
return { segment, frames, edges, animations };
|
|
1377
|
+
}
|
|
1378
|
+
async function runSegmentedPipeline(options, resolvedOptions) {
|
|
1379
|
+
const pipelineStart = Date.now();
|
|
1380
|
+
const sessionId = randomUUID();
|
|
1381
|
+
let mainWorkspace = "";
|
|
1382
|
+
try {
|
|
1383
|
+
mainWorkspace = await createWorkspace(sessionId);
|
|
1384
|
+
logger.debug(`Segmented pipeline: workspace at ${mainWorkspace}`);
|
|
1385
|
+
const { resolvedInputPath } = await resolveInput(options, mainWorkspace);
|
|
1386
|
+
const inputPath = resolvedInputPath ?? resolvedOptions.inputPath;
|
|
1387
|
+
if (!inputPath) {
|
|
1388
|
+
throw new Error("No input path available for segmented pipeline");
|
|
1389
|
+
}
|
|
1390
|
+
const metadata = await getVideoMetadata(inputPath);
|
|
1391
|
+
const totalDuration = parseFloat(metadata.format?.duration ?? "0");
|
|
1392
|
+
if (totalDuration <= 0) {
|
|
1393
|
+
throw new Error(`Invalid video duration: ${totalDuration}`);
|
|
1394
|
+
}
|
|
1395
|
+
logger.debug(`Video duration: ${totalDuration}s`);
|
|
1396
|
+
const segments = computeSegmentPlan(
|
|
1397
|
+
totalDuration,
|
|
1398
|
+
resolvedOptions.maxSegmentDuration,
|
|
1399
|
+
resolvedOptions.maxFrames,
|
|
1400
|
+
resolvedOptions.fps
|
|
1401
|
+
);
|
|
1402
|
+
logger.debug(`Segment plan: ${segments.length} segments`);
|
|
1403
|
+
const limit = concurrencyLimit(resolvedOptions.concurrency);
|
|
1404
|
+
const segmentProgresses = new Array(segments.length).fill(0);
|
|
1405
|
+
const weights = segments.map((s) => s.duration / totalDuration);
|
|
1406
|
+
const emitOverallProgress = (phase) => {
|
|
1407
|
+
if (!options.onProgress) return;
|
|
1408
|
+
const overall = weights.reduce(
|
|
1409
|
+
(sum, w, i) => sum + w * (segmentProgresses[i] ?? 0),
|
|
1410
|
+
0
|
|
1411
|
+
);
|
|
1412
|
+
options.onProgress(phase, Math.min(100, overall));
|
|
1413
|
+
};
|
|
1414
|
+
options.onProgress?.("EXTRACTING", 0);
|
|
1415
|
+
const results = await Promise.all(
|
|
1416
|
+
segments.map(
|
|
1417
|
+
(segment) => limit(async () => {
|
|
1418
|
+
const segWorkspace = await createSegmentWorkspace(
|
|
1419
|
+
mainWorkspace,
|
|
1420
|
+
segment.index
|
|
1421
|
+
);
|
|
1422
|
+
const result = await processSegment(
|
|
1423
|
+
inputPath,
|
|
1424
|
+
segment,
|
|
1425
|
+
segWorkspace,
|
|
1426
|
+
resolvedOptions,
|
|
1427
|
+
(percent) => {
|
|
1428
|
+
segmentProgresses[segment.index] = percent;
|
|
1429
|
+
emitOverallProgress("ANALYZING");
|
|
1430
|
+
}
|
|
1431
|
+
);
|
|
1432
|
+
return result;
|
|
1433
|
+
})
|
|
1434
|
+
)
|
|
1435
|
+
);
|
|
1436
|
+
options.onProgress?.("ANALYZING", 100);
|
|
1437
|
+
const { frames, edges, animations } = mergeSegmentFrames(results);
|
|
1438
|
+
logger.debug(
|
|
1439
|
+
`Merged: ${frames.length} frames, ${edges.length} edges, ${animations.length} animations`
|
|
1440
|
+
);
|
|
1441
|
+
options.onProgress?.("PRUNING", 0);
|
|
1442
|
+
const survivingIds = pruneByThresholdWithCap(
|
|
1443
|
+
edges,
|
|
1444
|
+
frames,
|
|
1445
|
+
resolvedOptions.threshold,
|
|
1446
|
+
resolvedOptions.count
|
|
1447
|
+
);
|
|
1448
|
+
const prunedFrames = frames.filter((f) => survivingIds.has(f.id));
|
|
1449
|
+
options.onProgress?.("PRUNING", 100);
|
|
1450
|
+
options.onProgress?.("FINALIZING", 0);
|
|
1451
|
+
const ctx = {
|
|
1452
|
+
options: resolvedOptions,
|
|
1453
|
+
workspacePath: mainWorkspace,
|
|
1454
|
+
frames,
|
|
1455
|
+
graph: edges,
|
|
1456
|
+
animations,
|
|
1457
|
+
status: "FINALIZING",
|
|
1458
|
+
emitProgress: (percent) => options.onProgress?.("FINALIZING", percent)
|
|
1459
|
+
};
|
|
1460
|
+
let outputFiles = [];
|
|
1461
|
+
let outputBuffers;
|
|
1462
|
+
if (resolvedOptions.mode === "buffer" || resolvedOptions.mode === "frames") {
|
|
1463
|
+
outputBuffers = await readFramesAsBuffers(
|
|
1464
|
+
prunedFrames,
|
|
1465
|
+
resolvedOptions.quality
|
|
1466
|
+
);
|
|
1467
|
+
} else {
|
|
1468
|
+
outputFiles = await finalizeOutput(ctx, prunedFrames);
|
|
1469
|
+
}
|
|
1470
|
+
options.onProgress?.("FINALIZING", 100);
|
|
1471
|
+
logger.success(
|
|
1472
|
+
`Segmented pipeline: ${prunedFrames.length} scenes from ${frames.length} frames (${segments.length} segments)`
|
|
1473
|
+
);
|
|
1474
|
+
return {
|
|
1475
|
+
success: true,
|
|
1476
|
+
originalFramesCount: frames.length,
|
|
1477
|
+
prunedFramesCount: prunedFrames.length,
|
|
1478
|
+
outputFiles,
|
|
1479
|
+
outputBuffers,
|
|
1480
|
+
animations,
|
|
1481
|
+
video: {
|
|
1482
|
+
originalDurationMs: totalDuration * 1e3,
|
|
1483
|
+
fps: resolvedOptions.fps,
|
|
1484
|
+
resolution: {
|
|
1485
|
+
width: resolvedOptions.scale,
|
|
1486
|
+
height: Math.round(resolvedOptions.scale * 9 / 16)
|
|
1487
|
+
}
|
|
1488
|
+
},
|
|
1489
|
+
executionTimeMs: Date.now() - pipelineStart
|
|
1490
|
+
};
|
|
1491
|
+
} catch (error) {
|
|
1492
|
+
const err = error instanceof Error ? error : new Error(String(error));
|
|
1493
|
+
logger.error(`Segmented pipeline failed: ${err.message}`);
|
|
1494
|
+
throw err;
|
|
1495
|
+
} finally {
|
|
1496
|
+
if (!resolvedOptions.debug) {
|
|
1497
|
+
await cleanupWorkspace(mainWorkspace);
|
|
1498
|
+
} else {
|
|
1499
|
+
logger.debug(`Debug mode: workspace preserved at ${mainWorkspace}`);
|
|
1500
|
+
}
|
|
1501
|
+
}
|
|
1502
|
+
}
|
|
1503
|
+
var init_segmenter = __esm({
|
|
1504
|
+
"src/core/segmenter.ts"() {
|
|
1505
|
+
"use strict";
|
|
1506
|
+
init_concurrency();
|
|
1507
|
+
init_logger();
|
|
1508
|
+
init_analyzer();
|
|
1509
|
+
init_extractor();
|
|
1510
|
+
init_input_resolver();
|
|
1511
|
+
init_pruner();
|
|
1512
|
+
init_workspace();
|
|
1513
|
+
}
|
|
1514
|
+
});
|
|
1515
|
+
|
|
1094
1516
|
// src/core/orchestrator.ts
|
|
1095
1517
|
var orchestrator_exports = {};
|
|
1096
1518
|
__export(orchestrator_exports, {
|
|
1097
1519
|
runPipeline: () => runPipeline
|
|
1098
1520
|
});
|
|
1099
|
-
import { randomUUID } from "crypto";
|
|
1521
|
+
import { randomUUID as randomUUID2 } from "crypto";
|
|
1100
1522
|
async function runPipeline(options) {
|
|
1101
|
-
const startTime = Date.now();
|
|
1102
|
-
const sessionId = randomUUID();
|
|
1103
1523
|
const debug = options.debug ?? false;
|
|
1104
1524
|
if (debug) setDebugMode(true);
|
|
1105
1525
|
const resolvedOptions = resolveOptions(options);
|
|
1526
|
+
if (shouldSegment(resolvedOptions, options)) {
|
|
1527
|
+
return runSegmentedPipeline(options, resolvedOptions);
|
|
1528
|
+
}
|
|
1529
|
+
const startTime = Date.now();
|
|
1530
|
+
const sessionId = randomUUID2();
|
|
1106
1531
|
const ctx = {
|
|
1107
1532
|
options: resolvedOptions,
|
|
1108
1533
|
workspacePath: "",
|
|
@@ -1204,6 +1629,7 @@ var init_orchestrator = __esm({
|
|
|
1204
1629
|
init_extractor();
|
|
1205
1630
|
init_input_resolver();
|
|
1206
1631
|
init_pruner();
|
|
1632
|
+
init_segmenter();
|
|
1207
1633
|
init_workspace();
|
|
1208
1634
|
}
|
|
1209
1635
|
});
|
|
@@ -1278,7 +1704,7 @@ var PhaseStep = ({ phase }) => {
|
|
|
1278
1704
|
};
|
|
1279
1705
|
|
|
1280
1706
|
// src/core/run-in-worker.ts
|
|
1281
|
-
import { dirname, join as
|
|
1707
|
+
import { dirname, join as join6 } from "path";
|
|
1282
1708
|
import { fileURLToPath } from "url";
|
|
1283
1709
|
import { Worker } from "worker_threads";
|
|
1284
1710
|
async function runPipelineInWorker(options, onProgress) {
|
|
@@ -1287,7 +1713,7 @@ async function runPipelineInWorker(options, onProgress) {
|
|
|
1287
1713
|
const { runPipeline: runPipeline2 } = await Promise.resolve().then(() => (init_orchestrator(), orchestrator_exports));
|
|
1288
1714
|
return runPipeline2({ ...options, onProgress });
|
|
1289
1715
|
}
|
|
1290
|
-
const workerPath =
|
|
1716
|
+
const workerPath = join6(dirname(currentFile), "pipeline-worker.mjs");
|
|
1291
1717
|
return new Promise((resolve2, reject) => {
|
|
1292
1718
|
const worker = new Worker(workerPath, { workerData: options });
|
|
1293
1719
|
worker.on(
|
|
@@ -1365,6 +1791,8 @@ var SieveView = (props) => {
|
|
|
1365
1791
|
quality: props.quality,
|
|
1366
1792
|
iouThreshold: props.iouThreshold,
|
|
1367
1793
|
animationThreshold: props.animationThreshold,
|
|
1794
|
+
maxSegmentDuration: props.maxSegmentDuration,
|
|
1795
|
+
concurrency: props.concurrency,
|
|
1368
1796
|
debug: props.debug
|
|
1369
1797
|
},
|
|
1370
1798
|
(phase, percent) => {
|
|
@@ -1512,6 +1940,12 @@ program.name("scene-sieve").description("Extract key frames from video and GIF f
|
|
|
1512
1940
|
).option(
|
|
1513
1941
|
"-at, --anim-threshold <number>",
|
|
1514
1942
|
`Min consecutive frames for animation (default: ${ANIMATION_FRAME_THRESHOLD})`
|
|
1943
|
+
).option(
|
|
1944
|
+
"--max-segment-duration <number>",
|
|
1945
|
+
`Max segment duration in seconds for long video splitting (default: ${DEFAULT_MAX_SEGMENT_DURATION})`
|
|
1946
|
+
).option(
|
|
1947
|
+
"--concurrency <number>",
|
|
1948
|
+
`Number of segments to process in parallel (default: ${DEFAULT_SEGMENT_CONCURRENCY})`
|
|
1515
1949
|
).option("--debug", "Enable debug mode (preserve temp workspace)").action(async (input, opts) => {
|
|
1516
1950
|
const { waitUntilExit } = render(
|
|
1517
1951
|
React2.createElement(SieveView, {
|
|
@@ -1525,6 +1959,8 @@ program.name("scene-sieve").description("Extract key frames from video and GIF f
|
|
|
1525
1959
|
quality: parseInt(opts.quality, 10),
|
|
1526
1960
|
iouThreshold: opts.iouThreshold !== void 0 ? parseFloat(opts.iouThreshold) : void 0,
|
|
1527
1961
|
animationThreshold: opts.animThreshold !== void 0 ? parseInt(opts.animThreshold, 10) : void 0,
|
|
1962
|
+
maxSegmentDuration: opts.maxSegmentDuration !== void 0 ? parseInt(opts.maxSegmentDuration, 10) : void 0,
|
|
1963
|
+
concurrency: opts.concurrency !== void 0 ? parseInt(opts.concurrency, 10) : void 0,
|
|
1528
1964
|
debug: opts.debug ?? false
|
|
1529
1965
|
})
|
|
1530
1966
|
);
|
package/dist/commands/Sieve.d.ts
CHANGED