@martini-film/cli 0.4.0 → 0.4.2
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 +15 -11
- package/dist/api.js +15 -7
- package/dist/api.js.map +1 -1
- package/dist/approve.js +14 -0
- package/dist/approve.js.map +1 -1
- package/dist/cli.js +78 -12
- package/dist/cli.js.map +1 -1
- package/dist/commands/assets.js +98 -60
- package/dist/commands/assets.js.map +1 -1
- package/dist/commands/call.js +56 -24
- package/dist/commands/call.js.map +1 -1
- package/dist/commands/captions.js +111 -0
- package/dist/commands/captions.js.map +1 -0
- package/dist/commands/context.js +3 -7
- package/dist/commands/context.js.map +1 -1
- package/dist/commands/files.js +19 -4
- package/dist/commands/files.js.map +1 -1
- package/dist/commands/graphics.js +1592 -0
- package/dist/commands/graphics.js.map +1 -0
- package/dist/commands/guide.js +12 -8
- package/dist/commands/guide.js.map +1 -1
- package/dist/commands/init.js +24 -7
- package/dist/commands/init.js.map +1 -1
- package/dist/commands/login.js +34 -10
- package/dist/commands/login.js.map +1 -1
- package/dist/commands/tools.js +27 -4
- package/dist/commands/tools.js.map +1 -1
- package/dist/commands/use.js +6 -1
- package/dist/commands/use.js.map +1 -1
- package/dist/config.js +6 -1
- package/dist/config.js.map +1 -1
- package/dist/desktop.js +49 -0
- package/dist/desktop.js.map +1 -0
- package/dist/generated/guide.json +8 -5
- package/dist/generated/manifest.json +14650 -9376
- package/dist/graphics/captionsState.js +24 -0
- package/dist/graphics/captionsState.js.map +1 -0
- package/dist/graphics/compress.js +103 -0
- package/dist/graphics/compress.js.map +1 -0
- package/dist/graphics/limits.js +67 -0
- package/dist/graphics/limits.js.map +1 -0
- package/dist/graphics/manifest.js +273 -0
- package/dist/graphics/manifest.js.map +1 -0
- package/dist/graphics/project.js +626 -0
- package/dist/graphics/project.js.map +1 -0
- package/dist/graphics/tar.js +152 -0
- package/dist/graphics/tar.js.map +1 -0
- package/dist/help.js +24 -7
- package/dist/help.js.map +1 -1
- package/dist/manifest.js +2 -1
- package/dist/manifest.js.map +1 -1
- package/dist/output.js +86 -9
- package/dist/output.js.map +1 -1
- package/dist/schema.js +184 -4
- package/dist/schema.js.map +1 -1
- package/dist/wait.js +2 -1
- package/dist/wait.js.map +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,626 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { existsSync, readdirSync, readFileSync, realpathSync, statSync } from 'node:fs';
|
|
3
|
+
import { isAbsolute, join, posix, relative, resolve, sep } from 'node:path';
|
|
4
|
+
import { ARTIFACT_MAX_DURATION_SECONDS, ARTIFACT_MIN_DURATION_SECONDS, encodedBundleBytes, extensionOf, formatMB, isSafeHyperframesPath, MAX_ASSET_REFS, MAX_BUNDLE_BYTES, MAX_BUNDLE_FILE_BYTES, MAX_DIMENSION, MAX_DURATION_SECONDS, MAX_MEDIA_BYTES, MAX_PATH_LENGTH, MAX_PROJECT_FILES, MAX_TEXT_FILE_BYTES, MIN_DIMENSION, MIN_DURATION_SECONDS, mediaTypeOf, PROJECT_FONT_EXTENSIONS, PROJECT_TEXT_EXTENSIONS, UPLOAD_CAP, } from './limits.js';
|
|
5
|
+
import { GRAPHIC_MANIFEST_FILE, issue, matchesAny, } from './manifest.js';
|
|
6
|
+
const SKIPPED_NAMES = new Set(['node_modules', 'venv', '__pycache__', '__MACOSX']);
|
|
7
|
+
function toPosix(path) {
|
|
8
|
+
return path.split(sep).join('/');
|
|
9
|
+
}
|
|
10
|
+
function inside(parent, child) {
|
|
11
|
+
const rel = relative(parent, child);
|
|
12
|
+
return rel === '' || (!rel.startsWith('..') && !isAbsolute(rel));
|
|
13
|
+
}
|
|
14
|
+
export function recipeDirsInside(dir, manifest) {
|
|
15
|
+
const root = resolve(dir);
|
|
16
|
+
const dirs = [];
|
|
17
|
+
for (const media of manifest?.media ?? []) {
|
|
18
|
+
if (!media.recipe)
|
|
19
|
+
continue;
|
|
20
|
+
const abs = resolve(root, media.recipe.dir);
|
|
21
|
+
if (abs !== root && inside(root, abs))
|
|
22
|
+
dirs.push(toPosix(relative(root, abs)));
|
|
23
|
+
}
|
|
24
|
+
return dirs;
|
|
25
|
+
}
|
|
26
|
+
export function walkProject(dir, manifest) {
|
|
27
|
+
const root = realpathSync(resolve(dir));
|
|
28
|
+
const recipeDirs = recipeDirsInside(root, manifest);
|
|
29
|
+
const files = [];
|
|
30
|
+
const warnings = [];
|
|
31
|
+
const visited = new Set();
|
|
32
|
+
const visit = (abs, rel) => {
|
|
33
|
+
const real = realpathSync(abs);
|
|
34
|
+
if (visited.has(real))
|
|
35
|
+
return;
|
|
36
|
+
visited.add(real);
|
|
37
|
+
for (const entry of readdirSync(abs, { withFileTypes: true })) {
|
|
38
|
+
const name = entry.name;
|
|
39
|
+
const path = rel ? `${rel}/${name}` : name;
|
|
40
|
+
if (name.startsWith('.') || SKIPPED_NAMES.has(name) || /\.pyc$/i.test(name))
|
|
41
|
+
continue;
|
|
42
|
+
if (/\.(md|txt)$/i.test(name))
|
|
43
|
+
continue;
|
|
44
|
+
if (path === GRAPHIC_MANIFEST_FILE)
|
|
45
|
+
continue;
|
|
46
|
+
if (matchesAny(path, manifest?.exclude))
|
|
47
|
+
continue;
|
|
48
|
+
if (recipeDirs.some(recipe => path === recipe || path.startsWith(`${recipe}/`)))
|
|
49
|
+
continue;
|
|
50
|
+
const full = join(abs, name);
|
|
51
|
+
let isDir = entry.isDirectory();
|
|
52
|
+
let isFile = entry.isFile();
|
|
53
|
+
if (entry.isSymbolicLink()) {
|
|
54
|
+
let target;
|
|
55
|
+
try {
|
|
56
|
+
target = realpathSync(full);
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
warnings.push(issue('SYMLINK_SKIPPED', `${path} is a broken symlink.`, 'Remove it or point it at a file in the project.', path, 'warning'));
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
if (!inside(root, target)) {
|
|
63
|
+
warnings.push(issue('SYMLINK_SKIPPED', `${path} links outside the project and was not sent.`, 'Copy the file into the project instead of linking it.', path, 'warning'));
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
const stats = statSync(target);
|
|
67
|
+
isDir = stats.isDirectory();
|
|
68
|
+
isFile = stats.isFile();
|
|
69
|
+
}
|
|
70
|
+
if (isDir)
|
|
71
|
+
visit(full, path);
|
|
72
|
+
else if (isFile)
|
|
73
|
+
files.push({ path, abs: full, bytes: statSync(full).size });
|
|
74
|
+
}
|
|
75
|
+
};
|
|
76
|
+
visit(root, '');
|
|
77
|
+
files.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
|
|
78
|
+
return { files, warnings };
|
|
79
|
+
}
|
|
80
|
+
const HYPERFRAMES_MARKER = /data-composition-id\s*=|data-root\s*=\s*["']?true|data-clock\s*=/i;
|
|
81
|
+
const WINDOW_FUNCTION = /\bwindow\.(?!on[a-z])[A-Za-z_$][\w$]*\s*=\s*(?:async\s+)?(?:function\b|\([^)]*\)\s*=>|[A-Za-z_$][\w$]*\s*=>)/;
|
|
82
|
+
const CLOCK_ROOT_FIX = 'For a page that draws each frame from its own seek function, put data-composition-id, data-root="true", data-width/height/duration, data-clock="<fn>" (and optionally data-clock-ready) and data-no-timeline on the root element.';
|
|
83
|
+
export function detectDoor(dir, flag, manifest) {
|
|
84
|
+
if (flag)
|
|
85
|
+
return { door: flag, reason: '--door' };
|
|
86
|
+
if (manifest?.door)
|
|
87
|
+
return { door: manifest.door, reason: GRAPHIC_MANIFEST_FILE };
|
|
88
|
+
const index = join(dir, 'index.html');
|
|
89
|
+
if (existsSync(index)) {
|
|
90
|
+
const html = readFileSync(index, 'utf8');
|
|
91
|
+
if (HYPERFRAMES_MARKER.test(html))
|
|
92
|
+
return { door: 'hyperframes', reason: 'index.html has a composition root' };
|
|
93
|
+
if (WINDOW_FUNCTION.test(html))
|
|
94
|
+
return {
|
|
95
|
+
door: 'artifact',
|
|
96
|
+
reason: 'index.html has no composition root',
|
|
97
|
+
warning: issue('SEEK_FUNCTION_WITHOUT_ROOT', 'index.html defines a window function but no composition root, so it is pushed as a web page artifact on a virtual clock; nothing calls a seek function there.', `To render it frame by frame from its seek function, add the clock root (see the martini-graphics skill) and push again, or pass --door hyperframes. ${CLOCK_ROOT_FIX}`, 'index.html', 'warning'),
|
|
98
|
+
};
|
|
99
|
+
return { door: 'artifact', reason: 'index.html has no composition root' };
|
|
100
|
+
}
|
|
101
|
+
if (existsSync(join(dir, 'App.jsx')) || existsSync(join(dir, 'App.tsx')))
|
|
102
|
+
return { door: 'artifact', reason: 'App.jsx/App.tsx' };
|
|
103
|
+
return {
|
|
104
|
+
error: issue('NO_ENTRY', 'The directory has no index.html, App.jsx or App.tsx at its root.', 'Run the push from the Graphic project directory (index.html at its root), or pass that directory.'),
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
function openingTags(html) {
|
|
108
|
+
return tokenizeHtml(html).map(element => element.tag);
|
|
109
|
+
}
|
|
110
|
+
function rootTag(html) {
|
|
111
|
+
const tags = openingTags(html);
|
|
112
|
+
return (tags.find(tag => /\sdata-root\s*=\s*["']?true\b/i.test(tag)) ??
|
|
113
|
+
tags.find(tag => /\sdata-composition-id\s*=/i.test(tag)));
|
|
114
|
+
}
|
|
115
|
+
function attribute(tag, name) {
|
|
116
|
+
const match = new RegExp(`\\s${name}\\s*=\\s*(?:"([^"]*)"|'([^']*)'|([^\\s"'>]+))`, 'i').exec(tag);
|
|
117
|
+
return match ? (match[1] ?? match[2] ?? match[3]) : undefined;
|
|
118
|
+
}
|
|
119
|
+
function hasAttribute(tag, name) {
|
|
120
|
+
return new RegExp(`\\s${name}(?=\\s*=|[\\s/>])`, 'i').test(tag);
|
|
121
|
+
}
|
|
122
|
+
const VOID_TAGS = new Set('area base br col embed hr img input link meta param source track wbr'.split(' '));
|
|
123
|
+
const RAW_TEXT_TAGS = new Set(['script', 'style', 'textarea', 'title']);
|
|
124
|
+
function tokenizeHtml(html) {
|
|
125
|
+
const elements = [];
|
|
126
|
+
const stack = [];
|
|
127
|
+
const tags = /<!--[\s\S]*?(?:-->|$)|<![^>]*>|<\/?([a-zA-Z][\w:-]*)(?=[\s/>])(?:"[^"]*"|'[^']*'|[^'">])*>/g;
|
|
128
|
+
let match;
|
|
129
|
+
while ((match = tags.exec(html))) {
|
|
130
|
+
if (!match[1])
|
|
131
|
+
continue;
|
|
132
|
+
const tag = match[0];
|
|
133
|
+
const name = match[1].toLowerCase();
|
|
134
|
+
if (tag.startsWith('</')) {
|
|
135
|
+
const top = stack.at(-1);
|
|
136
|
+
if (top?.name === name) {
|
|
137
|
+
top.closed = true;
|
|
138
|
+
top.contentEnd = match.index;
|
|
139
|
+
top.end = tags.lastIndex;
|
|
140
|
+
stack.pop();
|
|
141
|
+
}
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
const parent = stack.at(-1);
|
|
145
|
+
const foreign = name === 'svg' || name === 'math' || (parent?.foreign === true && parent.name !== 'foreignobject');
|
|
146
|
+
const element = {
|
|
147
|
+
tag,
|
|
148
|
+
name,
|
|
149
|
+
start: match.index,
|
|
150
|
+
end: tags.lastIndex,
|
|
151
|
+
contentEnd: tags.lastIndex,
|
|
152
|
+
closed: VOID_TAGS.has(name) || (foreign && /\/\s*>$/.test(tag)),
|
|
153
|
+
parent,
|
|
154
|
+
foreign,
|
|
155
|
+
};
|
|
156
|
+
elements.push(element);
|
|
157
|
+
if (VOID_TAGS.has(name) || (foreign && /\/\s*>$/.test(tag)))
|
|
158
|
+
continue;
|
|
159
|
+
if (RAW_TEXT_TAGS.has(name)) {
|
|
160
|
+
const close = new RegExp(`</${name}(?=[\\t\\n\\f\\r />])(?:"[^"]*"|'[^']*'|[^'">])*>`, 'gi');
|
|
161
|
+
close.lastIndex = tags.lastIndex;
|
|
162
|
+
const closing = close.exec(html);
|
|
163
|
+
element.closed = closing !== null;
|
|
164
|
+
element.contentEnd = closing?.index ?? html.length;
|
|
165
|
+
element.end = closing ? close.lastIndex : html.length;
|
|
166
|
+
tags.lastIndex = element.end;
|
|
167
|
+
}
|
|
168
|
+
else
|
|
169
|
+
stack.push(element);
|
|
170
|
+
}
|
|
171
|
+
return elements;
|
|
172
|
+
}
|
|
173
|
+
function entryChildren(html) {
|
|
174
|
+
const elements = tokenizeHtml(html);
|
|
175
|
+
const rootSpan = elements.find(element => attribute(element.tag, 'data-root') === 'true') ??
|
|
176
|
+
elements.find(element => hasAttribute(element.tag, 'data-composition-id'));
|
|
177
|
+
for (const element of elements) {
|
|
178
|
+
if (element.closed)
|
|
179
|
+
continue;
|
|
180
|
+
for (let ancestor = element; ancestor; ancestor = ancestor.parent) {
|
|
181
|
+
if (ancestor === rootSpan)
|
|
182
|
+
throw new Error('SPLIT_EXPLICIT_CLOSING_TAGS: Add explicit closing tags to the composition root and every non-void element inside it before splitting.');
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
const children = rootSpan ? elements.filter(element => element.parent === rootSpan) : [];
|
|
186
|
+
const pieces = children.filter(element => element.name !== 'script' && hasAttribute(element.tag, 'data-start'));
|
|
187
|
+
const allScripts = elements
|
|
188
|
+
.filter(element => element.name === 'script')
|
|
189
|
+
.map(element => ({
|
|
190
|
+
tag: html.slice(element.start, element.end),
|
|
191
|
+
start: element.start,
|
|
192
|
+
end: element.end,
|
|
193
|
+
}));
|
|
194
|
+
const scripts = children
|
|
195
|
+
.filter(element => element.name === 'script')
|
|
196
|
+
.map(element => html.slice(element.start, element.end));
|
|
197
|
+
return { root: rootSpan?.tag, rootSpan, tags: pieces.map(piece => piece.tag), pieces, scripts, allScripts };
|
|
198
|
+
}
|
|
199
|
+
export function readEntryPieces(indexHtml) {
|
|
200
|
+
const { root, tags } = entryChildren(indexHtml);
|
|
201
|
+
const rootDuration = Number(root === undefined ? NaN : attribute(root, 'data-duration'));
|
|
202
|
+
return tags.map(tag => ({
|
|
203
|
+
...(attribute(tag, 'id') !== undefined ? { id: attribute(tag, 'id') } : {}),
|
|
204
|
+
start: Number(attribute(tag, 'data-start')),
|
|
205
|
+
duration: Number(attribute(tag, 'data-duration') ?? rootDuration - Number(attribute(tag, 'data-start'))),
|
|
206
|
+
...(attribute(tag, 'data-composition-src') !== undefined
|
|
207
|
+
? {
|
|
208
|
+
src: attribute(tag, 'data-composition-src'),
|
|
209
|
+
path: normalizeProjectPath(attribute(tag, 'data-composition-src')),
|
|
210
|
+
}
|
|
211
|
+
: {}),
|
|
212
|
+
kind: hasAttribute(tag, 'data-composition-src') ? 'sub-composition' : 'other',
|
|
213
|
+
tag,
|
|
214
|
+
}));
|
|
215
|
+
}
|
|
216
|
+
export function wrapPieceAsEntry(indexHtml, piece, rootDuration, files = []) {
|
|
217
|
+
const head = tokenizeHtml(indexHtml).find(element => element.name === 'head');
|
|
218
|
+
if (!head)
|
|
219
|
+
throw new Error('SPLIT_NO_HEAD: Add a head element containing the shared styles and GSAP library.');
|
|
220
|
+
const { root, rootSpan, pieces, scripts, allScripts } = entryChildren(indexHtml);
|
|
221
|
+
if (!root || !rootSpan || hasAttribute(root, 'data-clock'))
|
|
222
|
+
throw new Error('SPLIT_NEEDS_GSAP_DOOR: Split requires a HyperFrames root without data-clock.');
|
|
223
|
+
const warnings = [];
|
|
224
|
+
const keptScripts = [];
|
|
225
|
+
const registrations = [];
|
|
226
|
+
for (const script of allScripts) {
|
|
227
|
+
const src = attribute(script.tag, 'src');
|
|
228
|
+
const path = src && !/^(?:[a-z][a-z\d+.-]*:|\/\/)/i.test(src) ? normalizeProjectPath(src) : undefined;
|
|
229
|
+
const file = path ? files.find(file => file.path === path) : undefined;
|
|
230
|
+
const text = file ? readFileSync(file.abs, 'utf8') : scriptTexts('index.html', script.tag).join('\n');
|
|
231
|
+
if (/__timelines/.test(text)) {
|
|
232
|
+
warnings.push(`SPLIT_TIMELINE_SCRIPT_DROPPED: ${path ?? 'inline'} registers the root timeline; the wrapper registers its own.`);
|
|
233
|
+
registrations.push(script);
|
|
234
|
+
}
|
|
235
|
+
else
|
|
236
|
+
keptScripts.push(script.tag);
|
|
237
|
+
}
|
|
238
|
+
const remainingRootScripts = scripts.filter(script => keptScripts.includes(script));
|
|
239
|
+
if (piece.duration > MAX_DURATION_SECONDS)
|
|
240
|
+
throw new Error(`SPLIT_PIECE_TOO_LONG: ${piece.id} is ${piece.duration} s; a Graphic holds at most 90 s`);
|
|
241
|
+
const declaredDuration = Number(attribute(root, 'data-duration'));
|
|
242
|
+
if (!Number.isFinite(piece.start) ||
|
|
243
|
+
piece.start < 0 ||
|
|
244
|
+
!Number.isFinite(piece.duration) ||
|
|
245
|
+
piece.duration < MIN_DURATION_SECONDS ||
|
|
246
|
+
piece.duration > MAX_DURATION_SECONDS ||
|
|
247
|
+
(declaredDuration <= MAX_DURATION_SECONDS &&
|
|
248
|
+
piece.start + piece.duration > Math.min(rootDuration, declaredDuration)))
|
|
249
|
+
throw new Error('SPLIT_INVALID_TIMING: Give each piece a finite non-negative start and positive duration within the root duration.');
|
|
250
|
+
if (remainingRootScripts.some(script => !hasAttribute(script, 'src') && !scriptTexts('index.html', script).some(text => /\bgsap\b/.test(text))))
|
|
251
|
+
throw new Error('SPLIT_ROOT_SCRIPTS: Move root inline scripts into the sub-compositions; split drops root scripts and replaces the timeline registration.');
|
|
252
|
+
const opening = /\sdata-duration\s*=\s*(?:"[^"]*"|'[^']*'|[^\s"'>]+)/i.test(root)
|
|
253
|
+
? root.replace(/(\sdata-duration\s*=\s*)(?:"[^"]*"|'[^']*'|[^\s"'>]+)/i, `$1"${piece.duration}"`)
|
|
254
|
+
: root.replace(/>$/, ` data-duration="${piece.duration}">`);
|
|
255
|
+
const gsap = keptScripts.some(tag => /\bgsap\b/i.test(attribute(tag, 'src') ?? scriptTexts('index.html', tag).join('\n')))
|
|
256
|
+
? ''
|
|
257
|
+
: '<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>';
|
|
258
|
+
const registration = `<script>document.addEventListener('DOMContentLoaded', () => { window.__timelines = window.__timelines || {}; window.__timelines[${JSON.stringify(attribute(root, 'data-composition-id') ?? 'main').replace(/</g, '\\u003c')}] = gsap.timeline({ paused: true }).to({}, { duration: ${piece.duration} }); });</script>`;
|
|
259
|
+
const edits = [
|
|
260
|
+
{ start: rootSpan.start, end: rootSpan.start + root.length, text: opening },
|
|
261
|
+
];
|
|
262
|
+
for (const child of pieces) {
|
|
263
|
+
edits.push({
|
|
264
|
+
start: child.start,
|
|
265
|
+
end: child.end,
|
|
266
|
+
text: child.tag === piece.tag
|
|
267
|
+
? indexHtml
|
|
268
|
+
.slice(child.start, child.end)
|
|
269
|
+
.replace(/(\sdata-start\s*=\s*)(?:"[^"]*"|'[^']*'|[^\s"'>]+)/i, '$1"0"')
|
|
270
|
+
: '',
|
|
271
|
+
});
|
|
272
|
+
}
|
|
273
|
+
let replacedInline = false;
|
|
274
|
+
for (const script of registrations) {
|
|
275
|
+
if (pieces.some(child => script.start >= child.start && script.end <= child.end))
|
|
276
|
+
continue;
|
|
277
|
+
const inline = !hasAttribute(script.tag, 'src');
|
|
278
|
+
edits.push({ start: script.start, end: script.end, text: inline ? registration : '' });
|
|
279
|
+
replacedInline ||= inline;
|
|
280
|
+
}
|
|
281
|
+
if (!replacedInline)
|
|
282
|
+
edits.push({ start: rootSpan.end, end: rootSpan.end, text: gsap + registration });
|
|
283
|
+
else if (gsap) {
|
|
284
|
+
const first = edits.find(edit => edit.text === registration);
|
|
285
|
+
first.text = gsap + first.text;
|
|
286
|
+
}
|
|
287
|
+
let html = indexHtml;
|
|
288
|
+
for (const edit of edits.sort((a, b) => b.start - a.start)) {
|
|
289
|
+
html = html.slice(0, edit.start) + edit.text + html.slice(edit.end);
|
|
290
|
+
}
|
|
291
|
+
return { html, droppedRootScripts: scripts.length, warnings };
|
|
292
|
+
}
|
|
293
|
+
function scriptTexts(path, text) {
|
|
294
|
+
const ext = extensionOf(path);
|
|
295
|
+
if (ext === 'js' || ext === 'mjs')
|
|
296
|
+
return [text];
|
|
297
|
+
if (ext === 'html' || ext === 'svg')
|
|
298
|
+
return tokenizeHtml(text)
|
|
299
|
+
.filter(element => element.name === 'script')
|
|
300
|
+
.map(element => text.slice(element.start + element.tag.length, element.contentEnd));
|
|
301
|
+
return [];
|
|
302
|
+
}
|
|
303
|
+
export function compositionRefsOf(path, files, entryHtml) {
|
|
304
|
+
const byPath = new Map(files.map(file => [file.path, file]));
|
|
305
|
+
const seen = new Set([path]);
|
|
306
|
+
const refs = new Set();
|
|
307
|
+
const entry = byPath.get(path);
|
|
308
|
+
if (!entry && entryHtml === undefined)
|
|
309
|
+
return refs;
|
|
310
|
+
const queue = [{ path, html: entryHtml ?? readFileSync(entry.abs, 'utf8') }];
|
|
311
|
+
while (queue.length) {
|
|
312
|
+
const current = queue.shift();
|
|
313
|
+
for (const tag of openingTags(current.html)) {
|
|
314
|
+
const src = attribute(tag, 'data-composition-src')?.replace(/[?#].*$/, '');
|
|
315
|
+
if (!src)
|
|
316
|
+
continue;
|
|
317
|
+
const rootPath = src.replace(/^\.?\//, '');
|
|
318
|
+
const relativePath = posix.normalize(posix.join(posix.dirname(current.path), src));
|
|
319
|
+
const file = byPath.get(rootPath) ?? byPath.get(relativePath);
|
|
320
|
+
if (!file || seen.has(file.path) || !/\.html?$/i.test(file.path))
|
|
321
|
+
continue;
|
|
322
|
+
seen.add(file.path);
|
|
323
|
+
refs.add(file.path);
|
|
324
|
+
queue.push({ path: file.path, html: readFileSync(file.abs, 'utf8') });
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
return refs;
|
|
328
|
+
}
|
|
329
|
+
export function normalizeProjectPath(src) {
|
|
330
|
+
let path = src.replace(/[?#].*$/, '');
|
|
331
|
+
try {
|
|
332
|
+
path = decodeURIComponent(path);
|
|
333
|
+
}
|
|
334
|
+
catch {
|
|
335
|
+
}
|
|
336
|
+
path = posix.normalize(path.replace(/^\.?\//, ''));
|
|
337
|
+
if (path.startsWith('..') || path === 'index.html')
|
|
338
|
+
throw new Error(`SPLIT_INVALID_SRC: ${src}; use a project file other than index.html.`);
|
|
339
|
+
return path;
|
|
340
|
+
}
|
|
341
|
+
export function sceneExclusions(files, pieces, piece) {
|
|
342
|
+
const candidates = new Set(pieces.map(item => item.path).filter((path) => Boolean(path) && path !== piece.path));
|
|
343
|
+
let changed = true;
|
|
344
|
+
while (changed) {
|
|
345
|
+
changed = false;
|
|
346
|
+
for (const file of files) {
|
|
347
|
+
if (file.path === 'index.html' ||
|
|
348
|
+
candidates.has(file.path) ||
|
|
349
|
+
!PROJECT_TEXT_EXTENSIONS.has(extensionOf(file.path)))
|
|
350
|
+
continue;
|
|
351
|
+
const text = readFileSync(file.abs, 'utf8');
|
|
352
|
+
for (const candidate of candidates) {
|
|
353
|
+
if (text.includes(candidate)) {
|
|
354
|
+
candidates.delete(candidate);
|
|
355
|
+
changed = true;
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
return candidates;
|
|
361
|
+
}
|
|
362
|
+
const CLOCK_IDENTIFIER = /^[A-Za-z_$][\w$]{0,63}$/;
|
|
363
|
+
const RESERVED_CLOCK_NAME = /^__(?:hf|martini)|^__timelines$/;
|
|
364
|
+
function clockNameProblem(name) {
|
|
365
|
+
if (name === undefined || !CLOCK_IDENTIFIER.test(name))
|
|
366
|
+
return 'is not a plain JavaScript identifier';
|
|
367
|
+
if (RESERVED_CLOCK_NAME.test(name) || name in Object.prototype)
|
|
368
|
+
return 'is a name Martini or the runtime reserves';
|
|
369
|
+
return undefined;
|
|
370
|
+
}
|
|
371
|
+
function clockIssues(root, html, files) {
|
|
372
|
+
const issues = [];
|
|
373
|
+
const notOnRoot = (path, entry) => issue('clock_not_on_entry_root', 'data-clock and data-clock-ready belong on the root element of index.html only.', entry
|
|
374
|
+
? 'Move data-clock (and data-clock-ready) to the root composition element of index.html.'
|
|
375
|
+
: 'Remove data-clock from the sub-composition; declare the clock once on the root of index.html and draw every part from that function.', path);
|
|
376
|
+
const clockTag = (tag) => hasAttribute(tag, 'data-clock') || hasAttribute(tag, 'data-clock-ready');
|
|
377
|
+
if (openingTags(html).some(tag => tag !== root && clockTag(tag)))
|
|
378
|
+
issues.push(notOnRoot('index.html', true));
|
|
379
|
+
for (const path of compositionRefsOf('index.html', files, html)) {
|
|
380
|
+
const file = files.find(file => file.path === path);
|
|
381
|
+
if (openingTags(readFileSync(file.abs, 'utf8')).some(clockTag))
|
|
382
|
+
issues.push(notOnRoot(path, false));
|
|
383
|
+
}
|
|
384
|
+
const seek = hasAttribute(root, 'data-clock') ? (attribute(root, 'data-clock') ?? '') : undefined;
|
|
385
|
+
const ready = hasAttribute(root, 'data-clock-ready') ? (attribute(root, 'data-clock-ready') ?? '') : undefined;
|
|
386
|
+
if (seek === undefined && ready === undefined)
|
|
387
|
+
return issues;
|
|
388
|
+
const problems = [
|
|
389
|
+
seek === undefined ? 'data-clock is missing (data-clock-ready needs it)' : undefined,
|
|
390
|
+
seek !== undefined && clockNameProblem(seek) ? `data-clock="${seek}" ${clockNameProblem(seek)}` : undefined,
|
|
391
|
+
ready !== undefined && clockNameProblem(ready)
|
|
392
|
+
? `data-clock-ready="${ready}" ${clockNameProblem(ready)}`
|
|
393
|
+
: undefined,
|
|
394
|
+
].filter(Boolean);
|
|
395
|
+
if (problems.length) {
|
|
396
|
+
issues.push(issue('invalid_clock', `The root's custom clock is invalid: ${problems.join('; ')}.`, 'Set data-clock to the name of the window function that draws a frame for t seconds, e.g. data-clock="renderAt", and data-clock-ready (optional) to the name of a window promise, e.g. data-clock-ready="ready".', 'index.html'));
|
|
397
|
+
return issues;
|
|
398
|
+
}
|
|
399
|
+
const id = attribute(root, 'data-composition-id');
|
|
400
|
+
const quoted = id === undefined ? undefined : new RegExp(`(['"\`])${id.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\1`);
|
|
401
|
+
const scripts = files
|
|
402
|
+
.filter(file => !file.path.startsWith('_vendor/') && /\.(?:html?|svg|m?js)$/i.test(file.path))
|
|
403
|
+
.flatMap(file => scriptTexts(file.path, readFileSync(file.abs, 'utf8')));
|
|
404
|
+
const registersRoot = quoted !== undefined && scripts.some(text => text.includes('__timelines') && quoted.test(text));
|
|
405
|
+
if (!hasAttribute(root, 'data-no-timeline') && !registersRoot)
|
|
406
|
+
issues.push(issue('clock_root_needs_no_timeline', 'A root with data-clock and no registered timeline must say so, or the renderer waits for a timeline forever.', 'Add data-no-timeline to the root element; a page driven by its own seek function registers no timeline.', 'index.html'));
|
|
407
|
+
return issues;
|
|
408
|
+
}
|
|
409
|
+
function hyperframesPathIssue(path) {
|
|
410
|
+
if (path.length > MAX_PATH_LENGTH)
|
|
411
|
+
return issue('invalid_path', `The path is longer than ${MAX_PATH_LENGTH} characters.`, 'Shorten the path.', path);
|
|
412
|
+
if (!isSafeHyperframesPath(path))
|
|
413
|
+
return issue('invalid_path', 'The file path is not a supported POSIX relative path.', 'Rename each path segment to start with an ASCII letter or digit and contain only letters, digits, dots, underscores or hyphens (no spaces), or add it to exclude in martini.graphic.json.', path);
|
|
414
|
+
return undefined;
|
|
415
|
+
}
|
|
416
|
+
const ARTIFACT_SAFE_PATH = /^(?:[A-Za-z0-9_-][A-Za-z0-9._ -]*\/)*[A-Za-z0-9_-][A-Za-z0-9._ -]*$/;
|
|
417
|
+
const ARTIFACT_TEXT = new Set(['html', 'htm', 'css', 'js', 'mjs', 'jsx', 'ts', 'tsx', 'json', 'svg']);
|
|
418
|
+
const ARTIFACT_IMAGES = new Set(['png', 'jpg', 'jpeg', 'webp']);
|
|
419
|
+
export function localValidate(door, files, options = {}) {
|
|
420
|
+
const issues = [];
|
|
421
|
+
const refCount = options.refCount ?? 0;
|
|
422
|
+
if (files.length + refCount > MAX_PROJECT_FILES)
|
|
423
|
+
issues.push(issue('too_many_files', `The project has ${files.length + refCount} files including asset references; the cap is ${MAX_PROJECT_FILES}.`, `Reduce the project to at most ${MAX_PROJECT_FILES} files, or exclude what the composition does not load.`));
|
|
424
|
+
const unsupported = (path, ext, accepted) => issue('unsupported_file_type', `.${ext || '(none)'} files cannot be part of a Graphic.`, `Add "${path}" (or a glob for it) to exclude in martini.graphic.json, or convert it to one of: ${accepted}.`, path);
|
|
425
|
+
if (door === 'hyperframes') {
|
|
426
|
+
if (refCount > MAX_ASSET_REFS)
|
|
427
|
+
issues.push(issue('too_many_asset_refs', `${refCount} media references; the cap is ${MAX_ASSET_REFS}.`, 'Bundle some media instead of referencing it.'));
|
|
428
|
+
const index = files.find(file => file.path === 'index.html');
|
|
429
|
+
if (!index)
|
|
430
|
+
issues.push(issue('missing_entry', 'The project has no index.html entry.', 'Add index.html at the project root.'));
|
|
431
|
+
else {
|
|
432
|
+
const html = options.entryHtml ?? readFileSync(index.abs, 'utf8');
|
|
433
|
+
const tag = rootTag(html);
|
|
434
|
+
if (!tag)
|
|
435
|
+
issues.push(issue('missing_root', 'No top-level composition element was found.', /\sdata-clock\s*=/i.test(html) || WINDOW_FUNCTION.test(html)
|
|
436
|
+
? `Add data-composition-id and data-root="true" to the top-level composition element in index.html. ${CLOCK_ROOT_FIX}`
|
|
437
|
+
: 'Add data-composition-id and data-root="true" to the top-level composition element in index.html.', 'index.html'));
|
|
438
|
+
else {
|
|
439
|
+
issues.push(...clockIssues(tag, html, files));
|
|
440
|
+
const width = Number(attribute(tag, 'data-width'));
|
|
441
|
+
const height = Number(attribute(tag, 'data-height'));
|
|
442
|
+
const seconds = Number(attribute(tag, 'data-duration'));
|
|
443
|
+
if (![width, height].every(n => Number.isInteger(n) && n >= MIN_DIMENSION && n <= MAX_DIMENSION && n % 2 === 0))
|
|
444
|
+
issues.push(issue('invalid_dimensions', `Root dimensions must be even whole numbers from ${MIN_DIMENSION} to ${MAX_DIMENSION} pixels.`, 'Set data-width and data-height on the root element to the output size, e.g. data-width="1920" data-height="1080".', 'index.html'));
|
|
445
|
+
if (!(Number.isFinite(seconds) &&
|
|
446
|
+
seconds >= MIN_DURATION_SECONDS &&
|
|
447
|
+
(options.split || seconds <= MAX_DURATION_SECONDS)))
|
|
448
|
+
issues.push(issue('invalid_duration', `Root duration must be ${MIN_DURATION_SECONDS}–${MAX_DURATION_SECONDS} seconds.`, 'Set data-duration on the root element to the total length in seconds.', 'index.html'));
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
for (const file of files) {
|
|
452
|
+
const pathIssue = hyperframesPathIssue(file.path);
|
|
453
|
+
if (pathIssue)
|
|
454
|
+
issues.push(pathIssue);
|
|
455
|
+
const ext = extensionOf(file.path);
|
|
456
|
+
if (PROJECT_TEXT_EXTENSIONS.has(ext)) {
|
|
457
|
+
if (file.bytes > MAX_TEXT_FILE_BYTES)
|
|
458
|
+
issues.push(issue('file_too_large', 'Text files cannot exceed 2 MB.', 'Reduce the text file to at most 2 MB, or exclude it.', file.path));
|
|
459
|
+
}
|
|
460
|
+
else if (!PROJECT_FONT_EXTENSIONS.has(ext) && !mediaTypeOf(file.path))
|
|
461
|
+
issues.push(unsupported(file.path, ext, 'html, css, js, mjs, json, svg, woff, woff2, ttf, otf, png, jpg, webp, mp4, mov, mp3, wav, m4a, ogg'));
|
|
462
|
+
}
|
|
463
|
+
return issues;
|
|
464
|
+
}
|
|
465
|
+
const duration = options.durationSeconds;
|
|
466
|
+
if (duration !== undefined &&
|
|
467
|
+
!(duration >= ARTIFACT_MIN_DURATION_SECONDS && duration <= ARTIFACT_MAX_DURATION_SECONDS))
|
|
468
|
+
issues.push(issue('invalid_duration', `An artifact runs ${ARTIFACT_MIN_DURATION_SECONDS}–${ARTIFACT_MAX_DURATION_SECONDS} seconds.`, 'Pass --duration <seconds> within that range.'));
|
|
469
|
+
if (refCount > 0)
|
|
470
|
+
issues.push(issue('ARTIFACT_ASSET_REFS', 'A web page artifact cannot reference Martini assets.', 'Remove the assetId bindings from martini.graphic.json, or make the page a HyperFrames project.', GRAPHIC_MANIFEST_FILE));
|
|
471
|
+
if (!files.some(file => ['index.html', 'App.jsx', 'App.tsx'].includes(file.path)))
|
|
472
|
+
issues.push(issue('missing_entry', 'An artifact needs index.html, App.jsx or App.tsx at its root.', 'Add the entry file at the project root.'));
|
|
473
|
+
for (const file of files) {
|
|
474
|
+
if (!ARTIFACT_SAFE_PATH.test(file.path))
|
|
475
|
+
issues.push(issue('invalid_path', 'The file path is not a supported relative path.', 'Rename it to letters, digits, spaces, dots, underscores or hyphens.', file.path));
|
|
476
|
+
const ext = extensionOf(file.path);
|
|
477
|
+
const media = mediaTypeOf(file.path);
|
|
478
|
+
if (media && media.kind !== 'image')
|
|
479
|
+
issues.push(issue('ARTIFACT_MEDIA', `A web page artifact takes images only; ${file.path} is ${media.kind}.`, 'Make the page a HyperFrames project (a composition root in index.html) to use video or audio, or add the file to exclude in martini.graphic.json.', file.path));
|
|
480
|
+
else if (!ARTIFACT_TEXT.has(ext) && !ARTIFACT_IMAGES.has(ext) && !PROJECT_FONT_EXTENSIONS.has(ext))
|
|
481
|
+
issues.push(unsupported(file.path, ext, 'html, css, js, jsx, ts, tsx, json, svg, png, jpg, webp, woff, woff2, ttf, otf'));
|
|
482
|
+
}
|
|
483
|
+
return issues;
|
|
484
|
+
}
|
|
485
|
+
export function sha256File(abs) {
|
|
486
|
+
return createHash('sha256').update(readFileSync(abs)).digest('hex');
|
|
487
|
+
}
|
|
488
|
+
export function planMedia(files, manifest, state, options) {
|
|
489
|
+
const errors = [];
|
|
490
|
+
const warnings = [];
|
|
491
|
+
const entries = new Map((manifest?.media ?? []).map(entry => [entry.path, entry]));
|
|
492
|
+
const local = new Map(files.map(file => [file.path, file]));
|
|
493
|
+
const bundle = [];
|
|
494
|
+
const media = [];
|
|
495
|
+
for (const entry of manifest?.media ?? []) {
|
|
496
|
+
if (local.has(entry.path))
|
|
497
|
+
continue;
|
|
498
|
+
const type = mediaTypeOf(entry.path);
|
|
499
|
+
if (!type) {
|
|
500
|
+
errors.push(issue('asset_ref_not_media', `${entry.path} is not an image, video or audio path.`, 'Give the media entry a supported media extension.', entry.path));
|
|
501
|
+
continue;
|
|
502
|
+
}
|
|
503
|
+
if (entry.assetId)
|
|
504
|
+
media.push({ path: entry.path, kind: type.kind, how: 'ref', assetId: entry.assetId });
|
|
505
|
+
else
|
|
506
|
+
errors.push(issue('MEDIA_MISSING', `${entry.path} is listed in martini.graphic.json but is not in the directory and has no assetId.`, entry.recipe
|
|
507
|
+
? 'Render it from its recipe into the directory, bind it to a Martini asset with assetId, or remove the entry.'
|
|
508
|
+
: 'Add the file, bind it to a Martini asset with assetId, or remove the entry.', entry.path));
|
|
509
|
+
}
|
|
510
|
+
for (const [path, known] of Object.entries(state?.media ?? {})) {
|
|
511
|
+
if (local.has(path) || entries.has(path) || options.door === 'artifact')
|
|
512
|
+
continue;
|
|
513
|
+
const type = mediaTypeOf(path);
|
|
514
|
+
if (type)
|
|
515
|
+
media.push({ path, kind: type.kind, how: 'ref', assetId: known.assetId });
|
|
516
|
+
}
|
|
517
|
+
const candidates = [];
|
|
518
|
+
for (const file of files) {
|
|
519
|
+
const type = mediaTypeOf(file.path);
|
|
520
|
+
if (!type || options.door === 'artifact') {
|
|
521
|
+
bundle.push(file);
|
|
522
|
+
continue;
|
|
523
|
+
}
|
|
524
|
+
const entry = entries.get(file.path);
|
|
525
|
+
const known = state?.media[file.path];
|
|
526
|
+
const sha = known ? sha256File(file.abs) : undefined;
|
|
527
|
+
const planned = { path: file.path, kind: type.kind, how: 'bundled', bytes: file.bytes, abs: file.abs };
|
|
528
|
+
if (sha)
|
|
529
|
+
planned.sha256 = sha;
|
|
530
|
+
if (entry?.upload === 'always')
|
|
531
|
+
planned.how = 'uploaded';
|
|
532
|
+
else if (known && sha === known.sha256) {
|
|
533
|
+
planned.how = 'ref';
|
|
534
|
+
planned.assetId = known.assetId;
|
|
535
|
+
}
|
|
536
|
+
else if (!options.refsRideInBundle &&
|
|
537
|
+
entry?.upload !== 'never' &&
|
|
538
|
+
type.kind !== 'image' &&
|
|
539
|
+
file.bytes > 1024 * 1024)
|
|
540
|
+
planned.how = 'uploaded';
|
|
541
|
+
if (entry?.assetId && planned.how === 'bundled')
|
|
542
|
+
warnings.push(issue('BINDING_SHADOWED', `${file.path} is bound to asset ${entry.assetId} but the directory holds its own copy, which is sent instead.`, 'Delete the local file to use the bound asset, or remove the assetId.', file.path, 'warning'));
|
|
543
|
+
candidates.push(planned);
|
|
544
|
+
}
|
|
545
|
+
const bundledBytes = () => bundle.reduce((sum, file) => sum + file.bytes, 0) +
|
|
546
|
+
candidates.filter(item => item.how === 'bundled').reduce((sum, item) => sum + (item.bytes ?? 0), 0);
|
|
547
|
+
if (!options.refsRideInBundle) {
|
|
548
|
+
const images = candidates.filter(item => item.how === 'bundled' && item.kind === 'image' && entries.get(item.path)?.upload !== 'never');
|
|
549
|
+
images.sort((a, b) => (b.bytes ?? 0) - (a.bytes ?? 0));
|
|
550
|
+
for (const image of images) {
|
|
551
|
+
if (bundledBytes() <= MAX_BUNDLE_FILE_BYTES)
|
|
552
|
+
break;
|
|
553
|
+
image.how = 'uploaded';
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
for (const item of candidates) {
|
|
557
|
+
if (item.how === 'bundled')
|
|
558
|
+
bundle.push(local.get(item.path));
|
|
559
|
+
media.push(item);
|
|
560
|
+
if (item.how === 'uploaded' && (item.bytes ?? 0) > UPLOAD_CAP[item.kind])
|
|
561
|
+
errors.push(issue('FILE_TOO_LARGE', `${item.path} is ${formatMB(item.bytes ?? 0)}; a ${item.kind} upload is capped at ${formatMB(UPLOAD_CAP[item.kind])}.`, item.kind === 'video'
|
|
562
|
+
? `Compress it (martini graphics push --compress transcodes it to fit, with ffmpeg), or reference a smaller copy.`
|
|
563
|
+
: `Reference a compressed copy (e.g. ffmpeg -i ${item.path} -c:a aac -b:a 192k <name>.m4a).`, item.path));
|
|
564
|
+
}
|
|
565
|
+
bundle.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
|
|
566
|
+
media.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
|
|
567
|
+
const riding = options.refsRideInBundle
|
|
568
|
+
? media.filter(item => item.how !== 'bundled' && item.bytes !== undefined)
|
|
569
|
+
: [];
|
|
570
|
+
const sized = [
|
|
571
|
+
...bundle.map(file => ({ path: file.path, bytes: file.bytes })),
|
|
572
|
+
...riding.map(item => ({ path: item.path, bytes: item.bytes ?? 0 })),
|
|
573
|
+
];
|
|
574
|
+
const rawBytes = sized.reduce((sum, file) => sum + file.bytes, 0);
|
|
575
|
+
const encodedBytes = encodedBundleBytes(sized);
|
|
576
|
+
if (!options.refsRideInBundle) {
|
|
577
|
+
const mediaBytes = media.reduce((sum, item) => sum + (item.bytes ?? 0), 0);
|
|
578
|
+
if (!options.split && mediaBytes > MAX_MEDIA_BYTES) {
|
|
579
|
+
const top = [...media].sort((a, b) => (b.bytes ?? 0) - (a.bytes ?? 0)).slice(0, 3);
|
|
580
|
+
errors.push(issue('MEDIA_TOO_LARGE', `The Graphic's media totals ${formatMB(mediaBytes)}; one Graphic holds ${formatMB(MAX_MEDIA_BYTES)}. Largest: ${top.map(item => `${item.path} (${formatMB(item.bytes ?? 0)})`).join(', ')}.`, 'Compress the largest clips (ffmpeg), or split the piece into more than one Graphic.'));
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
if (!options.split && (rawBytes > MAX_BUNDLE_FILE_BYTES || encodedBytes > MAX_BUNDLE_BYTES)) {
|
|
584
|
+
const top = [...sized].sort((a, b) => b.bytes - a.bytes).slice(0, 5);
|
|
585
|
+
errors.push(issue('RENDER_BUNDLE_TOO_LARGE', `The Graphic's files${options.refsRideInBundle ? ' and referenced media' : ''} total ${formatMB(rawBytes)}; one render bundle holds ${formatMB(MAX_BUNDLE_FILE_BYTES)}. Largest: ${top.map(file => `${file.path} (${formatMB(file.bytes)})`).join(', ')}.`, 'Pass --compress to transcode the video to fit (needs ffmpeg), downscale images, or exclude what the composition does not load.'));
|
|
586
|
+
}
|
|
587
|
+
return { bundle, media, errors, warnings, rawBytes, encodedBytes };
|
|
588
|
+
}
|
|
589
|
+
export function compressionTargets(plan, options) {
|
|
590
|
+
const targets = new Map();
|
|
591
|
+
const videos = plan.media.filter(item => item.kind === 'video' &&
|
|
592
|
+
item.bytes !== undefined &&
|
|
593
|
+
/\.(mp4|mov)$/i.test(item.path) &&
|
|
594
|
+
(item.how === 'bundled' || item.how === 'uploaded' || options.refsRideInBundle));
|
|
595
|
+
const inBudget = (item) => item.how === 'bundled' || (options.refsRideInBundle && item.bytes !== undefined);
|
|
596
|
+
const budgeted = videos.filter(inBudget);
|
|
597
|
+
if (budgeted.length) {
|
|
598
|
+
const others = [
|
|
599
|
+
...plan.bundle
|
|
600
|
+
.filter(file => !budgeted.some(video => video.path === file.path))
|
|
601
|
+
.map(file => ({ path: file.path, bytes: file.bytes })),
|
|
602
|
+
...(options.refsRideInBundle
|
|
603
|
+
? plan.media
|
|
604
|
+
.filter(item => item.how !== 'bundled' && item.bytes !== undefined && !budgeted.includes(item))
|
|
605
|
+
.map(item => ({ path: item.path, bytes: item.bytes ?? 0 }))
|
|
606
|
+
: []),
|
|
607
|
+
];
|
|
608
|
+
const otherRaw = others.reduce((sum, file) => sum + file.bytes, 0);
|
|
609
|
+
const byRaw = MAX_BUNDLE_FILE_BYTES - otherRaw;
|
|
610
|
+
const byEncoded = Math.floor(((MAX_BUNDLE_BYTES - encodedBundleBytes([...others, ...budgeted.map(item => ({ path: item.path, bytes: 0 }))])) *
|
|
611
|
+
3) /
|
|
612
|
+
4);
|
|
613
|
+
const remaining = Math.min(byRaw, byEncoded);
|
|
614
|
+
const total = budgeted.reduce((sum, item) => sum + (item.bytes ?? 0), 0);
|
|
615
|
+
if (total > remaining)
|
|
616
|
+
for (const item of budgeted) {
|
|
617
|
+
const share = Math.floor((remaining * (item.bytes ?? 0)) / total);
|
|
618
|
+
targets.set(item.path, Math.min(share, UPLOAD_CAP.video));
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
for (const item of videos)
|
|
622
|
+
if (!targets.has(item.path) && item.how === 'uploaded' && (item.bytes ?? 0) > UPLOAD_CAP.video)
|
|
623
|
+
targets.set(item.path, UPLOAD_CAP.video);
|
|
624
|
+
return targets;
|
|
625
|
+
}
|
|
626
|
+
//# sourceMappingURL=project.js.map
|