aivee 0.1.0-alpha.0 → 0.1.0-dev.59872e1
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/CHANGELOG.md +34 -0
- package/LICENSE +21 -21
- package/README.md +120 -118
- package/docs/architecture.md +401 -401
- package/docs/aspect-ratio-presets.md +72 -72
- package/docs/releasing.md +19 -19
- package/docs/usage.md +1193 -1193
- package/package.json +52 -52
- package/requirements-otio.txt +1 -1
- package/src/capabilities.mjs +307 -307
- package/src/cli.mjs +646 -646
- package/src/doctor.mjs +133 -133
- package/src/edits/analysis-to-edl.mjs +43 -43
- package/src/edits/detect-silence.mjs +51 -51
- package/src/edits/find-mistake-candidates.mjs +42 -42
- package/src/edits/plan-shot-placements.mjs +105 -105
- package/src/edits/plan-silence-cuts.mjs +49 -49
- package/src/edits/retime-transcript.mjs +105 -105
- package/src/files.mjs +80 -80
- package/src/index.mjs +44 -44
- package/src/interchange/otio.mjs +204 -204
- package/src/interchange/otio_bridge.py +521 -521
- package/src/media/analysis-provider.mjs +75 -75
- package/src/media/compatibility.mjs +45 -45
- package/src/media/ffmpeg-capabilities.mjs +129 -129
- package/src/media/frame-rate.mjs +58 -58
- package/src/media/frame-time.mjs +22 -22
- package/src/media/preview-assets.mjs +350 -350
- package/src/media/probe.mjs +149 -149
- package/src/media/render-cuts.mjs +90 -90
- package/src/media/scene-detection.mjs +107 -107
- package/src/media/scene-review.mjs +205 -205
- package/src/project/assets.mjs +1194 -1194
- package/src/project/create.mjs +100 -100
- package/src/project/presets.mjs +44 -44
- package/src/render/checkpoints.mjs +192 -192
- package/src/render/formats.mjs +163 -163
- package/src/render/hyperframes.mjs +590 -590
- package/src/render/process.mjs +111 -111
- package/src/render/progress.mjs +46 -46
- package/src/render/timeline-control.mjs +53 -53
- package/src/timeline/assemble.mjs +119 -119
- package/src/timeline/audio-effects.mjs +75 -75
- package/src/timeline/audio-mastering.mjs +236 -236
- package/src/timeline/audio-mix.mjs +105 -105
- package/src/timeline/audio-track-effects.mjs +139 -139
- package/src/timeline/audio-volume.mjs +63 -63
- package/src/timeline/background.mjs +8 -8
- package/src/timeline/blend-mode.mjs +34 -34
- package/src/timeline/caption-layouts.mjs +62 -61
- package/src/timeline/captions.mjs +80 -80
- package/src/timeline/edit.mjs +965 -965
- package/src/timeline/fingerprint.mjs +6 -6
- package/src/timeline/from-edl.mjs +31 -31
- package/src/timeline/history-store.mjs +281 -281
- package/src/timeline/history.mjs +69 -69
- package/src/timeline/inspect.mjs +117 -117
- package/src/timeline/keyframes.mjs +158 -158
- package/src/timeline/layers.mjs +18 -18
- package/src/timeline/mask.mjs +53 -53
- package/src/timeline/project.mjs +52 -52
- package/src/timeline/render.mjs +759 -759
- package/src/timeline/sidechain-render.mjs +209 -209
- package/src/timeline/source-fit.mjs +15 -15
- package/src/timeline/speech-bubble.mjs +26 -26
- package/src/timeline/speed-ramp.mjs +188 -188
- package/src/timeline/track-state.mjs +22 -22
- package/src/timeline/transition.mjs +34 -34
- package/src/timeline/validate.mjs +322 -322
- package/src/transcript/corrections.mjs +21 -21
- package/src/transcript/normalize.mjs +17 -17
- package/src/transcript/provider.mjs +42 -42
- package/src/transcript/subtitles.mjs +57 -57
- package/src/transcript/validate.mjs +39 -39
|
@@ -1,107 +1,107 @@
|
|
|
1
|
-
import { probeMediaAsync } from './probe.mjs';
|
|
2
|
-
import { runProcess } from '../render/process.mjs';
|
|
3
|
-
|
|
4
|
-
const DEFAULT_SCENE_THRESHOLD = 0.35;
|
|
5
|
-
const DEFAULT_MINIMUM_SPACING_SECONDS = 0.5;
|
|
6
|
-
const DEFAULT_MAXIMUM_FINDINGS = 2000;
|
|
7
|
-
|
|
8
|
-
/** Find visually distinct shot boundaries with FFmpeg's scene-change score. */
|
|
9
|
-
export async function detectSceneChangesAsync(inputPath, options = {}) {
|
|
10
|
-
if (!options || typeof options !== 'object' || Array.isArray(options)) {
|
|
11
|
-
throw new TypeError('Scene detection options must be an object.');
|
|
12
|
-
}
|
|
13
|
-
const allowedOptions = new Set(['threshold', 'minimumSpacingSeconds', 'maximumFindings', 'signal']);
|
|
14
|
-
for (const key of Object.keys(options)) {
|
|
15
|
-
if (!allowedOptions.has(key)) throw new TypeError(`Unsupported scene detection option: ${key}.`);
|
|
16
|
-
}
|
|
17
|
-
const media = await probeMediaAsync(inputPath, { signal: options.signal });
|
|
18
|
-
if (!media.videoStreams.length) throw new TypeError('Scene detection requires a video stream.');
|
|
19
|
-
|
|
20
|
-
const threshold = options.threshold ?? DEFAULT_SCENE_THRESHOLD;
|
|
21
|
-
const minimumSpacingSeconds = options.minimumSpacingSeconds ?? DEFAULT_MINIMUM_SPACING_SECONDS;
|
|
22
|
-
const maximumFindings = options.maximumFindings ?? DEFAULT_MAXIMUM_FINDINGS;
|
|
23
|
-
if (!Number.isFinite(threshold) || threshold < 0.05 || threshold > 1) {
|
|
24
|
-
throw new TypeError('Scene threshold must be between 0.05 and 1.');
|
|
25
|
-
}
|
|
26
|
-
if (!Number.isFinite(minimumSpacingSeconds) || minimumSpacingSeconds < 0 || minimumSpacingSeconds > 60) {
|
|
27
|
-
throw new TypeError('minimumSpacingSeconds must be between 0 and 60.');
|
|
28
|
-
}
|
|
29
|
-
if (!Number.isSafeInteger(maximumFindings) || maximumFindings < 1 || maximumFindings > 10000) {
|
|
30
|
-
throw new TypeError('maximumFindings must be an integer between 1 and 10000.');
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
const filter = `select=gt(scene\\,${threshold}),metadata=mode=print:key=lavfi.scene_score`;
|
|
34
|
-
let stderrBytes = 0;
|
|
35
|
-
const result = await runProcess('ffmpeg', [
|
|
36
|
-
'-nostdin', '-hide_banner', '-loglevel', 'info', '-i', media.path,
|
|
37
|
-
'-map', '0:v:0', '-vf', filter, '-an', '-sn', '-dn', '-f', 'null', '-',
|
|
38
|
-
], {
|
|
39
|
-
signal: options.signal,
|
|
40
|
-
stdio: 'capture',
|
|
41
|
-
onStderr: (chunk) => { stderrBytes += chunk.length; },
|
|
42
|
-
});
|
|
43
|
-
if (stderrBytes >= 16 * 1024 * 1024) {
|
|
44
|
-
throw new RangeError('FFmpeg scene analysis exceeded the safe log size. Raise the threshold or analyze a shorter source range.');
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
const parsed = parseSceneDetectionOutput(result.stderr, media.durationSeconds);
|
|
48
|
-
const findings = suppressNearbyCuts(parsed, minimumSpacingSeconds);
|
|
49
|
-
if (findings.length > maximumFindings) {
|
|
50
|
-
throw new RangeError(`Scene detection found more than ${maximumFindings} cuts; increase maximumFindings or raise the threshold.`);
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
return {
|
|
54
|
-
providerId: 'ffmpeg-scene-detection',
|
|
55
|
-
analysis: {
|
|
56
|
-
schemaVersion: 1,
|
|
57
|
-
durationSeconds: media.durationSeconds,
|
|
58
|
-
findings: findings.map((finding, index) => ({
|
|
59
|
-
id: `finding-${String(index + 1).padStart(3, '0')}`,
|
|
60
|
-
type: 'scene-cut',
|
|
61
|
-
startSeconds: finding.startSeconds,
|
|
62
|
-
confidence: finding.confidence,
|
|
63
|
-
description: `FFmpeg scene-change score ${finding.confidence.toFixed(3)}. Review this candidate boundary.`,
|
|
64
|
-
})),
|
|
65
|
-
},
|
|
66
|
-
settings: { threshold, minimumSpacingSeconds },
|
|
67
|
-
reviewRequired: true,
|
|
68
|
-
};
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
export function parseSceneDetectionOutput(log, durationSeconds) {
|
|
72
|
-
if (typeof log !== 'string' || !Number.isFinite(durationSeconds) || durationSeconds <= 0) {
|
|
73
|
-
throw new TypeError('Scene detection output requires a log string and positive duration.');
|
|
74
|
-
}
|
|
75
|
-
const frames = [...log.matchAll(/frame:\s*\d+\s+pts:\s*-?\d+\s+pts_time:\s*(-?(?:\d+(?:\.\d*)?|\.\d+))/g)];
|
|
76
|
-
const findings = [];
|
|
77
|
-
for (let index = 0; index < frames.length; index++) {
|
|
78
|
-
const frame = frames[index];
|
|
79
|
-
const blockStart = frame.index + frame[0].length;
|
|
80
|
-
const blockEnd = index + 1 < frames.length ? frames[index + 1].index : log.length;
|
|
81
|
-
const scoreMatch = /lavfi\.scene_score\s*=\s*((?:\d+(?:\.\d*)?|\.\d+))/.exec(log.slice(blockStart, blockEnd));
|
|
82
|
-
if (!scoreMatch) continue;
|
|
83
|
-
const startSeconds = Number(frame[1]);
|
|
84
|
-
const confidence = Number(scoreMatch[1]);
|
|
85
|
-
if (!Number.isFinite(startSeconds) || startSeconds < 0 || startSeconds >= durationSeconds
|
|
86
|
-
|| !Number.isFinite(confidence) || confidence < 0 || confidence > 1) continue;
|
|
87
|
-
findings.push({ startSeconds: round(startSeconds), confidence: round(confidence) });
|
|
88
|
-
}
|
|
89
|
-
return findings;
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
function suppressNearbyCuts(findings, minimumSpacingSeconds) {
|
|
93
|
-
const kept = [];
|
|
94
|
-
for (const finding of findings) {
|
|
95
|
-
const previous = kept.at(-1);
|
|
96
|
-
if (previous && finding.startSeconds - previous.startSeconds < minimumSpacingSeconds) {
|
|
97
|
-
if (finding.confidence > previous.confidence) kept[kept.length - 1] = finding;
|
|
98
|
-
} else {
|
|
99
|
-
kept.push(finding);
|
|
100
|
-
}
|
|
101
|
-
}
|
|
102
|
-
return kept;
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
function round(value) {
|
|
106
|
-
return Number(value.toFixed(3));
|
|
107
|
-
}
|
|
1
|
+
import { probeMediaAsync } from './probe.mjs';
|
|
2
|
+
import { runProcess } from '../render/process.mjs';
|
|
3
|
+
|
|
4
|
+
const DEFAULT_SCENE_THRESHOLD = 0.35;
|
|
5
|
+
const DEFAULT_MINIMUM_SPACING_SECONDS = 0.5;
|
|
6
|
+
const DEFAULT_MAXIMUM_FINDINGS = 2000;
|
|
7
|
+
|
|
8
|
+
/** Find visually distinct shot boundaries with FFmpeg's scene-change score. */
|
|
9
|
+
export async function detectSceneChangesAsync(inputPath, options = {}) {
|
|
10
|
+
if (!options || typeof options !== 'object' || Array.isArray(options)) {
|
|
11
|
+
throw new TypeError('Scene detection options must be an object.');
|
|
12
|
+
}
|
|
13
|
+
const allowedOptions = new Set(['threshold', 'minimumSpacingSeconds', 'maximumFindings', 'signal']);
|
|
14
|
+
for (const key of Object.keys(options)) {
|
|
15
|
+
if (!allowedOptions.has(key)) throw new TypeError(`Unsupported scene detection option: ${key}.`);
|
|
16
|
+
}
|
|
17
|
+
const media = await probeMediaAsync(inputPath, { signal: options.signal });
|
|
18
|
+
if (!media.videoStreams.length) throw new TypeError('Scene detection requires a video stream.');
|
|
19
|
+
|
|
20
|
+
const threshold = options.threshold ?? DEFAULT_SCENE_THRESHOLD;
|
|
21
|
+
const minimumSpacingSeconds = options.minimumSpacingSeconds ?? DEFAULT_MINIMUM_SPACING_SECONDS;
|
|
22
|
+
const maximumFindings = options.maximumFindings ?? DEFAULT_MAXIMUM_FINDINGS;
|
|
23
|
+
if (!Number.isFinite(threshold) || threshold < 0.05 || threshold > 1) {
|
|
24
|
+
throw new TypeError('Scene threshold must be between 0.05 and 1.');
|
|
25
|
+
}
|
|
26
|
+
if (!Number.isFinite(minimumSpacingSeconds) || minimumSpacingSeconds < 0 || minimumSpacingSeconds > 60) {
|
|
27
|
+
throw new TypeError('minimumSpacingSeconds must be between 0 and 60.');
|
|
28
|
+
}
|
|
29
|
+
if (!Number.isSafeInteger(maximumFindings) || maximumFindings < 1 || maximumFindings > 10000) {
|
|
30
|
+
throw new TypeError('maximumFindings must be an integer between 1 and 10000.');
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const filter = `select=gt(scene\\,${threshold}),metadata=mode=print:key=lavfi.scene_score`;
|
|
34
|
+
let stderrBytes = 0;
|
|
35
|
+
const result = await runProcess('ffmpeg', [
|
|
36
|
+
'-nostdin', '-hide_banner', '-loglevel', 'info', '-i', media.path,
|
|
37
|
+
'-map', '0:v:0', '-vf', filter, '-an', '-sn', '-dn', '-f', 'null', '-',
|
|
38
|
+
], {
|
|
39
|
+
signal: options.signal,
|
|
40
|
+
stdio: 'capture',
|
|
41
|
+
onStderr: (chunk) => { stderrBytes += chunk.length; },
|
|
42
|
+
});
|
|
43
|
+
if (stderrBytes >= 16 * 1024 * 1024) {
|
|
44
|
+
throw new RangeError('FFmpeg scene analysis exceeded the safe log size. Raise the threshold or analyze a shorter source range.');
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const parsed = parseSceneDetectionOutput(result.stderr, media.durationSeconds);
|
|
48
|
+
const findings = suppressNearbyCuts(parsed, minimumSpacingSeconds);
|
|
49
|
+
if (findings.length > maximumFindings) {
|
|
50
|
+
throw new RangeError(`Scene detection found more than ${maximumFindings} cuts; increase maximumFindings or raise the threshold.`);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return {
|
|
54
|
+
providerId: 'ffmpeg-scene-detection',
|
|
55
|
+
analysis: {
|
|
56
|
+
schemaVersion: 1,
|
|
57
|
+
durationSeconds: media.durationSeconds,
|
|
58
|
+
findings: findings.map((finding, index) => ({
|
|
59
|
+
id: `finding-${String(index + 1).padStart(3, '0')}`,
|
|
60
|
+
type: 'scene-cut',
|
|
61
|
+
startSeconds: finding.startSeconds,
|
|
62
|
+
confidence: finding.confidence,
|
|
63
|
+
description: `FFmpeg scene-change score ${finding.confidence.toFixed(3)}. Review this candidate boundary.`,
|
|
64
|
+
})),
|
|
65
|
+
},
|
|
66
|
+
settings: { threshold, minimumSpacingSeconds },
|
|
67
|
+
reviewRequired: true,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function parseSceneDetectionOutput(log, durationSeconds) {
|
|
72
|
+
if (typeof log !== 'string' || !Number.isFinite(durationSeconds) || durationSeconds <= 0) {
|
|
73
|
+
throw new TypeError('Scene detection output requires a log string and positive duration.');
|
|
74
|
+
}
|
|
75
|
+
const frames = [...log.matchAll(/frame:\s*\d+\s+pts:\s*-?\d+\s+pts_time:\s*(-?(?:\d+(?:\.\d*)?|\.\d+))/g)];
|
|
76
|
+
const findings = [];
|
|
77
|
+
for (let index = 0; index < frames.length; index++) {
|
|
78
|
+
const frame = frames[index];
|
|
79
|
+
const blockStart = frame.index + frame[0].length;
|
|
80
|
+
const blockEnd = index + 1 < frames.length ? frames[index + 1].index : log.length;
|
|
81
|
+
const scoreMatch = /lavfi\.scene_score\s*=\s*((?:\d+(?:\.\d*)?|\.\d+))/.exec(log.slice(blockStart, blockEnd));
|
|
82
|
+
if (!scoreMatch) continue;
|
|
83
|
+
const startSeconds = Number(frame[1]);
|
|
84
|
+
const confidence = Number(scoreMatch[1]);
|
|
85
|
+
if (!Number.isFinite(startSeconds) || startSeconds < 0 || startSeconds >= durationSeconds
|
|
86
|
+
|| !Number.isFinite(confidence) || confidence < 0 || confidence > 1) continue;
|
|
87
|
+
findings.push({ startSeconds: round(startSeconds), confidence: round(confidence) });
|
|
88
|
+
}
|
|
89
|
+
return findings;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function suppressNearbyCuts(findings, minimumSpacingSeconds) {
|
|
93
|
+
const kept = [];
|
|
94
|
+
for (const finding of findings) {
|
|
95
|
+
const previous = kept.at(-1);
|
|
96
|
+
if (previous && finding.startSeconds - previous.startSeconds < minimumSpacingSeconds) {
|
|
97
|
+
if (finding.confidence > previous.confidence) kept[kept.length - 1] = finding;
|
|
98
|
+
} else {
|
|
99
|
+
kept.push(finding);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return kept;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function round(value) {
|
|
106
|
+
return Number(value.toFixed(3));
|
|
107
|
+
}
|
|
@@ -1,205 +1,205 @@
|
|
|
1
|
-
import { copyFile, mkdir, mkdtemp, rename, rm, stat, writeFile } from 'node:fs/promises';
|
|
2
|
-
import { createReadStream } from 'node:fs';
|
|
3
|
-
import { createHash, randomUUID } from 'node:crypto';
|
|
4
|
-
import { basename, dirname, join, resolve } from 'node:path';
|
|
5
|
-
import { existsSync } from 'node:fs';
|
|
6
|
-
import { runProcess } from '../render/process.mjs';
|
|
7
|
-
import { detectSceneChangesAsync } from './scene-detection.mjs';
|
|
8
|
-
|
|
9
|
-
const DEFAULT_THUMBNAIL_SIZE = 320;
|
|
10
|
-
const DEFAULT_CONTACT_SHEET_LIMIT = 60;
|
|
11
|
-
const DEFAULT_MAXIMUM_SHOTS = 300;
|
|
12
|
-
const CONTACT_SHEET_PIXEL_LIMIT = 64_000_000;
|
|
13
|
-
|
|
14
|
-
/** Create a review-ready shot map with representative frames and a contact sheet. */
|
|
15
|
-
export async function createSceneReviewPackAsync(inputPath, outputDirectory, options = {}) {
|
|
16
|
-
if (!options || typeof options !== 'object' || Array.isArray(options)) {
|
|
17
|
-
throw new TypeError('Scene review options must be an object.');
|
|
18
|
-
}
|
|
19
|
-
const allowedOptions = new Set([
|
|
20
|
-
'threshold', 'minimumSpacingSeconds', 'maximumFindings', 'maximumShots',
|
|
21
|
-
'thumbnailSize', 'contactSheetLimit', 'columns', 'signal', 'onProgress',
|
|
22
|
-
]);
|
|
23
|
-
for (const key of Object.keys(options)) {
|
|
24
|
-
if (!allowedOptions.has(key)) throw new TypeError(`Unsupported scene review option: ${key}.`);
|
|
25
|
-
}
|
|
26
|
-
if (options.onProgress !== undefined && typeof options.onProgress !== 'function') {
|
|
27
|
-
throw new TypeError('onProgress must be a function.');
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
const output = resolve(outputDirectory);
|
|
31
|
-
if (existsSync(output)) throw new Error(`Scene review output already exists: ${output}`);
|
|
32
|
-
const maximumShots = options.maximumShots ?? DEFAULT_MAXIMUM_SHOTS;
|
|
33
|
-
const thumbnailSize = options.thumbnailSize ?? DEFAULT_THUMBNAIL_SIZE;
|
|
34
|
-
const contactSheetLimit = options.contactSheetLimit ?? DEFAULT_CONTACT_SHEET_LIMIT;
|
|
35
|
-
const columns = options.columns ?? 4;
|
|
36
|
-
validateIntegerRange(maximumShots, 1, 2000, 'maximumShots');
|
|
37
|
-
validateIntegerRange(thumbnailSize, 80, 1280, 'thumbnailSize');
|
|
38
|
-
validateIntegerRange(contactSheetLimit, 1, 120, 'contactSheetLimit');
|
|
39
|
-
validateIntegerRange(columns, 1, 8, 'columns');
|
|
40
|
-
|
|
41
|
-
emitProgress(options.onProgress, { stage: 'scene-source-fingerprint', phase: 'started' });
|
|
42
|
-
const sourceSha256 = await hashFileAsync(resolve(inputPath), options.signal);
|
|
43
|
-
emitProgress(options.onProgress, { stage: 'scene-source-fingerprint', phase: 'completed' });
|
|
44
|
-
emitProgress(options.onProgress, { stage: 'scene-analysis', phase: 'started' });
|
|
45
|
-
const analysis = await detectSceneChangesAsync(inputPath, {
|
|
46
|
-
...(options.threshold !== undefined ? { threshold: options.threshold } : {}),
|
|
47
|
-
...(options.minimumSpacingSeconds !== undefined ? { minimumSpacingSeconds: options.minimumSpacingSeconds } : {}),
|
|
48
|
-
...(options.maximumFindings !== undefined ? { maximumFindings: options.maximumFindings } : {}),
|
|
49
|
-
signal: options.signal,
|
|
50
|
-
});
|
|
51
|
-
emitProgress(options.onProgress, { stage: 'scene-analysis', phase: 'completed', completed: 1, total: 1 });
|
|
52
|
-
const shots = buildShotRanges(analysis);
|
|
53
|
-
if (shots.length > maximumShots) {
|
|
54
|
-
throw new RangeError(`Scene review found ${shots.length} shots, above maximumShots ${maximumShots}. Raise minimumSpacingSeconds or increase maximumShots.`);
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
const parent = dirname(output);
|
|
58
|
-
await mkdir(parent, { recursive: true });
|
|
59
|
-
const staging = await mkdtemp(join(parent, `.${basename(output)}-${randomUUID()}-`));
|
|
60
|
-
try {
|
|
61
|
-
const thumbnailDirectory = join(staging, 'thumbnails');
|
|
62
|
-
await mkdir(thumbnailDirectory);
|
|
63
|
-
emitProgress(options.onProgress, { stage: 'scene-thumbnails', phase: 'started', completed: 0, total: shots.length });
|
|
64
|
-
for (const shot of shots) {
|
|
65
|
-
throwIfAborted(options.signal);
|
|
66
|
-
const thumbnailName = `shot-${String(shot.index).padStart(4, '0')}.jpg`;
|
|
67
|
-
const thumbnailPath = join(thumbnailDirectory, thumbnailName);
|
|
68
|
-
const fit = `scale=${thumbnailSize}:${thumbnailSize}:force_original_aspect_ratio=decrease,pad=${thumbnailSize}:${thumbnailSize}:(ow-iw)/2:(oh-ih)/2:color=black`;
|
|
69
|
-
await runProcess('ffmpeg', [
|
|
70
|
-
'-nostdin', '-hide_banner', '-loglevel', 'error', '-ss', String(shot.previewSeconds),
|
|
71
|
-
'-i', resolve(inputPath), '-map', '0:v:0', '-frames:v', '1', '-vf', fit,
|
|
72
|
-
'-q:v', '3', thumbnailPath,
|
|
73
|
-
], { signal: options.signal });
|
|
74
|
-
if ((await stat(thumbnailPath)).size === 0) throw new Error(`FFmpeg produced an empty thumbnail for shot ${shot.index}.`);
|
|
75
|
-
shot.thumbnail = `thumbnails/${thumbnailName}`;
|
|
76
|
-
emitProgress(options.onProgress, {
|
|
77
|
-
stage: 'scene-thumbnails',
|
|
78
|
-
phase: 'progress',
|
|
79
|
-
completed: shot.index,
|
|
80
|
-
total: shots.length,
|
|
81
|
-
percent: shot.index / shots.length * 100,
|
|
82
|
-
});
|
|
83
|
-
}
|
|
84
|
-
emitProgress(options.onProgress, { stage: 'scene-thumbnails', phase: 'completed', completed: shots.length, total: shots.length });
|
|
85
|
-
|
|
86
|
-
const sheetShots = sampleShots(shots, contactSheetLimit);
|
|
87
|
-
const sheetColumns = Math.min(columns, sheetShots.length);
|
|
88
|
-
const sheetRows = Math.ceil(sheetShots.length / sheetColumns);
|
|
89
|
-
const sheetWidth = sheetColumns * thumbnailSize + (sheetColumns + 1) * 12;
|
|
90
|
-
const sheetHeight = sheetRows * thumbnailSize + (sheetRows + 1) * 12;
|
|
91
|
-
if (sheetWidth * sheetHeight > CONTACT_SHEET_PIXEL_LIMIT) {
|
|
92
|
-
throw new RangeError(`Contact sheet would exceed ${CONTACT_SHEET_PIXEL_LIMIT} pixels. Reduce thumbnailSize, contactSheetLimit, or columns.`);
|
|
93
|
-
}
|
|
94
|
-
const sheetInputDirectory = join(staging, 'contact-input');
|
|
95
|
-
await mkdir(sheetInputDirectory);
|
|
96
|
-
for (const [index, shot] of sheetShots.entries()) {
|
|
97
|
-
throwIfAborted(options.signal);
|
|
98
|
-
await copyFile(
|
|
99
|
-
join(staging, shot.thumbnail),
|
|
100
|
-
join(sheetInputDirectory, `frame-${String(index + 1).padStart(4, '0')}.jpg`),
|
|
101
|
-
);
|
|
102
|
-
}
|
|
103
|
-
const pattern = join(sheetInputDirectory, 'frame-%04d.jpg');
|
|
104
|
-
const sheetPath = join(staging, 'contact-sheet.jpg');
|
|
105
|
-
const sheetFilter = `tile=${sheetColumns}x${sheetRows}:padding=12:margin=12:color=white`;
|
|
106
|
-
emitProgress(options.onProgress, { stage: 'scene-contact-sheet', phase: 'started' });
|
|
107
|
-
await runProcess('ffmpeg', [
|
|
108
|
-
'-nostdin', '-hide_banner', '-loglevel', 'error', '-framerate', '1', '-start_number', '1',
|
|
109
|
-
'-i', pattern, '-vf', sheetFilter, '-frames:v', '1', '-q:v', '3', sheetPath,
|
|
110
|
-
], { signal: options.signal });
|
|
111
|
-
if ((await stat(sheetPath)).size === 0) throw new Error('FFmpeg produced an empty scene contact sheet.');
|
|
112
|
-
await rm(sheetInputDirectory, { recursive: true, force: true });
|
|
113
|
-
emitProgress(options.onProgress, { stage: 'scene-contact-sheet', phase: 'completed' });
|
|
114
|
-
|
|
115
|
-
const result = {
|
|
116
|
-
schemaVersion: 2,
|
|
117
|
-
sourceSha256,
|
|
118
|
-
providerId: analysis.providerId,
|
|
119
|
-
durationSeconds: analysis.analysis.durationSeconds,
|
|
120
|
-
settings: analysis.settings,
|
|
121
|
-
reviewRequired: true,
|
|
122
|
-
shotCount: shots.length,
|
|
123
|
-
contactSheet: 'contact-sheet.jpg',
|
|
124
|
-
contactSheetShotIndexes: sheetShots.map(({ index }) => index),
|
|
125
|
-
shots,
|
|
126
|
-
analysis: analysis.analysis,
|
|
127
|
-
};
|
|
128
|
-
await writeFile(join(staging, 'scene-review.json'), `${JSON.stringify(result, null, 2)}\n`, { flag: 'wx' });
|
|
129
|
-
throwIfAborted(options.signal);
|
|
130
|
-
emitProgress(options.onProgress, { stage: 'scene-review-publish', phase: 'started' });
|
|
131
|
-
await rename(staging, output);
|
|
132
|
-
emitProgress(options.onProgress, { stage: 'scene-review-publish', phase: 'completed' });
|
|
133
|
-
return { ...result, outputDirectory: output };
|
|
134
|
-
} catch (error) {
|
|
135
|
-
emitProgress(options.onProgress, { stage: 'scene-review-pack', phase: 'failed', message: error.message });
|
|
136
|
-
await rm(staging, { recursive: true, force: true });
|
|
137
|
-
throw error;
|
|
138
|
-
}
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
export function buildShotRanges(analysis) {
|
|
142
|
-
const durationSeconds = analysis?.analysis?.durationSeconds;
|
|
143
|
-
const findings = analysis?.analysis?.findings;
|
|
144
|
-
if (!Number.isFinite(durationSeconds) || durationSeconds <= 0 || !Array.isArray(findings)) {
|
|
145
|
-
throw new TypeError('Scene analysis must contain a positive duration and findings array.');
|
|
146
|
-
}
|
|
147
|
-
const boundaries = [0, ...findings
|
|
148
|
-
.filter((finding) => finding.type === 'scene-cut')
|
|
149
|
-
.map((finding) => finding.startSeconds)
|
|
150
|
-
.filter((time) => Number.isFinite(time) && time > 0 && time < durationSeconds), durationSeconds]
|
|
151
|
-
.sort((left, right) => left - right)
|
|
152
|
-
.filter((time, index, all) => index === 0 || time - all[index - 1] > 1e-6);
|
|
153
|
-
|
|
154
|
-
return boundaries.slice(0, -1).map((startSeconds, index) => {
|
|
155
|
-
const endSeconds = boundaries[index + 1];
|
|
156
|
-
const previewSeconds = startSeconds + Math.min(0.5, (endSeconds - startSeconds) / 2);
|
|
157
|
-
return {
|
|
158
|
-
index: index + 1,
|
|
159
|
-
startSeconds: round(startSeconds),
|
|
160
|
-
endSeconds: round(endSeconds),
|
|
161
|
-
durationSeconds: round(endSeconds - startSeconds),
|
|
162
|
-
previewSeconds: round(previewSeconds),
|
|
163
|
-
};
|
|
164
|
-
});
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
function sampleShots(shots, limit) {
|
|
168
|
-
if (shots.length <= limit) return shots;
|
|
169
|
-
if (limit === 1) return [shots[0]];
|
|
170
|
-
const selected = new Map();
|
|
171
|
-
for (let index = 0; index < limit; index += 1) {
|
|
172
|
-
const sourceIndex = Math.round(index * (shots.length - 1) / (limit - 1));
|
|
173
|
-
selected.set(shots[sourceIndex].index, shots[sourceIndex]);
|
|
174
|
-
}
|
|
175
|
-
return [...selected.values()];
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
function validateIntegerRange(value, minimum, maximum, name) {
|
|
179
|
-
if (!Number.isSafeInteger(value) || value < minimum || value > maximum) {
|
|
180
|
-
throw new TypeError(`${name} must be an integer between ${minimum} and ${maximum}.`);
|
|
181
|
-
}
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
function throwIfAborted(signal) {
|
|
185
|
-
if (signal?.aborted) {
|
|
186
|
-
if (signal.reason instanceof Error) throw signal.reason;
|
|
187
|
-
const error = new Error('Scene review cancelled.');
|
|
188
|
-
error.name = 'AbortError';
|
|
189
|
-
throw error;
|
|
190
|
-
}
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
function emitProgress(onProgress, event) {
|
|
194
|
-
try { onProgress?.({ ...event }); } catch { /* Progress observers must not interrupt media processing. */ }
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
async function hashFileAsync(path, signal) {
|
|
198
|
-
const hash = createHash('sha256');
|
|
199
|
-
for await (const chunk of createReadStream(path, { signal })) hash.update(chunk);
|
|
200
|
-
return hash.digest('hex');
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
function round(value) {
|
|
204
|
-
return Number(value.toFixed(6));
|
|
205
|
-
}
|
|
1
|
+
import { copyFile, mkdir, mkdtemp, rename, rm, stat, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { createReadStream } from 'node:fs';
|
|
3
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
4
|
+
import { basename, dirname, join, resolve } from 'node:path';
|
|
5
|
+
import { existsSync } from 'node:fs';
|
|
6
|
+
import { runProcess } from '../render/process.mjs';
|
|
7
|
+
import { detectSceneChangesAsync } from './scene-detection.mjs';
|
|
8
|
+
|
|
9
|
+
const DEFAULT_THUMBNAIL_SIZE = 320;
|
|
10
|
+
const DEFAULT_CONTACT_SHEET_LIMIT = 60;
|
|
11
|
+
const DEFAULT_MAXIMUM_SHOTS = 300;
|
|
12
|
+
const CONTACT_SHEET_PIXEL_LIMIT = 64_000_000;
|
|
13
|
+
|
|
14
|
+
/** Create a review-ready shot map with representative frames and a contact sheet. */
|
|
15
|
+
export async function createSceneReviewPackAsync(inputPath, outputDirectory, options = {}) {
|
|
16
|
+
if (!options || typeof options !== 'object' || Array.isArray(options)) {
|
|
17
|
+
throw new TypeError('Scene review options must be an object.');
|
|
18
|
+
}
|
|
19
|
+
const allowedOptions = new Set([
|
|
20
|
+
'threshold', 'minimumSpacingSeconds', 'maximumFindings', 'maximumShots',
|
|
21
|
+
'thumbnailSize', 'contactSheetLimit', 'columns', 'signal', 'onProgress',
|
|
22
|
+
]);
|
|
23
|
+
for (const key of Object.keys(options)) {
|
|
24
|
+
if (!allowedOptions.has(key)) throw new TypeError(`Unsupported scene review option: ${key}.`);
|
|
25
|
+
}
|
|
26
|
+
if (options.onProgress !== undefined && typeof options.onProgress !== 'function') {
|
|
27
|
+
throw new TypeError('onProgress must be a function.');
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const output = resolve(outputDirectory);
|
|
31
|
+
if (existsSync(output)) throw new Error(`Scene review output already exists: ${output}`);
|
|
32
|
+
const maximumShots = options.maximumShots ?? DEFAULT_MAXIMUM_SHOTS;
|
|
33
|
+
const thumbnailSize = options.thumbnailSize ?? DEFAULT_THUMBNAIL_SIZE;
|
|
34
|
+
const contactSheetLimit = options.contactSheetLimit ?? DEFAULT_CONTACT_SHEET_LIMIT;
|
|
35
|
+
const columns = options.columns ?? 4;
|
|
36
|
+
validateIntegerRange(maximumShots, 1, 2000, 'maximumShots');
|
|
37
|
+
validateIntegerRange(thumbnailSize, 80, 1280, 'thumbnailSize');
|
|
38
|
+
validateIntegerRange(contactSheetLimit, 1, 120, 'contactSheetLimit');
|
|
39
|
+
validateIntegerRange(columns, 1, 8, 'columns');
|
|
40
|
+
|
|
41
|
+
emitProgress(options.onProgress, { stage: 'scene-source-fingerprint', phase: 'started' });
|
|
42
|
+
const sourceSha256 = await hashFileAsync(resolve(inputPath), options.signal);
|
|
43
|
+
emitProgress(options.onProgress, { stage: 'scene-source-fingerprint', phase: 'completed' });
|
|
44
|
+
emitProgress(options.onProgress, { stage: 'scene-analysis', phase: 'started' });
|
|
45
|
+
const analysis = await detectSceneChangesAsync(inputPath, {
|
|
46
|
+
...(options.threshold !== undefined ? { threshold: options.threshold } : {}),
|
|
47
|
+
...(options.minimumSpacingSeconds !== undefined ? { minimumSpacingSeconds: options.minimumSpacingSeconds } : {}),
|
|
48
|
+
...(options.maximumFindings !== undefined ? { maximumFindings: options.maximumFindings } : {}),
|
|
49
|
+
signal: options.signal,
|
|
50
|
+
});
|
|
51
|
+
emitProgress(options.onProgress, { stage: 'scene-analysis', phase: 'completed', completed: 1, total: 1 });
|
|
52
|
+
const shots = buildShotRanges(analysis);
|
|
53
|
+
if (shots.length > maximumShots) {
|
|
54
|
+
throw new RangeError(`Scene review found ${shots.length} shots, above maximumShots ${maximumShots}. Raise minimumSpacingSeconds or increase maximumShots.`);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const parent = dirname(output);
|
|
58
|
+
await mkdir(parent, { recursive: true });
|
|
59
|
+
const staging = await mkdtemp(join(parent, `.${basename(output)}-${randomUUID()}-`));
|
|
60
|
+
try {
|
|
61
|
+
const thumbnailDirectory = join(staging, 'thumbnails');
|
|
62
|
+
await mkdir(thumbnailDirectory);
|
|
63
|
+
emitProgress(options.onProgress, { stage: 'scene-thumbnails', phase: 'started', completed: 0, total: shots.length });
|
|
64
|
+
for (const shot of shots) {
|
|
65
|
+
throwIfAborted(options.signal);
|
|
66
|
+
const thumbnailName = `shot-${String(shot.index).padStart(4, '0')}.jpg`;
|
|
67
|
+
const thumbnailPath = join(thumbnailDirectory, thumbnailName);
|
|
68
|
+
const fit = `scale=${thumbnailSize}:${thumbnailSize}:force_original_aspect_ratio=decrease,pad=${thumbnailSize}:${thumbnailSize}:(ow-iw)/2:(oh-ih)/2:color=black`;
|
|
69
|
+
await runProcess('ffmpeg', [
|
|
70
|
+
'-nostdin', '-hide_banner', '-loglevel', 'error', '-ss', String(shot.previewSeconds),
|
|
71
|
+
'-i', resolve(inputPath), '-map', '0:v:0', '-frames:v', '1', '-vf', fit,
|
|
72
|
+
'-q:v', '3', thumbnailPath,
|
|
73
|
+
], { signal: options.signal });
|
|
74
|
+
if ((await stat(thumbnailPath)).size === 0) throw new Error(`FFmpeg produced an empty thumbnail for shot ${shot.index}.`);
|
|
75
|
+
shot.thumbnail = `thumbnails/${thumbnailName}`;
|
|
76
|
+
emitProgress(options.onProgress, {
|
|
77
|
+
stage: 'scene-thumbnails',
|
|
78
|
+
phase: 'progress',
|
|
79
|
+
completed: shot.index,
|
|
80
|
+
total: shots.length,
|
|
81
|
+
percent: shot.index / shots.length * 100,
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
emitProgress(options.onProgress, { stage: 'scene-thumbnails', phase: 'completed', completed: shots.length, total: shots.length });
|
|
85
|
+
|
|
86
|
+
const sheetShots = sampleShots(shots, contactSheetLimit);
|
|
87
|
+
const sheetColumns = Math.min(columns, sheetShots.length);
|
|
88
|
+
const sheetRows = Math.ceil(sheetShots.length / sheetColumns);
|
|
89
|
+
const sheetWidth = sheetColumns * thumbnailSize + (sheetColumns + 1) * 12;
|
|
90
|
+
const sheetHeight = sheetRows * thumbnailSize + (sheetRows + 1) * 12;
|
|
91
|
+
if (sheetWidth * sheetHeight > CONTACT_SHEET_PIXEL_LIMIT) {
|
|
92
|
+
throw new RangeError(`Contact sheet would exceed ${CONTACT_SHEET_PIXEL_LIMIT} pixels. Reduce thumbnailSize, contactSheetLimit, or columns.`);
|
|
93
|
+
}
|
|
94
|
+
const sheetInputDirectory = join(staging, 'contact-input');
|
|
95
|
+
await mkdir(sheetInputDirectory);
|
|
96
|
+
for (const [index, shot] of sheetShots.entries()) {
|
|
97
|
+
throwIfAborted(options.signal);
|
|
98
|
+
await copyFile(
|
|
99
|
+
join(staging, shot.thumbnail),
|
|
100
|
+
join(sheetInputDirectory, `frame-${String(index + 1).padStart(4, '0')}.jpg`),
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
const pattern = join(sheetInputDirectory, 'frame-%04d.jpg');
|
|
104
|
+
const sheetPath = join(staging, 'contact-sheet.jpg');
|
|
105
|
+
const sheetFilter = `tile=${sheetColumns}x${sheetRows}:padding=12:margin=12:color=white`;
|
|
106
|
+
emitProgress(options.onProgress, { stage: 'scene-contact-sheet', phase: 'started' });
|
|
107
|
+
await runProcess('ffmpeg', [
|
|
108
|
+
'-nostdin', '-hide_banner', '-loglevel', 'error', '-framerate', '1', '-start_number', '1',
|
|
109
|
+
'-i', pattern, '-vf', sheetFilter, '-frames:v', '1', '-q:v', '3', sheetPath,
|
|
110
|
+
], { signal: options.signal });
|
|
111
|
+
if ((await stat(sheetPath)).size === 0) throw new Error('FFmpeg produced an empty scene contact sheet.');
|
|
112
|
+
await rm(sheetInputDirectory, { recursive: true, force: true });
|
|
113
|
+
emitProgress(options.onProgress, { stage: 'scene-contact-sheet', phase: 'completed' });
|
|
114
|
+
|
|
115
|
+
const result = {
|
|
116
|
+
schemaVersion: 2,
|
|
117
|
+
sourceSha256,
|
|
118
|
+
providerId: analysis.providerId,
|
|
119
|
+
durationSeconds: analysis.analysis.durationSeconds,
|
|
120
|
+
settings: analysis.settings,
|
|
121
|
+
reviewRequired: true,
|
|
122
|
+
shotCount: shots.length,
|
|
123
|
+
contactSheet: 'contact-sheet.jpg',
|
|
124
|
+
contactSheetShotIndexes: sheetShots.map(({ index }) => index),
|
|
125
|
+
shots,
|
|
126
|
+
analysis: analysis.analysis,
|
|
127
|
+
};
|
|
128
|
+
await writeFile(join(staging, 'scene-review.json'), `${JSON.stringify(result, null, 2)}\n`, { flag: 'wx' });
|
|
129
|
+
throwIfAborted(options.signal);
|
|
130
|
+
emitProgress(options.onProgress, { stage: 'scene-review-publish', phase: 'started' });
|
|
131
|
+
await rename(staging, output);
|
|
132
|
+
emitProgress(options.onProgress, { stage: 'scene-review-publish', phase: 'completed' });
|
|
133
|
+
return { ...result, outputDirectory: output };
|
|
134
|
+
} catch (error) {
|
|
135
|
+
emitProgress(options.onProgress, { stage: 'scene-review-pack', phase: 'failed', message: error.message });
|
|
136
|
+
await rm(staging, { recursive: true, force: true });
|
|
137
|
+
throw error;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export function buildShotRanges(analysis) {
|
|
142
|
+
const durationSeconds = analysis?.analysis?.durationSeconds;
|
|
143
|
+
const findings = analysis?.analysis?.findings;
|
|
144
|
+
if (!Number.isFinite(durationSeconds) || durationSeconds <= 0 || !Array.isArray(findings)) {
|
|
145
|
+
throw new TypeError('Scene analysis must contain a positive duration and findings array.');
|
|
146
|
+
}
|
|
147
|
+
const boundaries = [0, ...findings
|
|
148
|
+
.filter((finding) => finding.type === 'scene-cut')
|
|
149
|
+
.map((finding) => finding.startSeconds)
|
|
150
|
+
.filter((time) => Number.isFinite(time) && time > 0 && time < durationSeconds), durationSeconds]
|
|
151
|
+
.sort((left, right) => left - right)
|
|
152
|
+
.filter((time, index, all) => index === 0 || time - all[index - 1] > 1e-6);
|
|
153
|
+
|
|
154
|
+
return boundaries.slice(0, -1).map((startSeconds, index) => {
|
|
155
|
+
const endSeconds = boundaries[index + 1];
|
|
156
|
+
const previewSeconds = startSeconds + Math.min(0.5, (endSeconds - startSeconds) / 2);
|
|
157
|
+
return {
|
|
158
|
+
index: index + 1,
|
|
159
|
+
startSeconds: round(startSeconds),
|
|
160
|
+
endSeconds: round(endSeconds),
|
|
161
|
+
durationSeconds: round(endSeconds - startSeconds),
|
|
162
|
+
previewSeconds: round(previewSeconds),
|
|
163
|
+
};
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function sampleShots(shots, limit) {
|
|
168
|
+
if (shots.length <= limit) return shots;
|
|
169
|
+
if (limit === 1) return [shots[0]];
|
|
170
|
+
const selected = new Map();
|
|
171
|
+
for (let index = 0; index < limit; index += 1) {
|
|
172
|
+
const sourceIndex = Math.round(index * (shots.length - 1) / (limit - 1));
|
|
173
|
+
selected.set(shots[sourceIndex].index, shots[sourceIndex]);
|
|
174
|
+
}
|
|
175
|
+
return [...selected.values()];
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function validateIntegerRange(value, minimum, maximum, name) {
|
|
179
|
+
if (!Number.isSafeInteger(value) || value < minimum || value > maximum) {
|
|
180
|
+
throw new TypeError(`${name} must be an integer between ${minimum} and ${maximum}.`);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function throwIfAborted(signal) {
|
|
185
|
+
if (signal?.aborted) {
|
|
186
|
+
if (signal.reason instanceof Error) throw signal.reason;
|
|
187
|
+
const error = new Error('Scene review cancelled.');
|
|
188
|
+
error.name = 'AbortError';
|
|
189
|
+
throw error;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function emitProgress(onProgress, event) {
|
|
194
|
+
try { onProgress?.({ ...event }); } catch { /* Progress observers must not interrupt media processing. */ }
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
async function hashFileAsync(path, signal) {
|
|
198
|
+
const hash = createHash('sha256');
|
|
199
|
+
for await (const chunk of createReadStream(path, { signal })) hash.update(chunk);
|
|
200
|
+
return hash.digest('hex');
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function round(value) {
|
|
204
|
+
return Number(value.toFixed(6));
|
|
205
|
+
}
|