@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/dist/constants.d.ts
CHANGED
|
@@ -5,7 +5,12 @@ export declare const DEFAULT_FPS = 5;
|
|
|
5
5
|
export declare const DEFAULT_SCALE = 720;
|
|
6
6
|
export declare const DEFAULT_QUALITY = 80;
|
|
7
7
|
export declare const DEFAULT_MAX_FRAMES = 300;
|
|
8
|
-
export declare const
|
|
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;
|
|
9
14
|
export declare const WORKSPACE_PREFIX = "scene-sieve-";
|
|
10
15
|
export declare const TEMP_BASE_DIR: string;
|
|
11
16
|
export declare const FRAME_OUTPUT_EXTENSION = ".jpg";
|
|
@@ -22,4 +27,6 @@ export declare const PIXELDIFF_GAUSSIAN_KERNEL = 3;
|
|
|
22
27
|
export declare const PIXELDIFF_BINARY_THRESHOLD = 30;
|
|
23
28
|
export declare const PIXELDIFF_CONTOUR_MIN_AREA = 100;
|
|
24
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;
|
|
25
32
|
export declare function getTempWorkspaceDir(sessionId: string): string;
|
package/dist/core/extractor.d.ts
CHANGED
|
@@ -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[]>;
|
package/dist/core/index.d.ts
CHANGED
|
@@ -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 {
|
|
8
|
+
export { shouldSegment, computeSegmentPlan, processSegment, mergeSegmentFrames, runSegmentedPipeline, } from './segmenter.js';
|
|
9
|
+
export { createWorkspace, createSegmentWorkspace, cleanupWorkspace, finalizeOutput, readFramesAsBuffers, writeInputBuffer, writeInputFrames, } from './workspace.js';
|
|
@@ -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>;
|
package/dist/core/workspace.d.ts
CHANGED
|
@@ -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
|
|
42
|
+
var import_node_crypto2 = require("crypto");
|
|
43
43
|
|
|
44
44
|
// src/utils/logger.ts
|
|
45
45
|
var import_picocolors = __toESM(require("picocolors"), 1);
|
|
@@ -85,7 +85,10 @@ var DEFAULT_FPS = 5;
|
|
|
85
85
|
var DEFAULT_SCALE = 720;
|
|
86
86
|
var DEFAULT_QUALITY = 80;
|
|
87
87
|
var DEFAULT_MAX_FRAMES = 300;
|
|
88
|
-
var
|
|
88
|
+
var NORMALIZATION_LOGISTIC_K = 3;
|
|
89
|
+
var NORMALIZATION_ALPHA = 0.4;
|
|
90
|
+
var NORMALIZATION_MAD_COEFFICIENT = 1.4826;
|
|
91
|
+
var NORMALIZATION_MIN_SAMPLE_SIZE = 10;
|
|
89
92
|
var WORKSPACE_PREFIX = `${APP_NAME}-`;
|
|
90
93
|
var TEMP_BASE_DIR = (0, import_node_os.tmpdir)();
|
|
91
94
|
var FRAME_OUTPUT_EXTENSION = ".jpg";
|
|
@@ -101,6 +104,8 @@ var PIXELDIFF_GAUSSIAN_KERNEL = 3;
|
|
|
101
104
|
var PIXELDIFF_BINARY_THRESHOLD = 30;
|
|
102
105
|
var PIXELDIFF_CONTOUR_MIN_AREA = 100;
|
|
103
106
|
var PIXELDIFF_SAMPLE_SPACING = 8;
|
|
107
|
+
var DEFAULT_MAX_SEGMENT_DURATION = 300;
|
|
108
|
+
var DEFAULT_SEGMENT_CONCURRENCY = 2;
|
|
104
109
|
function getTempWorkspaceDir(sessionId) {
|
|
105
110
|
return (0, import_node_path.join)(TEMP_BASE_DIR, `${WORKSPACE_PREFIX}${sessionId}`);
|
|
106
111
|
}
|
|
@@ -693,6 +698,23 @@ async function buildFrameList(framesDir, duration) {
|
|
|
693
698
|
extractPath: (0, import_node_path3.join)(framesDir, file)
|
|
694
699
|
}));
|
|
695
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
|
+
}
|
|
696
718
|
|
|
697
719
|
// src/core/input-resolver.ts
|
|
698
720
|
var import_node_path5 = require("path");
|
|
@@ -755,6 +777,15 @@ async function finalizeOutput(ctx, selectedFrames) {
|
|
|
755
777
|
await (0, import_promises3.rename)(stagingDir, outputPath);
|
|
756
778
|
return outputFiles;
|
|
757
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
|
+
}
|
|
758
789
|
async function cleanupWorkspace(workspacePath) {
|
|
759
790
|
if (!workspacePath) return;
|
|
760
791
|
try {
|
|
@@ -815,7 +846,9 @@ function resolveOptions(options) {
|
|
|
815
846
|
quality: options.quality ?? DEFAULT_QUALITY,
|
|
816
847
|
iouThreshold: options.iouThreshold ?? IOU_THRESHOLD,
|
|
817
848
|
animationThreshold: options.animationThreshold ?? ANIMATION_FRAME_THRESHOLD,
|
|
818
|
-
debug: options.debug ?? false
|
|
849
|
+
debug: options.debug ?? false,
|
|
850
|
+
maxSegmentDuration: options.maxSegmentDuration ?? DEFAULT_MAX_SEGMENT_DURATION,
|
|
851
|
+
concurrency: options.concurrency ?? DEFAULT_SEGMENT_CONCURRENCY
|
|
819
852
|
};
|
|
820
853
|
}
|
|
821
854
|
async function resolveInput(options, workspacePath) {
|
|
@@ -839,6 +872,43 @@ async function resolveInput(options, workspacePath) {
|
|
|
839
872
|
throw new Error(`Unsupported input mode: ${options.mode}`);
|
|
840
873
|
}
|
|
841
874
|
|
|
875
|
+
// src/utils/math.ts
|
|
876
|
+
function normalizeScores(items) {
|
|
877
|
+
if (items.length === 0) return [];
|
|
878
|
+
const safeScores = items.map(
|
|
879
|
+
(e) => Number.isFinite(e.score) && e.score > 0 ? e.score : 0
|
|
880
|
+
);
|
|
881
|
+
const positiveScores = safeScores.filter((s) => s > 0);
|
|
882
|
+
if (positiveScores.length === 0) return safeScores;
|
|
883
|
+
const sorted = [...positiveScores].sort((a, b) => a - b);
|
|
884
|
+
if (positiveScores.length <= NORMALIZATION_MIN_SAMPLE_SIZE) {
|
|
885
|
+
const min = sorted[0];
|
|
886
|
+
const max = sorted[sorted.length - 1];
|
|
887
|
+
if (max === min) return safeScores.map((s) => s > 0 ? 1 : 0);
|
|
888
|
+
return safeScores.map(
|
|
889
|
+
(s) => s <= 0 ? 0 : Math.max(0, Math.min((s - min) / (max - min), 1))
|
|
890
|
+
);
|
|
891
|
+
}
|
|
892
|
+
const median = sorted[Math.floor(sorted.length / 2)];
|
|
893
|
+
const absoluteDiffs = positiveScores.map((v) => Math.abs(v - median));
|
|
894
|
+
const mad = [...absoluteDiffs].sort((a, b) => a - b)[Math.floor(absoluteDiffs.length / 2)];
|
|
895
|
+
const scale = mad === 0 ? median : mad * NORMALIZATION_MAD_COEFFICIENT;
|
|
896
|
+
const logisticZ = safeScores.map((s) => {
|
|
897
|
+
if (s <= 0) return 0;
|
|
898
|
+
if (scale === 0) return 1;
|
|
899
|
+
const z = (s - median) / scale;
|
|
900
|
+
return 1 / (1 + Math.exp(-NORMALIZATION_LOGISTIC_K * z));
|
|
901
|
+
});
|
|
902
|
+
const cdf = safeScores.map((s) => {
|
|
903
|
+
if (s <= 0) return 0;
|
|
904
|
+
const rank = sorted.findIndex((v) => v >= s);
|
|
905
|
+
return rank / sorted.length;
|
|
906
|
+
});
|
|
907
|
+
return logisticZ.map(
|
|
908
|
+
(z, i) => z * (1 - NORMALIZATION_ALPHA) + cdf[i] * NORMALIZATION_ALPHA
|
|
909
|
+
);
|
|
910
|
+
}
|
|
911
|
+
|
|
842
912
|
// src/utils/min-heap.ts
|
|
843
913
|
var MinHeap = class {
|
|
844
914
|
h = [];
|
|
@@ -933,20 +1003,6 @@ function pruneTo(graph, frames, targetCount) {
|
|
|
933
1003
|
}
|
|
934
1004
|
return surviving;
|
|
935
1005
|
}
|
|
936
|
-
function normalizeScores(graph) {
|
|
937
|
-
if (graph.length === 0) return [];
|
|
938
|
-
const safeScores = graph.map(
|
|
939
|
-
(e) => Number.isFinite(e.score) && e.score > 0 ? e.score : 0
|
|
940
|
-
);
|
|
941
|
-
const sorted = [...safeScores].filter((s) => s > 0).sort((a, b) => a - b);
|
|
942
|
-
if (sorted.length === 0) return safeScores;
|
|
943
|
-
const pIdx = Math.min(
|
|
944
|
-
Math.floor(sorted.length * NORMALIZATION_PERCENTILE),
|
|
945
|
-
sorted.length - 1
|
|
946
|
-
);
|
|
947
|
-
const refScore = sorted[pIdx];
|
|
948
|
-
return safeScores.map((s) => Math.min(s / refScore, 1));
|
|
949
|
-
}
|
|
950
1006
|
function suppressConsecutiveRuns(graph, passingIndices, normalizedScores) {
|
|
951
1007
|
const result = /* @__PURE__ */ new Set();
|
|
952
1008
|
let runStart = 0;
|
|
@@ -1044,13 +1100,361 @@ function pruneByThresholdWithCap(graph, frames, threshold, maxCount) {
|
|
|
1044
1100
|
return pruneTo(syntheticEdges, survivingFrames, maxCount);
|
|
1045
1101
|
}
|
|
1046
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
|
+
|
|
1047
1448
|
// src/core/orchestrator.ts
|
|
1048
1449
|
async function runPipeline(options) {
|
|
1049
|
-
const startTime = Date.now();
|
|
1050
|
-
const sessionId = (0, import_node_crypto.randomUUID)();
|
|
1051
1450
|
const debug = options.debug ?? false;
|
|
1052
1451
|
if (debug) setDebugMode(true);
|
|
1053
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)();
|
|
1054
1458
|
const ctx = {
|
|
1055
1459
|
options: resolvedOptions,
|
|
1056
1460
|
workspacePath: "",
|