polotno-node 3.4.0 → 3.5.0
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 +12 -1
- package/dist/assets/id3-Brg-Xwuw.js +16 -0
- package/dist/assets/{index-B2B7pzVl.js → index-DPXoKCa_.js} +109 -111
- package/dist/assets/{lib-C8V7BvzS.js → lib-BEzl6SFD.js} +1 -1
- package/dist/index.html +2 -2
- package/lib/index.js +62 -10
- package/lib/instance.d.ts +21 -0
- package/lib/instance.js +198 -39
- package/lib/launch-options.d.ts +21 -0
- package/lib/launch-options.js +27 -0
- package/lib/local-media.d.ts +18 -0
- package/lib/local-media.js +169 -0
- package/lib/types.d.ts +10 -2
- package/package.json +8 -7
- package/dist/assets/id3-CBTtHQkE.js +0 -16
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import crypto from 'crypto';
|
|
4
|
+
// Serve local files to the renderer over the request-interception seam, so that
|
|
5
|
+
// no `file:` URL ever reaches the browser.
|
|
6
|
+
//
|
|
7
|
+
// A `file:` URL looks like the obvious way to hand a downloaded video to the
|
|
8
|
+
// page, and it is a trap. `chrome-headless-shell` refuses `fetch()` on that
|
|
9
|
+
// scheme whatever flags we pass (`--allow-file-access-from-files` lifts it for
|
|
10
|
+
// XHR only), so the loader that reads media bytes fails — sometimes silently,
|
|
11
|
+
// leaving a video with no sound. A browser on another host cannot see this
|
|
12
|
+
// host's disk at all. And the rule "a URL is a URL" should hold for whichever
|
|
13
|
+
// API the client happens to use, today or later.
|
|
14
|
+
//
|
|
15
|
+
// So a local file is registered here and handed to the client as an ordinary
|
|
16
|
+
// `https:` URL, which `tryRespond` answers from Node — the same interface
|
|
17
|
+
// `font-cache.ts` uses, installed on every page by `createPage`.
|
|
18
|
+
//
|
|
19
|
+
// Two properties are deliberate:
|
|
20
|
+
// * `.invalid` is reserved (RFC 6761) and never resolves, so a request that
|
|
21
|
+
// escapes interception fails instead of reaching a real host.
|
|
22
|
+
// * the URL carries an id and nothing else. Paths come from the registry, so
|
|
23
|
+
// a design cannot name a path here that it could not already read.
|
|
24
|
+
export const ORIGIN = 'https://polotno-local-media.invalid/';
|
|
25
|
+
/** id → the local path it stands for. */
|
|
26
|
+
const registry = new Map();
|
|
27
|
+
const EXTENSION_CONTENT_TYPE = {
|
|
28
|
+
'.mp4': 'video/mp4',
|
|
29
|
+
'.m4v': 'video/mp4',
|
|
30
|
+
'.mov': 'video/quicktime',
|
|
31
|
+
'.webm': 'video/webm',
|
|
32
|
+
'.mp3': 'audio/mpeg',
|
|
33
|
+
'.m4a': 'audio/mp4',
|
|
34
|
+
'.wav': 'audio/wav',
|
|
35
|
+
'.ogg': 'audio/ogg',
|
|
36
|
+
'.png': 'image/png',
|
|
37
|
+
'.jpg': 'image/jpeg',
|
|
38
|
+
'.jpeg': 'image/jpeg',
|
|
39
|
+
'.gif': 'image/gif',
|
|
40
|
+
'.webp': 'image/webp',
|
|
41
|
+
'.avif': 'image/avif',
|
|
42
|
+
'.svg': 'image/svg+xml',
|
|
43
|
+
'.woff': 'font/woff',
|
|
44
|
+
'.woff2': 'font/woff2',
|
|
45
|
+
'.ttf': 'font/ttf',
|
|
46
|
+
'.otf': 'font/otf',
|
|
47
|
+
};
|
|
48
|
+
/**
|
|
49
|
+
* Content type to serve a path with. From the extension, which is what Chromium
|
|
50
|
+
* itself read a `file:` URL as, so no design renders differently for it. A video,
|
|
51
|
+
* an audio track and a font are decoded off the declared type, so a wrong guess
|
|
52
|
+
* here is an asset that never loads.
|
|
53
|
+
*/
|
|
54
|
+
export const contentTypeForPath = (filePath) => EXTENSION_CONTENT_TYPE[path.extname(filePath).toLowerCase()] ||
|
|
55
|
+
'application/octet-stream';
|
|
56
|
+
/** Register a local file and return the URL the client should load it from. */
|
|
57
|
+
export const register = (filePath) => {
|
|
58
|
+
const id = crypto.randomUUID();
|
|
59
|
+
registry.set(id, filePath);
|
|
60
|
+
return ORIGIN + id;
|
|
61
|
+
};
|
|
62
|
+
/** Drop registrations once the render that needed them is over. */
|
|
63
|
+
export const release = (urls) => {
|
|
64
|
+
for (const url of urls) {
|
|
65
|
+
registry.delete(url.slice(ORIGIN.length));
|
|
66
|
+
}
|
|
67
|
+
};
|
|
68
|
+
// A media element opens a file with `bytes=0-` — "everything" — and a 500MB
|
|
69
|
+
// video answered literally is 500MB buffered in Node and base64'd over the
|
|
70
|
+
// devtools protocol. Answering less than was asked for is what a range request
|
|
71
|
+
// is for: the client comes back for the next slice.
|
|
72
|
+
export const MAX_SLICE = 8 * 1024 * 1024;
|
|
73
|
+
// `bytes=0-1023`, `bytes=1024-` and `bytes=-512` (the last N bytes). A request
|
|
74
|
+
// for several ranges at once is answered in full instead — Chromium never asks
|
|
75
|
+
// for one, and a `multipart/byteranges` body would be the only correct reply.
|
|
76
|
+
//
|
|
77
|
+
// `null` means "no range asked for", and 'unsatisfiable' means one was and this
|
|
78
|
+
// file cannot answer it. The two must not collapse: a reader hunting an MP4's
|
|
79
|
+
// trailing `moov` atom asks for the last N bytes, and answering a plain 200
|
|
80
|
+
// from offset 0 hands it the front of the file under a header saying otherwise.
|
|
81
|
+
const parseRange = (header, size) => {
|
|
82
|
+
const match = /^bytes=(\d*)-(\d*)$/.exec((header || '').trim());
|
|
83
|
+
if (!match || (!match[1] && !match[2]))
|
|
84
|
+
return null;
|
|
85
|
+
// A suffix range longer than the file asks for all of it.
|
|
86
|
+
const start = match[1]
|
|
87
|
+
? Number(match[1])
|
|
88
|
+
: Math.max(0, size - Number(match[2]));
|
|
89
|
+
const end = match[1] && match[2] ? Number(match[2]) : size - 1;
|
|
90
|
+
if (start >= size || end < start)
|
|
91
|
+
return 'unsatisfiable';
|
|
92
|
+
return { start, end: Math.min(end, size - 1, start + MAX_SLICE - 1) };
|
|
93
|
+
};
|
|
94
|
+
// Returns the bytes actually read, which the caller reports as the range it
|
|
95
|
+
// served. A read can come up short, and a buffer padded with the zeroes it was
|
|
96
|
+
// allocated with — described by a `content-range` claiming the full slice — is
|
|
97
|
+
// media that decodes to silence or garbage instead of failing.
|
|
98
|
+
const readSlice = async (filePath, start, end) => {
|
|
99
|
+
const handle = await fs.promises.open(filePath, 'r');
|
|
100
|
+
try {
|
|
101
|
+
const buffer = Buffer.alloc(end - start + 1);
|
|
102
|
+
let filled = 0;
|
|
103
|
+
while (filled < buffer.length) {
|
|
104
|
+
const { bytesRead } = await handle.read(buffer, filled, buffer.length - filled, start + filled);
|
|
105
|
+
if (bytesRead === 0)
|
|
106
|
+
break; // end of file
|
|
107
|
+
filled += bytesRead;
|
|
108
|
+
}
|
|
109
|
+
return buffer.subarray(0, filled);
|
|
110
|
+
}
|
|
111
|
+
finally {
|
|
112
|
+
await handle.close();
|
|
113
|
+
}
|
|
114
|
+
};
|
|
115
|
+
/**
|
|
116
|
+
* Answer a request for a registered file and return true, or return false so
|
|
117
|
+
* the caller keeps handling the request itself.
|
|
118
|
+
*/
|
|
119
|
+
export const tryRespond = (request) => {
|
|
120
|
+
const url = request.url();
|
|
121
|
+
if (!url.startsWith(ORIGIN))
|
|
122
|
+
return false;
|
|
123
|
+
const filePath = registry.get(url.slice(ORIGIN.length));
|
|
124
|
+
const respond = async () => {
|
|
125
|
+
if (!filePath) {
|
|
126
|
+
await request.respond({ status: 404, body: 'Unknown media id' });
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
const { size } = await fs.promises.stat(filePath);
|
|
130
|
+
const range = parseRange(request.headers()['range'], size);
|
|
131
|
+
if (range === 'unsatisfiable') {
|
|
132
|
+
await request.respond({
|
|
133
|
+
status: 416,
|
|
134
|
+
headers: { 'content-range': `bytes */${size}` },
|
|
135
|
+
body: 'Range not satisfiable',
|
|
136
|
+
});
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
const { start, end } = range || { start: 0, end: size - 1 };
|
|
140
|
+
const body = await readSlice(filePath, start, end);
|
|
141
|
+
// A media element streams: it asks for a range, gets a 206, and comes back
|
|
142
|
+
// for the next one. A plain `fetch()` takes the whole body in one response,
|
|
143
|
+
// which is what the `file:` path did as well.
|
|
144
|
+
await request.respond({
|
|
145
|
+
status: range ? 206 : 200,
|
|
146
|
+
contentType: contentTypeForPath(filePath),
|
|
147
|
+
headers: {
|
|
148
|
+
'accept-ranges': 'bytes',
|
|
149
|
+
// The client page is a `file:` origin, so every read of this URL is
|
|
150
|
+
// cross-origin. Our launch args carry `--disable-web-security`, which
|
|
151
|
+
// would hide a missing header from us but not from a caller who brings
|
|
152
|
+
// their own browser.
|
|
153
|
+
'access-control-allow-origin': '*',
|
|
154
|
+
...(range
|
|
155
|
+
? {
|
|
156
|
+
'content-range': `bytes ${start}-${start + body.length - 1}/${size}`,
|
|
157
|
+
}
|
|
158
|
+
: {}),
|
|
159
|
+
},
|
|
160
|
+
body,
|
|
161
|
+
});
|
|
162
|
+
};
|
|
163
|
+
respond().catch(() => {
|
|
164
|
+
// The page may be gone, or the file unreadable. Fail the request rather
|
|
165
|
+
// than leave it hanging until the asset timeout.
|
|
166
|
+
request.abort('failed').catch(() => { });
|
|
167
|
+
});
|
|
168
|
+
return true;
|
|
169
|
+
};
|
package/lib/types.d.ts
CHANGED
|
@@ -33,8 +33,10 @@ export interface VideoRenderOptions extends RenderOptions {
|
|
|
33
33
|
onProgress?: (progress: number, frameTime?: number) => void;
|
|
34
34
|
/**
|
|
35
35
|
* When using the default local client (no `createInstance({ url })`), polotno-node will
|
|
36
|
-
* pre-download remote video/audio sources into a temp folder and
|
|
37
|
-
*
|
|
36
|
+
* pre-download remote video/audio sources into a temp folder and serve them to the
|
|
37
|
+
* renderer from there, for more reliable and faster loading. Set `skipDownloads: true`
|
|
38
|
+
* to disable that and let the browser load the original URLs itself. Local (`file:`)
|
|
39
|
+
* sources are always served, whatever this is set to.
|
|
38
40
|
*/
|
|
39
41
|
skipDownloads?: boolean;
|
|
40
42
|
/**
|
|
@@ -74,6 +76,12 @@ export interface InstanceOptions {
|
|
|
74
76
|
* google-chrome-stable, say. Ignored when you pass your own `browser`.
|
|
75
77
|
*/
|
|
76
78
|
executablePath?: string;
|
|
79
|
+
/**
|
|
80
|
+
* Which headless mode to launch. Defaults to puppeteer's `chrome-headless-shell`
|
|
81
|
+
* wherever we resolve the browser ourselves; `true` forces full Chrome.
|
|
82
|
+
* Ignored when you pass your own `browser`.
|
|
83
|
+
*/
|
|
84
|
+
headless?: boolean | 'shell';
|
|
77
85
|
/** Intercept every page request (e.g. to block or rewrite asset loads). */
|
|
78
86
|
requestInterceptor?: (request: any) => void;
|
|
79
87
|
/** Cache Google Fonts responses for the process lifetime and serve them to every page (default: true) */
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "polotno-node",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.5.0",
|
|
4
4
|
"description": "Polotno workflow from NodeJS",
|
|
5
5
|
"license": "SEE LICENSE IN LICENSE.md",
|
|
6
6
|
"type": "module",
|
|
@@ -36,12 +36,12 @@
|
|
|
36
36
|
],
|
|
37
37
|
"devDependencies": {
|
|
38
38
|
"@types/node": "^26.1.2",
|
|
39
|
-
"mediabunny": "^1.52.3",
|
|
40
39
|
"axios": "^1.19.0",
|
|
41
40
|
"dotenv": "^17.4.2",
|
|
42
41
|
"fluent-ffmpeg": "^2.1.3",
|
|
43
42
|
"jspdf": "^4.2.1",
|
|
44
|
-
"konva": "^10.3.
|
|
43
|
+
"konva": "^10.3.1",
|
|
44
|
+
"mediabunny": "^1.52.3",
|
|
45
45
|
"pixelmatch": "^7.2.0",
|
|
46
46
|
"pngjs": "^7.0.0",
|
|
47
47
|
"react": "^19.2.8",
|
|
@@ -51,8 +51,9 @@
|
|
|
51
51
|
"vite": "^8.2.1",
|
|
52
52
|
"vitest": "^4.1.10",
|
|
53
53
|
"web-audio-api": "^1.5.3",
|
|
54
|
-
"
|
|
55
|
-
"polotno": "
|
|
54
|
+
"polotno": "4.9.0",
|
|
55
|
+
"@polotno/design-fixtures": "0.0.0",
|
|
56
|
+
"@polotno/video-export": "0.2.3"
|
|
56
57
|
},
|
|
57
58
|
"overrides": {
|
|
58
59
|
"@polotno/video-export": {
|
|
@@ -75,9 +76,9 @@
|
|
|
75
76
|
"scripts": {
|
|
76
77
|
"build": "rm -rf ./lib ./dist && tsc -p tsconfig.json && vite build ./client/ --base=./ --outDir ../dist --emptyOutDir --logLevel error",
|
|
77
78
|
"typecheck": "tsc --noEmit -p tsconfig.json",
|
|
78
|
-
"test": "pnpm exec turbo run build --filter=polotno-node --output-logs=new-only --log-order=stream && vitest run test/unit test/static
|
|
79
|
+
"test": "if [ -z \"$TURBO_HASH\" ]; then pnpm exec turbo run build --filter=polotno-node --output-logs=new-only --log-order=stream; fi && vitest run test/unit test/static",
|
|
79
80
|
"test:video": "pnpm exec turbo run build --filter=polotno-node && vitest run test/video/video.test.mjs",
|
|
80
|
-
"test:all": "pnpm exec turbo run build --filter=polotno-node && vitest run test/unit test/static
|
|
81
|
+
"test:all": "pnpm exec turbo run build --filter=polotno-node && vitest run test/unit test/static test/video",
|
|
81
82
|
"dev": "vite ./client.html"
|
|
82
83
|
}
|
|
83
84
|
}
|