@lumy-pack/scene-sieve 0.0.8 → 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 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, 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;
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";
@@ -81,6 +81,8 @@ var init_constants = __esm({
81
81
  PIXELDIFF_BINARY_THRESHOLD = 30;
82
82
  PIXELDIFF_CONTOUR_MIN_AREA = 100;
83
83
  PIXELDIFF_SAMPLE_SPACING = 8;
84
+ DEFAULT_MAX_SEGMENT_DURATION = 300;
85
+ DEFAULT_SEGMENT_CONCURRENCY = 2;
84
86
  }
85
87
  });
86
88
 
@@ -693,6 +695,23 @@ async function buildFrameList(framesDir, duration) {
693
695
  extractPath: join2(framesDir, file)
694
696
  }));
695
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
+ }
696
715
  var init_extractor = __esm({
697
716
  "src/core/extractor.ts"() {
698
717
  "use strict";
@@ -760,6 +779,15 @@ async function finalizeOutput(ctx, selectedFrames) {
760
779
  await rename(stagingDir, outputPath);
761
780
  return outputFiles;
762
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
+ }
763
791
  async function cleanupWorkspace(workspacePath) {
764
792
  if (!workspacePath) return;
765
793
  try {
@@ -844,7 +872,9 @@ function resolveOptions(options) {
844
872
  quality: options.quality ?? DEFAULT_QUALITY,
845
873
  iouThreshold: options.iouThreshold ?? IOU_THRESHOLD,
846
874
  animationThreshold: options.animationThreshold ?? ANIMATION_FRAME_THRESHOLD,
847
- debug: options.debug ?? false
875
+ debug: options.debug ?? false,
876
+ maxSegmentDuration: options.maxSegmentDuration ?? DEFAULT_MAX_SEGMENT_DURATION,
877
+ concurrency: options.concurrency ?? DEFAULT_SEGMENT_CONCURRENCY
848
878
  };
849
879
  }
850
880
  async function resolveInput(options, workspacePath) {
@@ -1123,18 +1153,381 @@ var init_pruner = __esm({
1123
1153
  }
1124
1154
  });
1125
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
+
1126
1516
  // src/core/orchestrator.ts
1127
1517
  var orchestrator_exports = {};
1128
1518
  __export(orchestrator_exports, {
1129
1519
  runPipeline: () => runPipeline
1130
1520
  });
1131
- import { randomUUID } from "crypto";
1521
+ import { randomUUID as randomUUID2 } from "crypto";
1132
1522
  async function runPipeline(options) {
1133
- const startTime = Date.now();
1134
- const sessionId = randomUUID();
1135
1523
  const debug = options.debug ?? false;
1136
1524
  if (debug) setDebugMode(true);
1137
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();
1138
1531
  const ctx = {
1139
1532
  options: resolvedOptions,
1140
1533
  workspacePath: "",
@@ -1236,6 +1629,7 @@ var init_orchestrator = __esm({
1236
1629
  init_extractor();
1237
1630
  init_input_resolver();
1238
1631
  init_pruner();
1632
+ init_segmenter();
1239
1633
  init_workspace();
1240
1634
  }
1241
1635
  });
@@ -1310,7 +1704,7 @@ var PhaseStep = ({ phase }) => {
1310
1704
  };
1311
1705
 
1312
1706
  // src/core/run-in-worker.ts
1313
- import { dirname, join as join5 } from "path";
1707
+ import { dirname, join as join6 } from "path";
1314
1708
  import { fileURLToPath } from "url";
1315
1709
  import { Worker } from "worker_threads";
1316
1710
  async function runPipelineInWorker(options, onProgress) {
@@ -1319,7 +1713,7 @@ async function runPipelineInWorker(options, onProgress) {
1319
1713
  const { runPipeline: runPipeline2 } = await Promise.resolve().then(() => (init_orchestrator(), orchestrator_exports));
1320
1714
  return runPipeline2({ ...options, onProgress });
1321
1715
  }
1322
- const workerPath = join5(dirname(currentFile), "pipeline-worker.mjs");
1716
+ const workerPath = join6(dirname(currentFile), "pipeline-worker.mjs");
1323
1717
  return new Promise((resolve2, reject) => {
1324
1718
  const worker = new Worker(workerPath, { workerData: options });
1325
1719
  worker.on(
@@ -1397,6 +1791,8 @@ var SieveView = (props) => {
1397
1791
  quality: props.quality,
1398
1792
  iouThreshold: props.iouThreshold,
1399
1793
  animationThreshold: props.animationThreshold,
1794
+ maxSegmentDuration: props.maxSegmentDuration,
1795
+ concurrency: props.concurrency,
1400
1796
  debug: props.debug
1401
1797
  },
1402
1798
  (phase, percent) => {
@@ -1544,6 +1940,12 @@ program.name("scene-sieve").description("Extract key frames from video and GIF f
1544
1940
  ).option(
1545
1941
  "-at, --anim-threshold <number>",
1546
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})`
1547
1949
  ).option("--debug", "Enable debug mode (preserve temp workspace)").action(async (input, opts) => {
1548
1950
  const { waitUntilExit } = render(
1549
1951
  React2.createElement(SieveView, {
@@ -1557,6 +1959,8 @@ program.name("scene-sieve").description("Extract key frames from video and GIF f
1557
1959
  quality: parseInt(opts.quality, 10),
1558
1960
  iouThreshold: opts.iouThreshold !== void 0 ? parseFloat(opts.iouThreshold) : void 0,
1559
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,
1560
1964
  debug: opts.debug ?? false
1561
1965
  })
1562
1966
  );
@@ -10,6 +10,8 @@ export interface SieveViewProps {
10
10
  quality: number;
11
11
  iouThreshold?: number;
12
12
  animationThreshold?: number;
13
+ maxSegmentDuration?: number;
14
+ concurrency?: number;
13
15
  debug: boolean;
14
16
  }
15
17
  export declare const SieveView: React.FC<SieveViewProps>;
@@ -27,4 +27,6 @@ export declare const PIXELDIFF_GAUSSIAN_KERNEL = 3;
27
27
  export declare const PIXELDIFF_BINARY_THRESHOLD = 30;
28
28
  export declare const PIXELDIFF_CONTOUR_MIN_AREA = 100;
29
29
  export declare const PIXELDIFF_SAMPLE_SPACING = 8;
30
+ export declare const DEFAULT_MAX_SEGMENT_DURATION = 300;
31
+ export declare const DEFAULT_SEGMENT_CONCURRENCY = 2;
30
32
  export declare function getTempWorkspaceDir(sessionId: string): string;
@@ -1,7 +1,30 @@
1
1
  import type { FrameNode, ProcessContext } from '../types/index.js';
2
+ export interface FFprobeMetadata {
3
+ format?: {
4
+ format_name?: string;
5
+ duration?: string;
6
+ };
7
+ streams?: Array<{
8
+ codec_type?: string;
9
+ }>;
10
+ }
2
11
  /**
3
12
  * Extract frames from video/GIF using FFmpeg.
4
13
  * Always uses FPS-based extraction. For long videos, FPS is automatically
5
14
  * reduced to stay within maxFrames budget.
6
15
  */
7
16
  export declare function extractFrames(ctx: ProcessContext): Promise<FrameNode[]>;
17
+ export declare function getVideoMetadata(inputPath: string): Promise<FFprobeMetadata>;
18
+ /**
19
+ * Extract frames from a specific time range of a video using FFmpeg.
20
+ * Uses input seeking (-ss before -i) for fast seek + -t for duration.
21
+ *
22
+ * @param inputPath - Path to the video file
23
+ * @param outputDir - Directory to write extracted frames
24
+ * @param fps - Frames per second for extraction
25
+ * @param scale - Height scale for vision analysis
26
+ * @param startTime - Start time in seconds
27
+ * @param duration - Duration in seconds to extract
28
+ * @returns Array of FrameNode with segment-local timestamps (starting from 0)
29
+ */
30
+ export declare function extractFramesForRange(inputPath: string, outputDir: string, fps: number, scale: number, startTime: number, duration: number): Promise<FrameNode[]>;
@@ -5,4 +5,5 @@ export { pruneTo, pruneByThreshold, pruneByThresholdWithCap, suppressConsecutive
5
5
  export { dbscan } from './dbscan.js';
6
6
  export type { Point2D } from './dbscan.js';
7
7
  export { resolveInput, resolveOptions } from './input-resolver.js';
8
- export { createWorkspace, cleanupWorkspace, finalizeOutput, readFramesAsBuffers, writeInputBuffer, writeInputFrames, } from './workspace.js';
8
+ export { shouldSegment, computeSegmentPlan, processSegment, mergeSegmentFrames, runSegmentedPipeline, } from './segmenter.js';
9
+ export { createWorkspace, createSegmentWorkspace, cleanupWorkspace, finalizeOutput, readFramesAsBuffers, writeInputBuffer, writeInputFrames, } from './workspace.js';