aivee 0.1.0-alpha.0 → 0.1.0-dev.d34a6ba
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/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,6 +1,6 @@
|
|
|
1
|
-
import { createHash } from 'node:crypto';
|
|
2
|
-
|
|
3
|
-
/** Return a SHA-256 digest of a timeline object's JSON encoding. */
|
|
4
|
-
export function timelineFingerprint(timeline) {
|
|
5
|
-
return createHash('sha256').update(JSON.stringify(timeline)).digest('hex');
|
|
6
|
-
}
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
|
|
3
|
+
/** Return a SHA-256 digest of a timeline object's JSON encoding. */
|
|
4
|
+
export function timelineFingerprint(timeline) {
|
|
5
|
+
return createHash('sha256').update(JSON.stringify(timeline)).digest('hex');
|
|
6
|
+
}
|
|
@@ -1,31 +1,31 @@
|
|
|
1
|
-
import { assertEdlApproved, validateEdl } from '../edits/retime-transcript.mjs';
|
|
2
|
-
import { validateTimeline } from './validate.mjs';
|
|
3
|
-
|
|
4
|
-
export function timelineFromEdl(edl, assetId, project = {}) {
|
|
5
|
-
validateEdl(edl, edl?.sourceDurationSeconds);
|
|
6
|
-
assertEdlApproved(edl);
|
|
7
|
-
if (!/^[a-z][a-z0-9-]*$/.test(assetId ?? '')) throw new TypeError('assetId must be a kebab-case id.');
|
|
8
|
-
const cuts = edl.cuts;
|
|
9
|
-
const clips = [];
|
|
10
|
-
let source = 0, timeline = 0;
|
|
11
|
-
for (let i = 0; i <= cuts.length; i++) {
|
|
12
|
-
const end = cuts[i]?.sourceStart ?? edl.sourceDurationSeconds;
|
|
13
|
-
if (end > source) {
|
|
14
|
-
const durationSeconds = end - source;
|
|
15
|
-
clips.push({ id: `clip-${clips.length + 1}`, assetId, sourceIn: source, sourceOut: end, timelineStart: timeline, durationSeconds });
|
|
16
|
-
timeline += durationSeconds;
|
|
17
|
-
}
|
|
18
|
-
if (cuts[i]) source = cuts[i].sourceEnd;
|
|
19
|
-
}
|
|
20
|
-
const tracks = [{ id: 'video-main', kind: 'video', order: 0, clips }];
|
|
21
|
-
if (project.includeAudioTrack) {
|
|
22
|
-
tracks.push({
|
|
23
|
-
id: 'audio-main',
|
|
24
|
-
kind: 'audio',
|
|
25
|
-
order: 1,
|
|
26
|
-
clips: clips.map((clip, index) => ({ ...clip, id: `audio-clip-${index + 1}` })),
|
|
27
|
-
});
|
|
28
|
-
}
|
|
29
|
-
const result = { schemaVersion: 1, project: { id: project.id ?? 'edited-video', width: project.width ?? 1920, height: project.height ?? 1080, fps: project.fps ?? 30, durationSeconds: timeline, ...(project.sourceFit === undefined ? {} : { sourceFit: project.sourceFit }), ...(project.backgroundColor === undefined ? {} : { backgroundColor: project.backgroundColor }) }, assets: [{ id: assetId, path: project.assetPath ?? 'assets/source.mp4', ...(project.assetType ? { type: project.assetType } : {}), ...(project.assetHasAudio !== undefined ? { hasAudio: project.assetHasAudio } : {}), ...(project.assetVideoStreams !== undefined ? { videoStreams: project.assetVideoStreams } : {}), ...(project.assetAudioStreams !== undefined ? { audioStreams: project.assetAudioStreams } : {}), ...(project.assetDurationSeconds !== undefined ? { durationSeconds: project.assetDurationSeconds } : {}), ...(project.assetStreamTiming !== undefined ? { streamTiming: project.assetStreamTiming } : {}) }], tracks };
|
|
30
|
-
return validateTimeline(result);
|
|
31
|
-
}
|
|
1
|
+
import { assertEdlApproved, validateEdl } from '../edits/retime-transcript.mjs';
|
|
2
|
+
import { validateTimeline } from './validate.mjs';
|
|
3
|
+
|
|
4
|
+
export function timelineFromEdl(edl, assetId, project = {}) {
|
|
5
|
+
validateEdl(edl, edl?.sourceDurationSeconds);
|
|
6
|
+
assertEdlApproved(edl);
|
|
7
|
+
if (!/^[a-z][a-z0-9-]*$/.test(assetId ?? '')) throw new TypeError('assetId must be a kebab-case id.');
|
|
8
|
+
const cuts = edl.cuts;
|
|
9
|
+
const clips = [];
|
|
10
|
+
let source = 0, timeline = 0;
|
|
11
|
+
for (let i = 0; i <= cuts.length; i++) {
|
|
12
|
+
const end = cuts[i]?.sourceStart ?? edl.sourceDurationSeconds;
|
|
13
|
+
if (end > source) {
|
|
14
|
+
const durationSeconds = end - source;
|
|
15
|
+
clips.push({ id: `clip-${clips.length + 1}`, assetId, sourceIn: source, sourceOut: end, timelineStart: timeline, durationSeconds });
|
|
16
|
+
timeline += durationSeconds;
|
|
17
|
+
}
|
|
18
|
+
if (cuts[i]) source = cuts[i].sourceEnd;
|
|
19
|
+
}
|
|
20
|
+
const tracks = [{ id: 'video-main', kind: 'video', order: 0, clips }];
|
|
21
|
+
if (project.includeAudioTrack) {
|
|
22
|
+
tracks.push({
|
|
23
|
+
id: 'audio-main',
|
|
24
|
+
kind: 'audio',
|
|
25
|
+
order: 1,
|
|
26
|
+
clips: clips.map((clip, index) => ({ ...clip, id: `audio-clip-${index + 1}` })),
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
const result = { schemaVersion: 1, project: { id: project.id ?? 'edited-video', width: project.width ?? 1920, height: project.height ?? 1080, fps: project.fps ?? 30, durationSeconds: timeline, ...(project.sourceFit === undefined ? {} : { sourceFit: project.sourceFit }), ...(project.backgroundColor === undefined ? {} : { backgroundColor: project.backgroundColor }) }, assets: [{ id: assetId, path: project.assetPath ?? 'assets/source.mp4', ...(project.assetType ? { type: project.assetType } : {}), ...(project.assetHasAudio !== undefined ? { hasAudio: project.assetHasAudio } : {}), ...(project.assetVideoStreams !== undefined ? { videoStreams: project.assetVideoStreams } : {}), ...(project.assetAudioStreams !== undefined ? { audioStreams: project.assetAudioStreams } : {}), ...(project.assetDurationSeconds !== undefined ? { durationSeconds: project.assetDurationSeconds } : {}), ...(project.assetStreamTiming !== undefined ? { streamTiming: project.assetStreamTiming } : {}) }], tracks };
|
|
30
|
+
return validateTimeline(result);
|
|
31
|
+
}
|
|
@@ -1,281 +1,281 @@
|
|
|
1
|
-
import { randomUUID } from 'node:crypto';
|
|
2
|
-
import { closeSync, existsSync, lstatSync, openSync, readFileSync, renameSync, rmSync, statSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
3
|
-
import { basename, dirname, join, resolve } from 'node:path';
|
|
4
|
-
import { editTimeline, editTimelineBatch } from './edit.mjs';
|
|
5
|
-
import { validateTimeline } from './validate.mjs';
|
|
6
|
-
import { timelineFingerprint } from './fingerprint.mjs';
|
|
7
|
-
|
|
8
|
-
const DEFAULT_LIMIT = 20;
|
|
9
|
-
const MAX_LIMIT = 100;
|
|
10
|
-
const MAX_HISTORY_BYTES = 64 * 1024 * 1024;
|
|
11
|
-
|
|
12
|
-
export function editTimelineFile(timelinePath, operation, { limit } = {}) {
|
|
13
|
-
return withHistoryLock(timelinePath, () => {
|
|
14
|
-
const history = loadHistory(timelinePath, limit, { createIfMissing: true });
|
|
15
|
-
const next = Array.isArray(operation)
|
|
16
|
-
? editTimelineBatch(history.timeline, operation)
|
|
17
|
-
: editTimeline(history.timeline, operation);
|
|
18
|
-
const undo = [...history.undo, history.timeline].slice(-history.limit);
|
|
19
|
-
return commitHistory(timelinePath, {
|
|
20
|
-
...history,
|
|
21
|
-
timeline: next,
|
|
22
|
-
timelineHash: hashTimeline(next),
|
|
23
|
-
previousTimelineHash: history.timelineHash,
|
|
24
|
-
undo,
|
|
25
|
-
redo: [],
|
|
26
|
-
});
|
|
27
|
-
});
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
/** Apply an explicitly approved review plan if it was created from the current timeline. */
|
|
31
|
-
export function applyTimelineReviewPlan(timelinePath, plan, { limit } = {}) {
|
|
32
|
-
validateReviewPlan(plan);
|
|
33
|
-
return withHistoryLock(timelinePath, () => {
|
|
34
|
-
const { path, timeline } = readTimeline(timelinePath);
|
|
35
|
-
const baseHash = hashTimeline(timeline);
|
|
36
|
-
if (baseHash !== plan.baseTimelineSha256) {
|
|
37
|
-
throw new Error('Review plan is stale because the timeline changed after the plan was created. Generate a new plan.');
|
|
38
|
-
}
|
|
39
|
-
const next = editTimelineBatch(timeline, plan.operations);
|
|
40
|
-
const history = loadHistory(path, limit);
|
|
41
|
-
if (history.timelineHash !== baseHash) {
|
|
42
|
-
throw new Error('Review plan is stale because the timeline history changed after the plan was created. Generate a new plan.');
|
|
43
|
-
}
|
|
44
|
-
const undo = [...history.undo, history.timeline].slice(-history.limit);
|
|
45
|
-
const result = commitHistory(path, {
|
|
46
|
-
...history,
|
|
47
|
-
timeline: next,
|
|
48
|
-
timelineHash: hashTimeline(next),
|
|
49
|
-
previousTimelineHash: history.timelineHash,
|
|
50
|
-
undo,
|
|
51
|
-
redo: [],
|
|
52
|
-
});
|
|
53
|
-
return {
|
|
54
|
-
...result,
|
|
55
|
-
appliedReviewPlan: plan.type,
|
|
56
|
-
appliedOperationCount: plan.operations.length,
|
|
57
|
-
};
|
|
58
|
-
});
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
export function undoTimelineFile(timelinePath) {
|
|
62
|
-
return withHistoryLock(timelinePath, () => {
|
|
63
|
-
const history = loadHistory(timelinePath);
|
|
64
|
-
if (history.undo.length === 0) return describeHistory(history);
|
|
65
|
-
const undo = [...history.undo];
|
|
66
|
-
const timeline = undo.pop();
|
|
67
|
-
return commitHistory(timelinePath, {
|
|
68
|
-
...history,
|
|
69
|
-
timeline,
|
|
70
|
-
timelineHash: hashTimeline(timeline),
|
|
71
|
-
previousTimelineHash: history.timelineHash,
|
|
72
|
-
undo,
|
|
73
|
-
redo: [...history.redo, history.timeline].slice(-history.limit),
|
|
74
|
-
});
|
|
75
|
-
});
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
export function redoTimelineFile(timelinePath) {
|
|
79
|
-
return withHistoryLock(timelinePath, () => {
|
|
80
|
-
const history = loadHistory(timelinePath);
|
|
81
|
-
if (history.redo.length === 0) return describeHistory(history);
|
|
82
|
-
const redo = [...history.redo];
|
|
83
|
-
const timeline = redo.pop();
|
|
84
|
-
return commitHistory(timelinePath, {
|
|
85
|
-
...history,
|
|
86
|
-
timeline,
|
|
87
|
-
timelineHash: hashTimeline(timeline),
|
|
88
|
-
previousTimelineHash: history.timelineHash,
|
|
89
|
-
undo: [...history.undo, history.timeline].slice(-history.limit),
|
|
90
|
-
redo,
|
|
91
|
-
});
|
|
92
|
-
});
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
export function getTimelineFileHistory(timelinePath) {
|
|
96
|
-
return withHistoryLock(timelinePath, () => describeHistory(loadHistory(timelinePath)));
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
export function resetTimelineFileHistory(timelinePath, { limit = DEFAULT_LIMIT } = {}) {
|
|
100
|
-
validateLimit(limit);
|
|
101
|
-
return withHistoryLock(timelinePath, () => {
|
|
102
|
-
const { timeline } = readTimeline(timelinePath);
|
|
103
|
-
return commitHistory(timelinePath, {
|
|
104
|
-
schemaVersion: 1,
|
|
105
|
-
limit,
|
|
106
|
-
timeline,
|
|
107
|
-
timelineHash: hashTimeline(timeline),
|
|
108
|
-
previousTimelineHash: null,
|
|
109
|
-
undo: [],
|
|
110
|
-
redo: [],
|
|
111
|
-
});
|
|
112
|
-
});
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
function loadHistory(timelinePath, requestedLimit, { createIfMissing = false } = {}) {
|
|
116
|
-
if (requestedLimit !== undefined) validateLimit(requestedLimit);
|
|
117
|
-
const { path, timeline } = readTimeline(timelinePath);
|
|
118
|
-
const historyPath = getHistoryPath(path);
|
|
119
|
-
if (!existsSync(historyPath)) {
|
|
120
|
-
const state = {
|
|
121
|
-
schemaVersion: 1,
|
|
122
|
-
limit: requestedLimit ?? DEFAULT_LIMIT,
|
|
123
|
-
timeline,
|
|
124
|
-
timelineHash: hashTimeline(timeline),
|
|
125
|
-
previousTimelineHash: null,
|
|
126
|
-
undo: [],
|
|
127
|
-
redo: [],
|
|
128
|
-
};
|
|
129
|
-
if (createIfMissing) writeHistoryFile(historyPath, state);
|
|
130
|
-
return state;
|
|
131
|
-
}
|
|
132
|
-
if (lstatSync(historyPath).isSymbolicLink()) throw new TypeError('Timeline history file cannot be a symbolic link.');
|
|
133
|
-
if (statSync(historyPath).size > MAX_HISTORY_BYTES) throw new RangeError(`Timeline history exceeds the ${MAX_HISTORY_BYTES / 1024 / 1024} MiB limit.`);
|
|
134
|
-
const state = JSON.parse(readFileSync(historyPath, 'utf8'));
|
|
135
|
-
validateHistory(state);
|
|
136
|
-
if (requestedLimit !== undefined && requestedLimit !== state.limit) {
|
|
137
|
-
throw new TypeError(`Timeline history already uses limit ${state.limit}; reset it to change the limit.`);
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
const diskHash = hashTimeline(timeline);
|
|
141
|
-
if (diskHash === state.timelineHash) return state;
|
|
142
|
-
if (state.previousTimelineHash !== null && diskHash === state.previousTimelineHash) {
|
|
143
|
-
writeTimelineFile(path, state.timeline);
|
|
144
|
-
return state;
|
|
145
|
-
}
|
|
146
|
-
throw new Error(`Timeline changed outside its history session: ${path}. Use timeline history reset to start a new session.`);
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
function readTimeline(timelinePath) {
|
|
150
|
-
const path = resolve(timelinePath);
|
|
151
|
-
if (!existsSync(path)) throw new Error(`Timeline file not found: ${path}`);
|
|
152
|
-
if (lstatSync(path).isSymbolicLink()) throw new TypeError('Timeline file cannot be a symbolic link when using persistent history.');
|
|
153
|
-
const timeline = JSON.parse(readFileSync(path, 'utf8'));
|
|
154
|
-
validateTimeline(timeline);
|
|
155
|
-
return { path, timeline };
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
function commitHistory(timelinePath, state) {
|
|
159
|
-
const { path } = readTimeline(timelinePath);
|
|
160
|
-
validateHistory(state);
|
|
161
|
-
writeHistoryFile(getHistoryPath(path), state);
|
|
162
|
-
writeTimelineFile(path, state.timeline);
|
|
163
|
-
return describeHistory(state);
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
function withHistoryLock(timelinePath, operation) {
|
|
167
|
-
const timeline = resolve(timelinePath);
|
|
168
|
-
const lockPath = join(dirname(timeline), `.${basename(timeline)}.history.lock`);
|
|
169
|
-
let descriptor;
|
|
170
|
-
try {
|
|
171
|
-
descriptor = openSync(lockPath, 'wx');
|
|
172
|
-
} catch (error) {
|
|
173
|
-
if (error.code === 'EEXIST' && removeStaleHistoryLock(lockPath)) descriptor = openSync(lockPath, 'wx');
|
|
174
|
-
else if (error.code === 'EEXIST') throw new Error(`Timeline history is locked by another operation: ${lockPath}`);
|
|
175
|
-
else throw error;
|
|
176
|
-
}
|
|
177
|
-
try {
|
|
178
|
-
writeFileSync(descriptor, JSON.stringify({ pid: process.pid, createdAt: Date.now() }));
|
|
179
|
-
return operation();
|
|
180
|
-
} finally {
|
|
181
|
-
closeSync(descriptor);
|
|
182
|
-
unlinkSync(lockPath);
|
|
183
|
-
}
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
function removeStaleHistoryLock(lockPath) {
|
|
187
|
-
try {
|
|
188
|
-
if (lstatSync(lockPath).isSymbolicLink()) throw new TypeError('Timeline history lock cannot be a symbolic link.');
|
|
189
|
-
const lock = JSON.parse(readFileSync(lockPath, 'utf8'));
|
|
190
|
-
if (!Number.isSafeInteger(lock.pid) || lock.pid <= 0) {
|
|
191
|
-
if (Date.now() - statSync(lockPath).mtimeMs < 30_000) return false;
|
|
192
|
-
unlinkSync(lockPath);
|
|
193
|
-
return true;
|
|
194
|
-
}
|
|
195
|
-
try {
|
|
196
|
-
process.kill(lock.pid, 0);
|
|
197
|
-
return false;
|
|
198
|
-
} catch (error) {
|
|
199
|
-
if (error.code !== 'ESRCH') return false;
|
|
200
|
-
}
|
|
201
|
-
unlinkSync(lockPath);
|
|
202
|
-
return true;
|
|
203
|
-
} catch (error) {
|
|
204
|
-
if (error.code === 'ENOENT') return true;
|
|
205
|
-
if (error instanceof SyntaxError && Date.now() - statSync(lockPath).mtimeMs >= 30_000) {
|
|
206
|
-
unlinkSync(lockPath);
|
|
207
|
-
return true;
|
|
208
|
-
}
|
|
209
|
-
return false;
|
|
210
|
-
}
|
|
211
|
-
}
|
|
212
|
-
|
|
213
|
-
function validateHistory(state) {
|
|
214
|
-
if (!state || state.schemaVersion !== 1 || !Number.isSafeInteger(state.limit)
|
|
215
|
-
|| state.limit < 1 || state.limit > MAX_LIMIT || !Array.isArray(state.undo)
|
|
216
|
-
|| !Array.isArray(state.redo) || state.undo.length > state.limit
|
|
217
|
-
|| state.redo.length > state.limit || !/^[\da-f]{64}$/.test(state.timelineHash ?? '')
|
|
218
|
-
|| (state.previousTimelineHash !== null && !/^[\da-f]{64}$/.test(state.previousTimelineHash ?? ''))) {
|
|
219
|
-
throw new TypeError('Persistent timeline history has an invalid schema.');
|
|
220
|
-
}
|
|
221
|
-
validateTimeline(state.timeline);
|
|
222
|
-
for (const snapshot of [...state.undo, ...state.redo]) validateTimeline(snapshot);
|
|
223
|
-
if (hashTimeline(state.timeline) !== state.timelineHash) throw new TypeError('Persistent timeline history snapshot does not match its fingerprint.');
|
|
224
|
-
}
|
|
225
|
-
|
|
226
|
-
function validateLimit(limit) {
|
|
227
|
-
if (!Number.isSafeInteger(limit) || limit < 1 || limit > MAX_LIMIT) {
|
|
228
|
-
throw new TypeError(`Timeline history limit must be an integer from 1 to ${MAX_LIMIT}.`);
|
|
229
|
-
}
|
|
230
|
-
}
|
|
231
|
-
|
|
232
|
-
function describeHistory(state) {
|
|
233
|
-
return {
|
|
234
|
-
schemaVersion: 1,
|
|
235
|
-
canUndo: state.undo.length > 0,
|
|
236
|
-
canRedo: state.redo.length > 0,
|
|
237
|
-
undoDepth: state.undo.length,
|
|
238
|
-
redoDepth: state.redo.length,
|
|
239
|
-
limit: state.limit,
|
|
240
|
-
};
|
|
241
|
-
}
|
|
242
|
-
|
|
243
|
-
function writeHistoryFile(path, state) {
|
|
244
|
-
const content = `${JSON.stringify(state, null, 2)}\n`;
|
|
245
|
-
if (Buffer.byteLength(content) > MAX_HISTORY_BYTES) {
|
|
246
|
-
throw new RangeError(`Timeline history exceeds the ${MAX_HISTORY_BYTES / 1024 / 1024} MiB limit.`);
|
|
247
|
-
}
|
|
248
|
-
writeAtomically(path, content);
|
|
249
|
-
}
|
|
250
|
-
|
|
251
|
-
function writeTimelineFile(path, timeline) {
|
|
252
|
-
writeAtomically(path, `${JSON.stringify(timeline, null, 2)}\n`);
|
|
253
|
-
}
|
|
254
|
-
|
|
255
|
-
function writeAtomically(path, content) {
|
|
256
|
-
const temporaryPath = join(dirname(path), `.${basename(path)}.${randomUUID()}.tmp`);
|
|
257
|
-
try {
|
|
258
|
-
writeFileSync(temporaryPath, content, { flag: 'wx' });
|
|
259
|
-
renameSync(temporaryPath, path);
|
|
260
|
-
} finally {
|
|
261
|
-
rmSync(temporaryPath, { force: true });
|
|
262
|
-
}
|
|
263
|
-
}
|
|
264
|
-
|
|
265
|
-
function getHistoryPath(timelinePath) {
|
|
266
|
-
return join(dirname(timelinePath), `.${basename(timelinePath)}.history.json`);
|
|
267
|
-
}
|
|
268
|
-
|
|
269
|
-
function hashTimeline(timeline) {
|
|
270
|
-
return timelineFingerprint(timeline);
|
|
271
|
-
}
|
|
272
|
-
|
|
273
|
-
function validateReviewPlan(plan) {
|
|
274
|
-
if (!plan || typeof plan !== 'object' || Array.isArray(plan)
|
|
275
|
-
|| plan.schemaVersion !== 1 || plan.reviewRequired !== true
|
|
276
|
-
|| !/^[a-z][a-z0-9-]*$/.test(plan.type ?? '')
|
|
277
|
-
|| !/^[a-f0-9]{64}$/.test(plan.baseTimelineSha256 ?? '')
|
|
278
|
-
|| !Array.isArray(plan.operations) || plan.operations.length === 0 || plan.operations.length > 2000) {
|
|
279
|
-
throw new TypeError('Review plan must be a schemaVersion 1 review-required plan with a SHA-256 timeline base and 1–2000 operations.');
|
|
280
|
-
}
|
|
281
|
-
}
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { closeSync, existsSync, lstatSync, openSync, readFileSync, renameSync, rmSync, statSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { basename, dirname, join, resolve } from 'node:path';
|
|
4
|
+
import { editTimeline, editTimelineBatch } from './edit.mjs';
|
|
5
|
+
import { validateTimeline } from './validate.mjs';
|
|
6
|
+
import { timelineFingerprint } from './fingerprint.mjs';
|
|
7
|
+
|
|
8
|
+
const DEFAULT_LIMIT = 20;
|
|
9
|
+
const MAX_LIMIT = 100;
|
|
10
|
+
const MAX_HISTORY_BYTES = 64 * 1024 * 1024;
|
|
11
|
+
|
|
12
|
+
export function editTimelineFile(timelinePath, operation, { limit } = {}) {
|
|
13
|
+
return withHistoryLock(timelinePath, () => {
|
|
14
|
+
const history = loadHistory(timelinePath, limit, { createIfMissing: true });
|
|
15
|
+
const next = Array.isArray(operation)
|
|
16
|
+
? editTimelineBatch(history.timeline, operation)
|
|
17
|
+
: editTimeline(history.timeline, operation);
|
|
18
|
+
const undo = [...history.undo, history.timeline].slice(-history.limit);
|
|
19
|
+
return commitHistory(timelinePath, {
|
|
20
|
+
...history,
|
|
21
|
+
timeline: next,
|
|
22
|
+
timelineHash: hashTimeline(next),
|
|
23
|
+
previousTimelineHash: history.timelineHash,
|
|
24
|
+
undo,
|
|
25
|
+
redo: [],
|
|
26
|
+
});
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Apply an explicitly approved review plan if it was created from the current timeline. */
|
|
31
|
+
export function applyTimelineReviewPlan(timelinePath, plan, { limit } = {}) {
|
|
32
|
+
validateReviewPlan(plan);
|
|
33
|
+
return withHistoryLock(timelinePath, () => {
|
|
34
|
+
const { path, timeline } = readTimeline(timelinePath);
|
|
35
|
+
const baseHash = hashTimeline(timeline);
|
|
36
|
+
if (baseHash !== plan.baseTimelineSha256) {
|
|
37
|
+
throw new Error('Review plan is stale because the timeline changed after the plan was created. Generate a new plan.');
|
|
38
|
+
}
|
|
39
|
+
const next = editTimelineBatch(timeline, plan.operations);
|
|
40
|
+
const history = loadHistory(path, limit);
|
|
41
|
+
if (history.timelineHash !== baseHash) {
|
|
42
|
+
throw new Error('Review plan is stale because the timeline history changed after the plan was created. Generate a new plan.');
|
|
43
|
+
}
|
|
44
|
+
const undo = [...history.undo, history.timeline].slice(-history.limit);
|
|
45
|
+
const result = commitHistory(path, {
|
|
46
|
+
...history,
|
|
47
|
+
timeline: next,
|
|
48
|
+
timelineHash: hashTimeline(next),
|
|
49
|
+
previousTimelineHash: history.timelineHash,
|
|
50
|
+
undo,
|
|
51
|
+
redo: [],
|
|
52
|
+
});
|
|
53
|
+
return {
|
|
54
|
+
...result,
|
|
55
|
+
appliedReviewPlan: plan.type,
|
|
56
|
+
appliedOperationCount: plan.operations.length,
|
|
57
|
+
};
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function undoTimelineFile(timelinePath) {
|
|
62
|
+
return withHistoryLock(timelinePath, () => {
|
|
63
|
+
const history = loadHistory(timelinePath);
|
|
64
|
+
if (history.undo.length === 0) return describeHistory(history);
|
|
65
|
+
const undo = [...history.undo];
|
|
66
|
+
const timeline = undo.pop();
|
|
67
|
+
return commitHistory(timelinePath, {
|
|
68
|
+
...history,
|
|
69
|
+
timeline,
|
|
70
|
+
timelineHash: hashTimeline(timeline),
|
|
71
|
+
previousTimelineHash: history.timelineHash,
|
|
72
|
+
undo,
|
|
73
|
+
redo: [...history.redo, history.timeline].slice(-history.limit),
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function redoTimelineFile(timelinePath) {
|
|
79
|
+
return withHistoryLock(timelinePath, () => {
|
|
80
|
+
const history = loadHistory(timelinePath);
|
|
81
|
+
if (history.redo.length === 0) return describeHistory(history);
|
|
82
|
+
const redo = [...history.redo];
|
|
83
|
+
const timeline = redo.pop();
|
|
84
|
+
return commitHistory(timelinePath, {
|
|
85
|
+
...history,
|
|
86
|
+
timeline,
|
|
87
|
+
timelineHash: hashTimeline(timeline),
|
|
88
|
+
previousTimelineHash: history.timelineHash,
|
|
89
|
+
undo: [...history.undo, history.timeline].slice(-history.limit),
|
|
90
|
+
redo,
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function getTimelineFileHistory(timelinePath) {
|
|
96
|
+
return withHistoryLock(timelinePath, () => describeHistory(loadHistory(timelinePath)));
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function resetTimelineFileHistory(timelinePath, { limit = DEFAULT_LIMIT } = {}) {
|
|
100
|
+
validateLimit(limit);
|
|
101
|
+
return withHistoryLock(timelinePath, () => {
|
|
102
|
+
const { timeline } = readTimeline(timelinePath);
|
|
103
|
+
return commitHistory(timelinePath, {
|
|
104
|
+
schemaVersion: 1,
|
|
105
|
+
limit,
|
|
106
|
+
timeline,
|
|
107
|
+
timelineHash: hashTimeline(timeline),
|
|
108
|
+
previousTimelineHash: null,
|
|
109
|
+
undo: [],
|
|
110
|
+
redo: [],
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function loadHistory(timelinePath, requestedLimit, { createIfMissing = false } = {}) {
|
|
116
|
+
if (requestedLimit !== undefined) validateLimit(requestedLimit);
|
|
117
|
+
const { path, timeline } = readTimeline(timelinePath);
|
|
118
|
+
const historyPath = getHistoryPath(path);
|
|
119
|
+
if (!existsSync(historyPath)) {
|
|
120
|
+
const state = {
|
|
121
|
+
schemaVersion: 1,
|
|
122
|
+
limit: requestedLimit ?? DEFAULT_LIMIT,
|
|
123
|
+
timeline,
|
|
124
|
+
timelineHash: hashTimeline(timeline),
|
|
125
|
+
previousTimelineHash: null,
|
|
126
|
+
undo: [],
|
|
127
|
+
redo: [],
|
|
128
|
+
};
|
|
129
|
+
if (createIfMissing) writeHistoryFile(historyPath, state);
|
|
130
|
+
return state;
|
|
131
|
+
}
|
|
132
|
+
if (lstatSync(historyPath).isSymbolicLink()) throw new TypeError('Timeline history file cannot be a symbolic link.');
|
|
133
|
+
if (statSync(historyPath).size > MAX_HISTORY_BYTES) throw new RangeError(`Timeline history exceeds the ${MAX_HISTORY_BYTES / 1024 / 1024} MiB limit.`);
|
|
134
|
+
const state = JSON.parse(readFileSync(historyPath, 'utf8'));
|
|
135
|
+
validateHistory(state);
|
|
136
|
+
if (requestedLimit !== undefined && requestedLimit !== state.limit) {
|
|
137
|
+
throw new TypeError(`Timeline history already uses limit ${state.limit}; reset it to change the limit.`);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const diskHash = hashTimeline(timeline);
|
|
141
|
+
if (diskHash === state.timelineHash) return state;
|
|
142
|
+
if (state.previousTimelineHash !== null && diskHash === state.previousTimelineHash) {
|
|
143
|
+
writeTimelineFile(path, state.timeline);
|
|
144
|
+
return state;
|
|
145
|
+
}
|
|
146
|
+
throw new Error(`Timeline changed outside its history session: ${path}. Use timeline history reset to start a new session.`);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function readTimeline(timelinePath) {
|
|
150
|
+
const path = resolve(timelinePath);
|
|
151
|
+
if (!existsSync(path)) throw new Error(`Timeline file not found: ${path}`);
|
|
152
|
+
if (lstatSync(path).isSymbolicLink()) throw new TypeError('Timeline file cannot be a symbolic link when using persistent history.');
|
|
153
|
+
const timeline = JSON.parse(readFileSync(path, 'utf8'));
|
|
154
|
+
validateTimeline(timeline);
|
|
155
|
+
return { path, timeline };
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function commitHistory(timelinePath, state) {
|
|
159
|
+
const { path } = readTimeline(timelinePath);
|
|
160
|
+
validateHistory(state);
|
|
161
|
+
writeHistoryFile(getHistoryPath(path), state);
|
|
162
|
+
writeTimelineFile(path, state.timeline);
|
|
163
|
+
return describeHistory(state);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function withHistoryLock(timelinePath, operation) {
|
|
167
|
+
const timeline = resolve(timelinePath);
|
|
168
|
+
const lockPath = join(dirname(timeline), `.${basename(timeline)}.history.lock`);
|
|
169
|
+
let descriptor;
|
|
170
|
+
try {
|
|
171
|
+
descriptor = openSync(lockPath, 'wx');
|
|
172
|
+
} catch (error) {
|
|
173
|
+
if (error.code === 'EEXIST' && removeStaleHistoryLock(lockPath)) descriptor = openSync(lockPath, 'wx');
|
|
174
|
+
else if (error.code === 'EEXIST') throw new Error(`Timeline history is locked by another operation: ${lockPath}`);
|
|
175
|
+
else throw error;
|
|
176
|
+
}
|
|
177
|
+
try {
|
|
178
|
+
writeFileSync(descriptor, JSON.stringify({ pid: process.pid, createdAt: Date.now() }));
|
|
179
|
+
return operation();
|
|
180
|
+
} finally {
|
|
181
|
+
closeSync(descriptor);
|
|
182
|
+
unlinkSync(lockPath);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function removeStaleHistoryLock(lockPath) {
|
|
187
|
+
try {
|
|
188
|
+
if (lstatSync(lockPath).isSymbolicLink()) throw new TypeError('Timeline history lock cannot be a symbolic link.');
|
|
189
|
+
const lock = JSON.parse(readFileSync(lockPath, 'utf8'));
|
|
190
|
+
if (!Number.isSafeInteger(lock.pid) || lock.pid <= 0) {
|
|
191
|
+
if (Date.now() - statSync(lockPath).mtimeMs < 30_000) return false;
|
|
192
|
+
unlinkSync(lockPath);
|
|
193
|
+
return true;
|
|
194
|
+
}
|
|
195
|
+
try {
|
|
196
|
+
process.kill(lock.pid, 0);
|
|
197
|
+
return false;
|
|
198
|
+
} catch (error) {
|
|
199
|
+
if (error.code !== 'ESRCH') return false;
|
|
200
|
+
}
|
|
201
|
+
unlinkSync(lockPath);
|
|
202
|
+
return true;
|
|
203
|
+
} catch (error) {
|
|
204
|
+
if (error.code === 'ENOENT') return true;
|
|
205
|
+
if (error instanceof SyntaxError && Date.now() - statSync(lockPath).mtimeMs >= 30_000) {
|
|
206
|
+
unlinkSync(lockPath);
|
|
207
|
+
return true;
|
|
208
|
+
}
|
|
209
|
+
return false;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function validateHistory(state) {
|
|
214
|
+
if (!state || state.schemaVersion !== 1 || !Number.isSafeInteger(state.limit)
|
|
215
|
+
|| state.limit < 1 || state.limit > MAX_LIMIT || !Array.isArray(state.undo)
|
|
216
|
+
|| !Array.isArray(state.redo) || state.undo.length > state.limit
|
|
217
|
+
|| state.redo.length > state.limit || !/^[\da-f]{64}$/.test(state.timelineHash ?? '')
|
|
218
|
+
|| (state.previousTimelineHash !== null && !/^[\da-f]{64}$/.test(state.previousTimelineHash ?? ''))) {
|
|
219
|
+
throw new TypeError('Persistent timeline history has an invalid schema.');
|
|
220
|
+
}
|
|
221
|
+
validateTimeline(state.timeline);
|
|
222
|
+
for (const snapshot of [...state.undo, ...state.redo]) validateTimeline(snapshot);
|
|
223
|
+
if (hashTimeline(state.timeline) !== state.timelineHash) throw new TypeError('Persistent timeline history snapshot does not match its fingerprint.');
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function validateLimit(limit) {
|
|
227
|
+
if (!Number.isSafeInteger(limit) || limit < 1 || limit > MAX_LIMIT) {
|
|
228
|
+
throw new TypeError(`Timeline history limit must be an integer from 1 to ${MAX_LIMIT}.`);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function describeHistory(state) {
|
|
233
|
+
return {
|
|
234
|
+
schemaVersion: 1,
|
|
235
|
+
canUndo: state.undo.length > 0,
|
|
236
|
+
canRedo: state.redo.length > 0,
|
|
237
|
+
undoDepth: state.undo.length,
|
|
238
|
+
redoDepth: state.redo.length,
|
|
239
|
+
limit: state.limit,
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function writeHistoryFile(path, state) {
|
|
244
|
+
const content = `${JSON.stringify(state, null, 2)}\n`;
|
|
245
|
+
if (Buffer.byteLength(content) > MAX_HISTORY_BYTES) {
|
|
246
|
+
throw new RangeError(`Timeline history exceeds the ${MAX_HISTORY_BYTES / 1024 / 1024} MiB limit.`);
|
|
247
|
+
}
|
|
248
|
+
writeAtomically(path, content);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function writeTimelineFile(path, timeline) {
|
|
252
|
+
writeAtomically(path, `${JSON.stringify(timeline, null, 2)}\n`);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function writeAtomically(path, content) {
|
|
256
|
+
const temporaryPath = join(dirname(path), `.${basename(path)}.${randomUUID()}.tmp`);
|
|
257
|
+
try {
|
|
258
|
+
writeFileSync(temporaryPath, content, { flag: 'wx' });
|
|
259
|
+
renameSync(temporaryPath, path);
|
|
260
|
+
} finally {
|
|
261
|
+
rmSync(temporaryPath, { force: true });
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function getHistoryPath(timelinePath) {
|
|
266
|
+
return join(dirname(timelinePath), `.${basename(timelinePath)}.history.json`);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function hashTimeline(timeline) {
|
|
270
|
+
return timelineFingerprint(timeline);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function validateReviewPlan(plan) {
|
|
274
|
+
if (!plan || typeof plan !== 'object' || Array.isArray(plan)
|
|
275
|
+
|| plan.schemaVersion !== 1 || plan.reviewRequired !== true
|
|
276
|
+
|| !/^[a-z][a-z0-9-]*$/.test(plan.type ?? '')
|
|
277
|
+
|| !/^[a-f0-9]{64}$/.test(plan.baseTimelineSha256 ?? '')
|
|
278
|
+
|| !Array.isArray(plan.operations) || plan.operations.length === 0 || plan.operations.length > 2000) {
|
|
279
|
+
throw new TypeError('Review plan must be a schemaVersion 1 review-required plan with a SHA-256 timeline base and 1–2000 operations.');
|
|
280
|
+
}
|
|
281
|
+
}
|