@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.
@@ -0,0 +1,38 @@
1
+ import type { AnimationMetadata, FrameNode, ResolvedOptions, ScoreEdge, SegmentPlan, SegmentResult, SieveOptions, SieveResult } from '../types/index.js';
2
+ /**
3
+ * Determine whether segmentation should be used.
4
+ * Returns false for frames mode and GIF files.
5
+ * Actual duration check happens inside runSegmentedPipeline after metadata fetch.
6
+ */
7
+ export declare function shouldSegment(resolvedOptions: ResolvedOptions, originalOptions: SieveOptions): boolean;
8
+ /**
9
+ * Compute segment boundaries with overlap, frame allocation, and effectiveFps.
10
+ * Pure function — no I/O.
11
+ *
12
+ * - effectiveFps is uniform across all segments
13
+ * - Overlap: 1 frame at each internal boundary
14
+ * - allocatedFrames total <= maxFrames (last segment adjusted if needed)
15
+ */
16
+ export declare function computeSegmentPlan(totalDuration: number, maxSegmentDuration: number, maxFrames: number, fps: number): SegmentPlan[];
17
+ /**
18
+ * Merge multiple segment results into a single unified frame/edge/animation set.
19
+ * - Timestamps adjusted using extractStartTime (Section 18 note 1)
20
+ * - Overlap frames deduplicated by threshold 1/(effectiveFps*2) (Section 18 note 5)
21
+ * - Global IDs reassigned after dedup
22
+ * - Duplicate edges keep higher score
23
+ */
24
+ export declare function mergeSegmentFrames(segmentResults: SegmentResult[]): {
25
+ frames: FrameNode[];
26
+ edges: ScoreEdge[];
27
+ animations: AnimationMetadata[];
28
+ };
29
+ /**
30
+ * Extract frames for a single segment and analyze them.
31
+ * Each segment uses an isolated workspace directory.
32
+ */
33
+ export declare function processSegment(inputPath: string, segment: SegmentPlan, workspacePath: string, resolvedOptions: ResolvedOptions, onProgress: (percent: number) => void): Promise<SegmentResult>;
34
+ /**
35
+ * Full segmented pipeline: metadata → plan → parallel extract+analyze → merge → prune → finalize.
36
+ * Called from runPipeline when shouldSegment() returns true.
37
+ */
38
+ export declare function runSegmentedPipeline(options: SieveOptions, resolvedOptions: ResolvedOptions): Promise<SieveResult>;
@@ -1,6 +1,7 @@
1
1
  import type { FrameNode, ProcessContext } from '../types/index.js';
2
2
  export declare function createWorkspace(sessionId: string): Promise<string>;
3
3
  export declare function finalizeOutput(ctx: ProcessContext, selectedFrames: FrameNode[]): Promise<string[]>;
4
+ export declare function createSegmentWorkspace(parentWorkspacePath: string, segmentIndex: number): Promise<string>;
4
5
  export declare function cleanupWorkspace(workspacePath: string): Promise<void>;
5
6
  /**
6
7
  * Remove stale workspace directories left by previous interrupted runs.
package/dist/index.cjs CHANGED
@@ -39,7 +39,7 @@ var getImportMetaUrl = () => typeof document === "undefined" ? new URL(`file:${_
39
39
  var importMetaUrl = /* @__PURE__ */ getImportMetaUrl();
40
40
 
41
41
  // src/core/orchestrator.ts
42
- var import_node_crypto = require("crypto");
42
+ var import_node_crypto2 = require("crypto");
43
43
 
44
44
  // src/utils/logger.ts
45
45
  var import_picocolors = __toESM(require("picocolors"), 1);
@@ -104,6 +104,8 @@ var PIXELDIFF_GAUSSIAN_KERNEL = 3;
104
104
  var PIXELDIFF_BINARY_THRESHOLD = 30;
105
105
  var PIXELDIFF_CONTOUR_MIN_AREA = 100;
106
106
  var PIXELDIFF_SAMPLE_SPACING = 8;
107
+ var DEFAULT_MAX_SEGMENT_DURATION = 300;
108
+ var DEFAULT_SEGMENT_CONCURRENCY = 2;
107
109
  function getTempWorkspaceDir(sessionId) {
108
110
  return (0, import_node_path.join)(TEMP_BASE_DIR, `${WORKSPACE_PREFIX}${sessionId}`);
109
111
  }
@@ -696,6 +698,23 @@ async function buildFrameList(framesDir, duration) {
696
698
  extractPath: (0, import_node_path3.join)(framesDir, file)
697
699
  }));
698
700
  }
701
+ async function extractFramesForRange(inputPath, outputDir, fps, scale, startTime, duration) {
702
+ const outputPattern = (0, import_node_path3.join)(outputDir, FRAME_FILENAME_PATTERN);
703
+ await (0, import_execa.execa)(import_ffmpeg_static.default, [
704
+ "-ss",
705
+ String(startTime),
706
+ "-i",
707
+ inputPath,
708
+ "-t",
709
+ String(duration),
710
+ "-vf",
711
+ `fps=${fps},scale=-1:${scale}`,
712
+ "-q:v",
713
+ "2",
714
+ outputPattern
715
+ ]);
716
+ return buildFrameList(outputDir, duration);
717
+ }
699
718
 
700
719
  // src/core/input-resolver.ts
701
720
  var import_node_path5 = require("path");
@@ -758,6 +777,15 @@ async function finalizeOutput(ctx, selectedFrames) {
758
777
  await (0, import_promises3.rename)(stagingDir, outputPath);
759
778
  return outputFiles;
760
779
  }
780
+ async function createSegmentWorkspace(parentWorkspacePath, segmentIndex) {
781
+ const segmentPath = (0, import_node_path4.join)(
782
+ parentWorkspacePath,
783
+ "segments",
784
+ String(segmentIndex)
785
+ );
786
+ await ensureDir((0, import_node_path4.join)(segmentPath, "frames"));
787
+ return segmentPath;
788
+ }
761
789
  async function cleanupWorkspace(workspacePath) {
762
790
  if (!workspacePath) return;
763
791
  try {
@@ -818,7 +846,9 @@ function resolveOptions(options) {
818
846
  quality: options.quality ?? DEFAULT_QUALITY,
819
847
  iouThreshold: options.iouThreshold ?? IOU_THRESHOLD,
820
848
  animationThreshold: options.animationThreshold ?? ANIMATION_FRAME_THRESHOLD,
821
- debug: options.debug ?? false
849
+ debug: options.debug ?? false,
850
+ maxSegmentDuration: options.maxSegmentDuration ?? DEFAULT_MAX_SEGMENT_DURATION,
851
+ concurrency: options.concurrency ?? DEFAULT_SEGMENT_CONCURRENCY
822
852
  };
823
853
  }
824
854
  async function resolveInput(options, workspacePath) {
@@ -1070,13 +1100,361 @@ function pruneByThresholdWithCap(graph, frames, threshold, maxCount) {
1070
1100
  return pruneTo(syntheticEdges, survivingFrames, maxCount);
1071
1101
  }
1072
1102
 
1103
+ // src/core/segmenter.ts
1104
+ var import_node_crypto = require("crypto");
1105
+ var import_node_path6 = require("path");
1106
+
1107
+ // src/utils/concurrency.ts
1108
+ function concurrencyLimit(limit) {
1109
+ limit = Math.max(1, limit);
1110
+ let active = 0;
1111
+ const queue = [];
1112
+ return async (fn) => {
1113
+ while (active >= limit) {
1114
+ await new Promise((resolve2) => queue.push(resolve2));
1115
+ }
1116
+ active++;
1117
+ try {
1118
+ return await fn();
1119
+ } finally {
1120
+ active--;
1121
+ queue.shift()?.();
1122
+ }
1123
+ };
1124
+ }
1125
+
1126
+ // src/core/segmenter.ts
1127
+ function shouldSegment(resolvedOptions, originalOptions) {
1128
+ if (resolvedOptions.mode === "frames") return false;
1129
+ if (originalOptions.mode === "file") {
1130
+ if (originalOptions.inputPath.toLowerCase().endsWith(".gif")) return false;
1131
+ }
1132
+ return true;
1133
+ }
1134
+ function computeSegmentPlan(totalDuration, maxSegmentDuration, maxFrames, fps) {
1135
+ const effectiveFps = Math.max(0.5, Math.min(fps, maxFrames / totalDuration));
1136
+ if (totalDuration <= maxSegmentDuration) {
1137
+ return [
1138
+ {
1139
+ index: 0,
1140
+ startTime: 0,
1141
+ endTime: totalDuration,
1142
+ duration: totalDuration,
1143
+ allocatedFrames: Math.min(
1144
+ Math.ceil(effectiveFps * totalDuration),
1145
+ maxFrames
1146
+ ),
1147
+ effectiveFps,
1148
+ overlapBefore: 0,
1149
+ overlapAfter: 0,
1150
+ extractStartTime: 0,
1151
+ extractDuration: totalDuration
1152
+ }
1153
+ ];
1154
+ }
1155
+ const segmentCount = Math.ceil(totalDuration / maxSegmentDuration);
1156
+ const overlapTime = 1 / effectiveFps;
1157
+ const segments = [];
1158
+ for (let i = 0; i < segmentCount; i++) {
1159
+ const startTime = i * maxSegmentDuration;
1160
+ const endTime = Math.min((i + 1) * maxSegmentDuration, totalDuration);
1161
+ const duration = endTime - startTime;
1162
+ const overlapBefore = i > 0 ? 1 : 0;
1163
+ const overlapAfter = i < segmentCount - 1 ? 1 : 0;
1164
+ const extractStartTime = Math.max(
1165
+ 0,
1166
+ startTime - overlapBefore * overlapTime
1167
+ );
1168
+ const extractEndTime = Math.min(
1169
+ totalDuration,
1170
+ endTime + overlapAfter * overlapTime
1171
+ );
1172
+ const extractDuration = extractEndTime - extractStartTime;
1173
+ segments.push({
1174
+ index: i,
1175
+ startTime,
1176
+ endTime,
1177
+ duration,
1178
+ allocatedFrames: Math.ceil(effectiveFps * duration),
1179
+ effectiveFps,
1180
+ overlapBefore,
1181
+ overlapAfter,
1182
+ extractStartTime,
1183
+ extractDuration
1184
+ });
1185
+ }
1186
+ const totalAllocated = segments.reduce(
1187
+ (sum, s) => sum + s.allocatedFrames,
1188
+ 0
1189
+ );
1190
+ if (totalAllocated > maxFrames) {
1191
+ segments[segments.length - 1].allocatedFrames -= totalAllocated - maxFrames;
1192
+ }
1193
+ return segments;
1194
+ }
1195
+ function mergeSegmentFrames(segmentResults) {
1196
+ if (segmentResults.length === 0) {
1197
+ return { frames: [], edges: [], animations: [] };
1198
+ }
1199
+ const allFrames = [];
1200
+ for (const result of segmentResults) {
1201
+ for (const frame of result.frames) {
1202
+ allFrames.push({
1203
+ frame: {
1204
+ ...frame,
1205
+ // Use extractStartTime for timestamp correction (Section 18 note 1)
1206
+ timestamp: frame.timestamp + result.segment.extractStartTime
1207
+ },
1208
+ segmentIndex: result.segment.index,
1209
+ localId: frame.id
1210
+ });
1211
+ }
1212
+ }
1213
+ allFrames.sort((a, b) => a.frame.timestamp - b.frame.timestamp);
1214
+ const effectiveFps = segmentResults[0].segment.effectiveFps;
1215
+ const dupThreshold = 1 / (effectiveFps * 2);
1216
+ const uniqueFrames = [];
1217
+ for (const entry of allFrames) {
1218
+ if (uniqueFrames.length > 0) {
1219
+ const last = uniqueFrames[uniqueFrames.length - 1];
1220
+ if (Math.abs(entry.frame.timestamp - last.frame.timestamp) < dupThreshold) {
1221
+ continue;
1222
+ }
1223
+ }
1224
+ uniqueFrames.push(entry);
1225
+ }
1226
+ const globalIdMap = /* @__PURE__ */ new Map();
1227
+ const frames = uniqueFrames.map((entry, globalId) => {
1228
+ globalIdMap.set(`${entry.segmentIndex}:${entry.localId}`, globalId);
1229
+ return {
1230
+ id: globalId,
1231
+ timestamp: entry.frame.timestamp,
1232
+ extractPath: entry.frame.extractPath
1233
+ };
1234
+ });
1235
+ const edges = [];
1236
+ const edgeMap = /* @__PURE__ */ new Map();
1237
+ for (const result of segmentResults) {
1238
+ for (const edge of result.edges) {
1239
+ const newSourceId = globalIdMap.get(
1240
+ `${result.segment.index}:${edge.sourceId}`
1241
+ );
1242
+ const newTargetId = globalIdMap.get(
1243
+ `${result.segment.index}:${edge.targetId}`
1244
+ );
1245
+ if (newSourceId === void 0 || newTargetId === void 0) continue;
1246
+ const edgeKey = `${newSourceId}-${newTargetId}`;
1247
+ const existingIdx = edgeMap.get(edgeKey);
1248
+ if (existingIdx !== void 0) {
1249
+ if (edges[existingIdx].score < edge.score) {
1250
+ edges[existingIdx] = {
1251
+ sourceId: newSourceId,
1252
+ targetId: newTargetId,
1253
+ score: edge.score
1254
+ };
1255
+ }
1256
+ } else {
1257
+ edgeMap.set(edgeKey, edges.length);
1258
+ edges.push({
1259
+ sourceId: newSourceId,
1260
+ targetId: newTargetId,
1261
+ score: edge.score
1262
+ });
1263
+ }
1264
+ }
1265
+ }
1266
+ const animations = [];
1267
+ for (const result of segmentResults) {
1268
+ for (const anim of result.animations) {
1269
+ const newStartId = globalIdMap.get(
1270
+ `${result.segment.index}:${anim.startFrameId}`
1271
+ );
1272
+ const newEndId = globalIdMap.get(
1273
+ `${result.segment.index}:${anim.endFrameId}`
1274
+ );
1275
+ if (newStartId === void 0 || newEndId === void 0) continue;
1276
+ animations.push({
1277
+ ...anim,
1278
+ startFrameId: newStartId,
1279
+ endFrameId: newEndId
1280
+ });
1281
+ }
1282
+ }
1283
+ return { frames, edges, animations };
1284
+ }
1285
+ function buildSegmentContext(segment, frames, segmentWorkspacePath, resolvedOptions, onProgress) {
1286
+ return {
1287
+ options: {
1288
+ ...resolvedOptions,
1289
+ fps: segment.effectiveFps,
1290
+ maxFrames: segment.allocatedFrames
1291
+ },
1292
+ workspacePath: segmentWorkspacePath,
1293
+ frames,
1294
+ graph: [],
1295
+ status: "ANALYZING",
1296
+ emitProgress: onProgress
1297
+ };
1298
+ }
1299
+ async function processSegment(inputPath, segment, workspacePath, resolvedOptions, onProgress) {
1300
+ const framesDir = (0, import_node_path6.join)(workspacePath, "frames");
1301
+ const frames = await extractFramesForRange(
1302
+ inputPath,
1303
+ framesDir,
1304
+ segment.effectiveFps,
1305
+ resolvedOptions.scale,
1306
+ segment.extractStartTime,
1307
+ segment.extractDuration
1308
+ );
1309
+ if (frames.length < 2) {
1310
+ return { segment, frames, edges: [], animations: [] };
1311
+ }
1312
+ const ctx = buildSegmentContext(
1313
+ segment,
1314
+ frames,
1315
+ workspacePath,
1316
+ resolvedOptions,
1317
+ onProgress
1318
+ );
1319
+ const { edges, animations } = await analyzeFrames(ctx);
1320
+ return { segment, frames, edges, animations };
1321
+ }
1322
+ async function runSegmentedPipeline(options, resolvedOptions) {
1323
+ const pipelineStart = Date.now();
1324
+ const sessionId = (0, import_node_crypto.randomUUID)();
1325
+ let mainWorkspace = "";
1326
+ try {
1327
+ mainWorkspace = await createWorkspace(sessionId);
1328
+ logger.debug(`Segmented pipeline: workspace at ${mainWorkspace}`);
1329
+ const { resolvedInputPath } = await resolveInput(options, mainWorkspace);
1330
+ const inputPath = resolvedInputPath ?? resolvedOptions.inputPath;
1331
+ if (!inputPath) {
1332
+ throw new Error("No input path available for segmented pipeline");
1333
+ }
1334
+ const metadata = await getVideoMetadata(inputPath);
1335
+ const totalDuration = parseFloat(metadata.format?.duration ?? "0");
1336
+ if (totalDuration <= 0) {
1337
+ throw new Error(`Invalid video duration: ${totalDuration}`);
1338
+ }
1339
+ logger.debug(`Video duration: ${totalDuration}s`);
1340
+ const segments = computeSegmentPlan(
1341
+ totalDuration,
1342
+ resolvedOptions.maxSegmentDuration,
1343
+ resolvedOptions.maxFrames,
1344
+ resolvedOptions.fps
1345
+ );
1346
+ logger.debug(`Segment plan: ${segments.length} segments`);
1347
+ const limit = concurrencyLimit(resolvedOptions.concurrency);
1348
+ const segmentProgresses = new Array(segments.length).fill(0);
1349
+ const weights = segments.map((s) => s.duration / totalDuration);
1350
+ const emitOverallProgress = (phase) => {
1351
+ if (!options.onProgress) return;
1352
+ const overall = weights.reduce(
1353
+ (sum, w, i) => sum + w * (segmentProgresses[i] ?? 0),
1354
+ 0
1355
+ );
1356
+ options.onProgress(phase, Math.min(100, overall));
1357
+ };
1358
+ options.onProgress?.("EXTRACTING", 0);
1359
+ const results = await Promise.all(
1360
+ segments.map(
1361
+ (segment) => limit(async () => {
1362
+ const segWorkspace = await createSegmentWorkspace(
1363
+ mainWorkspace,
1364
+ segment.index
1365
+ );
1366
+ const result = await processSegment(
1367
+ inputPath,
1368
+ segment,
1369
+ segWorkspace,
1370
+ resolvedOptions,
1371
+ (percent) => {
1372
+ segmentProgresses[segment.index] = percent;
1373
+ emitOverallProgress("ANALYZING");
1374
+ }
1375
+ );
1376
+ return result;
1377
+ })
1378
+ )
1379
+ );
1380
+ options.onProgress?.("ANALYZING", 100);
1381
+ const { frames, edges, animations } = mergeSegmentFrames(results);
1382
+ logger.debug(
1383
+ `Merged: ${frames.length} frames, ${edges.length} edges, ${animations.length} animations`
1384
+ );
1385
+ options.onProgress?.("PRUNING", 0);
1386
+ const survivingIds = pruneByThresholdWithCap(
1387
+ edges,
1388
+ frames,
1389
+ resolvedOptions.threshold,
1390
+ resolvedOptions.count
1391
+ );
1392
+ const prunedFrames = frames.filter((f) => survivingIds.has(f.id));
1393
+ options.onProgress?.("PRUNING", 100);
1394
+ options.onProgress?.("FINALIZING", 0);
1395
+ const ctx = {
1396
+ options: resolvedOptions,
1397
+ workspacePath: mainWorkspace,
1398
+ frames,
1399
+ graph: edges,
1400
+ animations,
1401
+ status: "FINALIZING",
1402
+ emitProgress: (percent) => options.onProgress?.("FINALIZING", percent)
1403
+ };
1404
+ let outputFiles = [];
1405
+ let outputBuffers;
1406
+ if (resolvedOptions.mode === "buffer" || resolvedOptions.mode === "frames") {
1407
+ outputBuffers = await readFramesAsBuffers(
1408
+ prunedFrames,
1409
+ resolvedOptions.quality
1410
+ );
1411
+ } else {
1412
+ outputFiles = await finalizeOutput(ctx, prunedFrames);
1413
+ }
1414
+ options.onProgress?.("FINALIZING", 100);
1415
+ logger.success(
1416
+ `Segmented pipeline: ${prunedFrames.length} scenes from ${frames.length} frames (${segments.length} segments)`
1417
+ );
1418
+ return {
1419
+ success: true,
1420
+ originalFramesCount: frames.length,
1421
+ prunedFramesCount: prunedFrames.length,
1422
+ outputFiles,
1423
+ outputBuffers,
1424
+ animations,
1425
+ video: {
1426
+ originalDurationMs: totalDuration * 1e3,
1427
+ fps: resolvedOptions.fps,
1428
+ resolution: {
1429
+ width: resolvedOptions.scale,
1430
+ height: Math.round(resolvedOptions.scale * 9 / 16)
1431
+ }
1432
+ },
1433
+ executionTimeMs: Date.now() - pipelineStart
1434
+ };
1435
+ } catch (error) {
1436
+ const err = error instanceof Error ? error : new Error(String(error));
1437
+ logger.error(`Segmented pipeline failed: ${err.message}`);
1438
+ throw err;
1439
+ } finally {
1440
+ if (!resolvedOptions.debug) {
1441
+ await cleanupWorkspace(mainWorkspace);
1442
+ } else {
1443
+ logger.debug(`Debug mode: workspace preserved at ${mainWorkspace}`);
1444
+ }
1445
+ }
1446
+ }
1447
+
1073
1448
  // src/core/orchestrator.ts
1074
1449
  async function runPipeline(options) {
1075
- const startTime = Date.now();
1076
- const sessionId = (0, import_node_crypto.randomUUID)();
1077
1450
  const debug = options.debug ?? false;
1078
1451
  if (debug) setDebugMode(true);
1079
1452
  const resolvedOptions = resolveOptions(options);
1453
+ if (shouldSegment(resolvedOptions, options)) {
1454
+ return runSegmentedPipeline(options, resolvedOptions);
1455
+ }
1456
+ const startTime = Date.now();
1457
+ const sessionId = (0, import_node_crypto2.randomUUID)();
1080
1458
  const ctx = {
1081
1459
  options: resolvedOptions,
1082
1460
  workspacePath: "",