multiplane 0.3.7 → 0.3.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,123 @@
1
+ import path from 'node:path';
2
+ import { mkdirSync, readFileSync, writeFileSync, renameSync, openSync, closeSync, unlinkSync, statSync, fsyncSync } from 'node:fs';
3
+ import { stat } from 'node:fs/promises';
4
+ import { fileDigest } from './input-records.mjs';
5
+ import { MultiplaneError } from './errors.mjs';
6
+ import { retryFileOperation } from './user-home.mjs';
7
+
8
+ export const unavailable = (reason, changes) => new MultiplaneError('resume_unavailable', undefined,
9
+ { details: { reason, ...(changes ? { changes } : {}) } });
10
+
11
+ // TODO(design): Document the checkpoint schema and resume.json directory pointer.
12
+
13
+ export function atomicJSON(file, data) {
14
+ const temporary = file + '.tmp';
15
+ const fd = openSync(temporary, 'w');
16
+ try { writeFileSync(fd, JSON.stringify(data) + '\n'); fsyncSync(fd); } finally { closeSync(fd); }
17
+ retryFileOperation(() => renameSync(temporary, file), performance.now() + 1000);
18
+ }
19
+
20
+ export function segmentDirectory(jobDir) {
21
+ try {
22
+ const { directory } = JSON.parse(readFileSync(path.join(jobDir, 'resume.json'), 'utf8'));
23
+ if (!path.isAbsolute(directory) || path.basename(directory) !== 'segments') throw Error('Invalid segment directory.');
24
+ return directory;
25
+ }
26
+ catch { return path.join(jobDir, 'segments'); }
27
+ }
28
+
29
+ export function readManifest(directory) {
30
+ try {
31
+ const value = JSON.parse(readFileSync(path.join(directory, 'manifest.json'), 'utf8'));
32
+ if (value.schema !== 'multiplane-segments/1' || !value.options || !Array.isArray(value.segments) || !value.inputs) return null;
33
+ if (typeof value.shot !== 'string' || !Number.isSafeInteger(value.first) || value.first < 0
34
+ || !Number.isSafeInteger(value.end) || value.end < value.first || value.total !== value.end - value.first
35
+ || (value.segmentFrames !== null && (!Number.isSafeInteger(value.segmentFrames) || value.segmentFrames < 1))) return null;
36
+ const starts = new Set();
37
+ for (const segment of value.segments) {
38
+ if (!Number.isSafeInteger(segment.first) || segment.first < value.first || segment.first >= value.end
39
+ || (segment.first - value.first) % value.segmentFrames !== 0
40
+ || segment.end !== Math.min(value.end, segment.first + value.segmentFrames)
41
+ || starts.has(segment.first) || typeof segment.file !== 'string' || segment.file !== path.basename(segment.file)) return null;
42
+ starts.add(segment.first);
43
+ }
44
+ return value;
45
+ } catch { return null; }
46
+ }
47
+
48
+ export function lockSegments(directory) {
49
+ mkdirSync(directory, { recursive: true });
50
+ const file = path.join(directory, 'render.lock');
51
+ for (;;) {
52
+ let fd;
53
+ try { fd = openSync(file, 'wx'); }
54
+ catch (error) {
55
+ if (error.code !== 'EEXIST') throw error;
56
+ try {
57
+ const before = statSync(file);
58
+ let owner;
59
+ try { owner = JSON.parse(readFileSync(file, 'utf8')); } catch { /* Do not steal a lock being written. */ }
60
+ let dead = false;
61
+ if (Number.isSafeInteger(owner?.pid) && owner.pid > 0) {
62
+ try { process.kill(owner.pid, 0); } catch (error) { dead = error.code === 'ESRCH'; }
63
+ }
64
+ if (dead || (!owner && Date.now() - before.mtimeMs > 10000)) {
65
+ const after = statSync(file);
66
+ if (before.ino === after.ino && before.mtimeMs === after.mtimeMs) unlinkSync(file);
67
+ continue;
68
+ }
69
+ } catch (error) { if (error.code === 'ENOENT') continue; throw error; }
70
+ throw unavailable('running');
71
+ }
72
+ try { writeFileSync(fd, JSON.stringify({ pid: process.pid })); fsyncSync(fd); } finally { closeSync(fd); }
73
+ return () => { try { unlinkSync(file); } catch (error) { if (error.code !== 'ENOENT') throw error; } };
74
+ }
75
+ }
76
+
77
+ export function manifestStore(directory, manifest) {
78
+ const save = () => atomicJSON(path.join(directory, 'manifest.json'), manifest);
79
+ return { manifest, save,
80
+ input(file, record) {
81
+ const previous = manifest.inputs[file];
82
+ if (previous && previous.sha256 !== record.sha256) throw unavailable('inputs_changed', [file]);
83
+ if (!previous) { manifest.inputs[file] = record; save(); }
84
+ },
85
+ };
86
+ }
87
+
88
+ export async function checkInputs(manifest) {
89
+ const changes = [];
90
+ for (const [file, expected] of Object.entries(manifest.inputs)) {
91
+ try { if (await fileDigest(file) !== expected.sha256) changes.push(file); }
92
+ catch { changes.push(file); }
93
+ }
94
+ if (changes.length) throw unavailable('inputs_changed', changes);
95
+ }
96
+
97
+ export function checkEnvironment(expected, actual) {
98
+ if (!expected) return;
99
+ const changes = Object.keys(actual).filter(key => key !== 'renderer' && JSON.stringify(expected[key]) !== JSON.stringify(actual[key]));
100
+ if (changes.length) throw unavailable('environment_changed', changes);
101
+ }
102
+
103
+ export async function reusableSegments(directory, manifest) {
104
+ const valid = [];
105
+ for (const segment of manifest.segments) {
106
+ if (segment.file !== path.basename(segment.file)) continue;
107
+ try {
108
+ const file = path.join(directory, segment.file);
109
+ if ((await stat(file)).size === segment.bytes && await fileDigest(file) === segment.sha256) valid.push(segment);
110
+ } catch { /* Missing or damaged segments are rendered again. */ }
111
+ }
112
+ return valid;
113
+ }
114
+
115
+ export function resumeStatus(status, directory) {
116
+ const manifest = readManifest(segmentDirectory(directory));
117
+ // TODO(design): Specify missing_manifest for legacy, missing or invalid checkpoints.
118
+ const reason = status.state === 'succeeded' ? 'succeeded' : !['failed', 'cancelled'].includes(status.state) ? 'running' : !manifest ? 'missing_manifest' : null;
119
+ return { available: reason === null, reason,
120
+ framesDone: manifest?.segments.reduce((sum, segment) => sum + segment.end - segment.first, 0)
121
+ ?? (status.state === 'succeeded' ? status.result?.data?.frames ?? status.done ?? 0 : 0),
122
+ framesTotal: manifest?.total ?? status.total ?? status.result?.data?.frames ?? 0 };
123
+ }
package/src/server.mjs CHANGED
@@ -6,6 +6,7 @@ import path from 'node:path';
6
6
  import { fileURLToPath } from 'node:url';
7
7
  import { MultiplaneError, invalidOption } from './errors.mjs';
8
8
  import { resolveThree } from './three-package.mjs';
9
+ import { recordInput } from './input-records.mjs';
9
10
 
10
11
  export const root = process.cwd();
11
12
  const packageRoot = fileURLToPath(new URL('../', import.meta.url));
@@ -71,6 +72,7 @@ export async function startServer(port = 0, root = process.cwd()) {
71
72
  target = await normalizeResourcePath(target);
72
73
  if (!await isInsideResourceRoot(resourceBase, target)) { res.writeHead(403); return res.end(); }
73
74
  const info = await stat(target);
75
+ const recorded = await recordInput(target);
74
76
  const contentType = { '.wav': 'audio/wav', '.srt': 'text/plain; charset=utf-8', '.mp4': 'video/mp4' }[path.extname(target)] ?? mime[path.extname(target)] ?? 'application/octet-stream';
75
77
  const headers = { 'Content-Type': contentType, 'Content-Length': info.size, 'Accept-Ranges': 'bytes' };
76
78
  if (req.headers.range) {
@@ -82,10 +84,12 @@ export async function startServer(port = 0, root = process.cwd()) {
82
84
  }
83
85
  res.writeHead(206, { ...headers, 'Content-Length': end - start + 1, 'Content-Range': `bytes ${start}-${end}/${info.size}` });
84
86
  if (req.method === 'HEAD') return res.end();
87
+ if (recorded) return res.end(recorded.subarray(start, end + 1));
85
88
  return createReadStream(target, { start, end }).on('error', () => res.destroy()).pipe(res);
86
89
  }
87
90
  res.writeHead(200, headers);
88
91
  if (req.method === 'HEAD') return res.end();
92
+ if (recorded) return res.end(recorded);
89
93
  createReadStream(target).on('error', () => res.destroy()).pipe(res);
90
94
  } catch (error) {
91
95
  if (error.code === 'ERR_MODULE_NOT_FOUND') { res.writeHead(503, { 'x-multiplane-error': 'three_not_installed' }); return res.end('Optional three.js is not installed.'); }