slides-grab 1.2.0 → 1.2.1
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 +33 -3
- package/bin/ppt-agent.js +35 -0
- package/package.json +8 -2
- package/scripts/download-video.js +213 -0
- package/scripts/generate-image.js +49 -0
- package/scripts/html2pdf.js +134 -0
- package/skills/slides-grab/SKILL.md +7 -4
- package/skills/slides-grab/references/presentation-workflow-reference.md +13 -9
- package/skills/slides-grab-design/SKILL.md +16 -8
- package/skills/slides-grab-design/references/design-rules.md +7 -0
- package/skills/slides-grab-design/references/detailed-design-rules.md +8 -2
- package/src/editor/codex-edit.js +23 -10
- package/src/image-contract.js +138 -31
- package/src/nano-banana.js +417 -0
- package/src/validation/core.js +151 -18
|
@@ -0,0 +1,417 @@
|
|
|
1
|
+
import { mkdir, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { extname, isAbsolute, join, relative, resolve } from 'node:path';
|
|
3
|
+
|
|
4
|
+
import { getSlidesDir } from './resolve.js';
|
|
5
|
+
|
|
6
|
+
export const DEFAULT_NANO_BANANA_MODEL = 'gemini-3-pro-image-preview';
|
|
7
|
+
export const DEFAULT_NANO_BANANA_ASPECT_RATIO = '16:9';
|
|
8
|
+
export const DEFAULT_NANO_BANANA_IMAGE_SIZE = '4K';
|
|
9
|
+
const VALID_IMAGE_SIZES = new Set(['2K', '4K']);
|
|
10
|
+
|
|
11
|
+
const MIME_TYPE_TO_EXTENSION = new Map([
|
|
12
|
+
['image/png', '.png'],
|
|
13
|
+
['image/jpeg', '.jpg'],
|
|
14
|
+
['image/webp', '.webp'],
|
|
15
|
+
]);
|
|
16
|
+
|
|
17
|
+
function readOptionValue(argv, index, option) {
|
|
18
|
+
const value = argv[index + 1];
|
|
19
|
+
if (!value || value.startsWith('--')) {
|
|
20
|
+
throw new Error(`${option} is missing a value.`);
|
|
21
|
+
}
|
|
22
|
+
return value;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function getNanoBananaUsage() {
|
|
26
|
+
return [
|
|
27
|
+
'Usage: slides-grab image --prompt <text> [options]',
|
|
28
|
+
'',
|
|
29
|
+
'Generate a deck-local image asset with Nano Banana Pro and save it into <slides-dir>/assets/.',
|
|
30
|
+
'',
|
|
31
|
+
'Options:',
|
|
32
|
+
' --prompt <text> Required text prompt for image generation',
|
|
33
|
+
' --slides-dir <path> Slides directory (default: slides)',
|
|
34
|
+
' --output <path> Optional explicit output path inside <slides-dir>/assets/',
|
|
35
|
+
' --name <slug> Optional asset basename without extension',
|
|
36
|
+
` --model <id> Model id (default: ${DEFAULT_NANO_BANANA_MODEL})`,
|
|
37
|
+
` --aspect-ratio <ratio> Image aspect ratio (default: ${DEFAULT_NANO_BANANA_ASPECT_RATIO})`,
|
|
38
|
+
` --image-size <size> Image size preset: 2K or 4K (default: ${DEFAULT_NANO_BANANA_IMAGE_SIZE})`,
|
|
39
|
+
' -h, --help Show this help text',
|
|
40
|
+
'',
|
|
41
|
+
'Auth:',
|
|
42
|
+
' Set GOOGLE_API_KEY or GEMINI_API_KEY before running this command.',
|
|
43
|
+
].join('\n');
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function parseNanoBananaCliArgs(argv) {
|
|
47
|
+
const parsed = {
|
|
48
|
+
prompt: '',
|
|
49
|
+
slidesDir: 'slides',
|
|
50
|
+
output: '',
|
|
51
|
+
name: '',
|
|
52
|
+
model: DEFAULT_NANO_BANANA_MODEL,
|
|
53
|
+
aspectRatio: DEFAULT_NANO_BANANA_ASPECT_RATIO,
|
|
54
|
+
imageSize: DEFAULT_NANO_BANANA_IMAGE_SIZE,
|
|
55
|
+
help: false,
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
const args = Array.isArray(argv) ? [...argv] : [];
|
|
59
|
+
|
|
60
|
+
for (let i = 0; i < args.length; i += 1) {
|
|
61
|
+
const arg = args[i];
|
|
62
|
+
|
|
63
|
+
if (arg === '--help' || arg === '-h') {
|
|
64
|
+
parsed.help = true;
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (arg === '--prompt') {
|
|
69
|
+
parsed.prompt = readOptionValue(args, i, '--prompt');
|
|
70
|
+
i += 1;
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
if (arg.startsWith('--prompt=')) {
|
|
74
|
+
parsed.prompt = arg.slice('--prompt='.length);
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (arg === '--slides-dir') {
|
|
79
|
+
parsed.slidesDir = readOptionValue(args, i, '--slides-dir');
|
|
80
|
+
i += 1;
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
if (arg.startsWith('--slides-dir=')) {
|
|
84
|
+
parsed.slidesDir = arg.slice('--slides-dir='.length);
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (arg === '--output') {
|
|
89
|
+
parsed.output = readOptionValue(args, i, '--output');
|
|
90
|
+
i += 1;
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
if (arg.startsWith('--output=')) {
|
|
94
|
+
parsed.output = arg.slice('--output='.length);
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (arg === '--name') {
|
|
99
|
+
parsed.name = readOptionValue(args, i, '--name');
|
|
100
|
+
i += 1;
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
if (arg.startsWith('--name=')) {
|
|
104
|
+
parsed.name = arg.slice('--name='.length);
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (arg === '--model') {
|
|
109
|
+
parsed.model = readOptionValue(args, i, '--model');
|
|
110
|
+
i += 1;
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
if (arg.startsWith('--model=')) {
|
|
114
|
+
parsed.model = arg.slice('--model='.length);
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (arg === '--aspect-ratio') {
|
|
119
|
+
parsed.aspectRatio = readOptionValue(args, i, '--aspect-ratio');
|
|
120
|
+
i += 1;
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
if (arg.startsWith('--aspect-ratio=')) {
|
|
124
|
+
parsed.aspectRatio = arg.slice('--aspect-ratio='.length);
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
if (arg === '--image-size') {
|
|
129
|
+
parsed.imageSize = readOptionValue(args, i, '--image-size').toUpperCase();
|
|
130
|
+
i += 1;
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
if (arg.startsWith('--image-size=')) {
|
|
134
|
+
parsed.imageSize = arg.slice('--image-size='.length).toUpperCase();
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
throw new Error(`Unknown option: ${arg}`);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if (parsed.help) {
|
|
142
|
+
return parsed;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
if (typeof parsed.prompt !== 'string' || parsed.prompt.trim() === '') {
|
|
146
|
+
throw new Error('--prompt must be a non-empty string.');
|
|
147
|
+
}
|
|
148
|
+
parsed.prompt = parsed.prompt.trim();
|
|
149
|
+
|
|
150
|
+
if (typeof parsed.slidesDir !== 'string' || parsed.slidesDir.trim() === '') {
|
|
151
|
+
throw new Error('--slides-dir must be a non-empty string.');
|
|
152
|
+
}
|
|
153
|
+
parsed.slidesDir = parsed.slidesDir.trim();
|
|
154
|
+
|
|
155
|
+
if (typeof parsed.output !== 'string') {
|
|
156
|
+
throw new Error('--output must be a string.');
|
|
157
|
+
}
|
|
158
|
+
parsed.output = parsed.output.trim();
|
|
159
|
+
|
|
160
|
+
if (typeof parsed.name !== 'string') {
|
|
161
|
+
throw new Error('--name must be a string.');
|
|
162
|
+
}
|
|
163
|
+
parsed.name = parsed.name.trim();
|
|
164
|
+
|
|
165
|
+
if (typeof parsed.model !== 'string' || parsed.model.trim() === '') {
|
|
166
|
+
throw new Error('--model must be a non-empty string.');
|
|
167
|
+
}
|
|
168
|
+
parsed.model = parsed.model.trim();
|
|
169
|
+
|
|
170
|
+
if (typeof parsed.aspectRatio !== 'string' || parsed.aspectRatio.trim() === '') {
|
|
171
|
+
throw new Error('--aspect-ratio must be a non-empty string.');
|
|
172
|
+
}
|
|
173
|
+
parsed.aspectRatio = parsed.aspectRatio.trim();
|
|
174
|
+
|
|
175
|
+
if (!VALID_IMAGE_SIZES.has(parsed.imageSize)) {
|
|
176
|
+
throw new Error(`Unknown --image-size value: ${parsed.imageSize}. Expected 2K or 4K.`);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
return parsed;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export function resolveNanoBananaApiKey(env = process.env) {
|
|
183
|
+
const googleApiKey = typeof env?.GOOGLE_API_KEY === 'string' ? env.GOOGLE_API_KEY.trim() : '';
|
|
184
|
+
if (googleApiKey) {
|
|
185
|
+
return { apiKey: googleApiKey, source: 'GOOGLE_API_KEY' };
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const geminiApiKey = typeof env?.GEMINI_API_KEY === 'string' ? env.GEMINI_API_KEY.trim() : '';
|
|
189
|
+
if (geminiApiKey) {
|
|
190
|
+
return { apiKey: geminiApiKey, source: 'GEMINI_API_KEY' };
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
return { apiKey: '', source: '' };
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
export function getNanoBananaFallbackMessage(reason) {
|
|
197
|
+
const summary = typeof reason === 'string' && reason.trim() ? reason.trim() : 'Nano Banana image generation failed.';
|
|
198
|
+
return `${summary} Ask the user to provide GOOGLE_API_KEY (or GEMINI_API_KEY), or fall back to web search + download the chosen image into ./assets/<file>.`;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
export function buildNanoBananaApiRequest({ prompt, aspectRatio, imageSize }) {
|
|
202
|
+
return {
|
|
203
|
+
contents: [
|
|
204
|
+
{
|
|
205
|
+
parts: [{ text: prompt }],
|
|
206
|
+
},
|
|
207
|
+
],
|
|
208
|
+
generationConfig: {
|
|
209
|
+
imageConfig: {
|
|
210
|
+
aspectRatio,
|
|
211
|
+
imageSize,
|
|
212
|
+
},
|
|
213
|
+
},
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function sanitizeAssetName(value) {
|
|
218
|
+
return value
|
|
219
|
+
.toLowerCase()
|
|
220
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
221
|
+
.replace(/^-+|-+$/g, '')
|
|
222
|
+
.replace(/-{2,}/g, '-')
|
|
223
|
+
.slice(0, 80);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function pickAssetBaseName({ prompt, name }) {
|
|
227
|
+
const preferred = sanitizeAssetName(name || '');
|
|
228
|
+
if (preferred) return preferred;
|
|
229
|
+
|
|
230
|
+
const fromPrompt = sanitizeAssetName(prompt);
|
|
231
|
+
return fromPrompt ? `nano-banana-${fromPrompt}` : 'nano-banana-generated-image';
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function getExtensionFromMimeType(mimeType) {
|
|
235
|
+
return MIME_TYPE_TO_EXTENSION.get((mimeType || '').toLowerCase()) || '.png';
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function ensureInsideDirectory(filePath, directoryPath) {
|
|
239
|
+
const relativePath = relative(directoryPath, filePath);
|
|
240
|
+
return relativePath !== '' && !relativePath.startsWith('..') && !isAbsolute(relativePath);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function resolveRequestedOutputPath(output, assetsDir) {
|
|
244
|
+
const trimmed = output.trim();
|
|
245
|
+
if (isAbsolute(trimmed)) {
|
|
246
|
+
return resolve(trimmed);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const isBareFileName = !/[\\/]/.test(trimmed);
|
|
250
|
+
if (isBareFileName) {
|
|
251
|
+
return resolve(assetsDir, trimmed);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
if (/^(?:\.\/)?assets[\\/]/.test(trimmed)) {
|
|
255
|
+
const normalized = trimmed
|
|
256
|
+
.replace(/^[.][\\/]/, '')
|
|
257
|
+
.replace(/^assets[\\/]/, '');
|
|
258
|
+
return resolve(assetsDir, normalized);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
const cwdRelativePath = resolve(trimmed);
|
|
262
|
+
if (ensureInsideDirectory(cwdRelativePath, assetsDir)) {
|
|
263
|
+
return cwdRelativePath;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
return resolve(assetsDir, trimmed);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
export function resolveNanoBananaOutputPath({
|
|
270
|
+
slidesDir,
|
|
271
|
+
prompt,
|
|
272
|
+
output = '',
|
|
273
|
+
name = '',
|
|
274
|
+
mimeType = 'image/png',
|
|
275
|
+
}) {
|
|
276
|
+
const absoluteSlidesDir = resolve(slidesDir);
|
|
277
|
+
const assetsDir = join(absoluteSlidesDir, 'assets');
|
|
278
|
+
const extension = getExtensionFromMimeType(mimeType);
|
|
279
|
+
|
|
280
|
+
let outputPath;
|
|
281
|
+
if (output) {
|
|
282
|
+
const requestedPath = resolveRequestedOutputPath(output, assetsDir);
|
|
283
|
+
if (!ensureInsideDirectory(requestedPath, assetsDir)) {
|
|
284
|
+
throw new Error(`Generated images must be saved inside ${assetsDir}.`);
|
|
285
|
+
}
|
|
286
|
+
outputPath = extname(requestedPath) ? requestedPath : `${requestedPath}${extension}`;
|
|
287
|
+
} else {
|
|
288
|
+
outputPath = join(assetsDir, `${pickAssetBaseName({ prompt, name })}${extension}`);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
if (!ensureInsideDirectory(outputPath, assetsDir)) {
|
|
292
|
+
throw new Error(`Generated images must be saved inside ${assetsDir}.`);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
return {
|
|
296
|
+
assetsDir,
|
|
297
|
+
outputPath,
|
|
298
|
+
relativeRef: `./assets/${relative(assetsDir, outputPath).replace(/\\/g, '/')}`,
|
|
299
|
+
};
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
export function extractGeneratedImage(payload) {
|
|
303
|
+
const candidates = Array.isArray(payload?.candidates) ? payload.candidates : [];
|
|
304
|
+
|
|
305
|
+
for (const candidate of candidates) {
|
|
306
|
+
const parts = Array.isArray(candidate?.content?.parts) ? candidate.content.parts : [];
|
|
307
|
+
for (const part of parts) {
|
|
308
|
+
const inlineData = part?.inlineData || part?.inline_data;
|
|
309
|
+
if (!inlineData?.data) continue;
|
|
310
|
+
|
|
311
|
+
return {
|
|
312
|
+
mimeType: inlineData.mimeType || inlineData.mime_type || 'image/png',
|
|
313
|
+
bytes: Buffer.from(inlineData.data, 'base64'),
|
|
314
|
+
};
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
throw new Error('Nano Banana API response did not include an image payload.');
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
function buildNanoBananaEndpoint(model) {
|
|
322
|
+
return `https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent`;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
function getApiErrorMessage(payload, status) {
|
|
326
|
+
const message = payload?.error?.message || payload?.message;
|
|
327
|
+
if (typeof message === 'string' && message.trim()) {
|
|
328
|
+
return message.trim();
|
|
329
|
+
}
|
|
330
|
+
return `HTTP ${status}`;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
export async function generateNanoBananaImage({
|
|
334
|
+
prompt,
|
|
335
|
+
apiKey,
|
|
336
|
+
model = DEFAULT_NANO_BANANA_MODEL,
|
|
337
|
+
aspectRatio = DEFAULT_NANO_BANANA_ASPECT_RATIO,
|
|
338
|
+
imageSize = DEFAULT_NANO_BANANA_IMAGE_SIZE,
|
|
339
|
+
fetchImpl = globalThis.fetch,
|
|
340
|
+
}) {
|
|
341
|
+
if (typeof fetchImpl !== 'function') {
|
|
342
|
+
throw new Error('Global fetch is unavailable in this runtime.');
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
try {
|
|
346
|
+
const response = await fetchImpl(buildNanoBananaEndpoint(model), {
|
|
347
|
+
method: 'POST',
|
|
348
|
+
headers: {
|
|
349
|
+
'Content-Type': 'application/json',
|
|
350
|
+
'x-goog-api-key': apiKey,
|
|
351
|
+
},
|
|
352
|
+
body: JSON.stringify(buildNanoBananaApiRequest({ prompt, aspectRatio, imageSize })),
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
const payload = await response.json();
|
|
356
|
+
if (!response.ok) {
|
|
357
|
+
throw new Error(`Nano Banana API request failed: ${getApiErrorMessage(payload, response.status)}.`);
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
return extractGeneratedImage(payload);
|
|
361
|
+
} catch (error) {
|
|
362
|
+
throw new Error(getNanoBananaFallbackMessage(error.message));
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
export async function saveNanoBananaImage({
|
|
367
|
+
prompt,
|
|
368
|
+
slidesDir,
|
|
369
|
+
output = '',
|
|
370
|
+
name = '',
|
|
371
|
+
mimeType,
|
|
372
|
+
bytes,
|
|
373
|
+
}) {
|
|
374
|
+
const target = resolveNanoBananaOutputPath({ slidesDir, prompt, output, name, mimeType });
|
|
375
|
+
await mkdir(target.assetsDir, { recursive: true });
|
|
376
|
+
await writeFile(target.outputPath, bytes);
|
|
377
|
+
return target;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
export async function runNanoBananaCli(argv = process.argv.slice(2), {
|
|
381
|
+
env = process.env,
|
|
382
|
+
fetchImpl = globalThis.fetch,
|
|
383
|
+
stdout = process.stdout,
|
|
384
|
+
} = {}) {
|
|
385
|
+
const options = parseNanoBananaCliArgs(argv);
|
|
386
|
+
if (options.help) {
|
|
387
|
+
stdout.write(`${getNanoBananaUsage()}\n`);
|
|
388
|
+
return null;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
const { apiKey } = resolveNanoBananaApiKey(env);
|
|
392
|
+
if (!apiKey) {
|
|
393
|
+
throw new Error(getNanoBananaFallbackMessage('Nano Banana image generation requires GOOGLE_API_KEY or GEMINI_API_KEY.'));
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
const generated = await generateNanoBananaImage({
|
|
397
|
+
prompt: options.prompt,
|
|
398
|
+
apiKey,
|
|
399
|
+
model: options.model,
|
|
400
|
+
aspectRatio: options.aspectRatio,
|
|
401
|
+
imageSize: options.imageSize,
|
|
402
|
+
fetchImpl,
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
const target = await saveNanoBananaImage({
|
|
406
|
+
prompt: options.prompt,
|
|
407
|
+
slidesDir: getSlidesDir(options.slidesDir),
|
|
408
|
+
output: options.output,
|
|
409
|
+
name: options.name,
|
|
410
|
+
mimeType: generated.mimeType,
|
|
411
|
+
bytes: generated.bytes,
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
stdout.write(`Saved generated image to ${target.outputPath}\n`);
|
|
415
|
+
stdout.write(`Reference it from slide HTML as ${target.relativeRef}\n`);
|
|
416
|
+
return target;
|
|
417
|
+
}
|
package/src/validation/core.js
CHANGED
|
@@ -4,6 +4,7 @@ import { pathToFileURL } from 'node:url';
|
|
|
4
4
|
import { chromium } from 'playwright';
|
|
5
5
|
import {
|
|
6
6
|
buildImageContractReport,
|
|
7
|
+
buildVideoContractReport,
|
|
7
8
|
classifyImageSource,
|
|
8
9
|
resolveSlideSourcePath,
|
|
9
10
|
} from '../image-contract.js';
|
|
@@ -114,6 +115,18 @@ async function fileExists(filePath) {
|
|
|
114
115
|
}
|
|
115
116
|
}
|
|
116
117
|
|
|
118
|
+
function shouldSkipLocalAssetExistenceCheck(classification) {
|
|
119
|
+
return (
|
|
120
|
+
classification.kind === 'empty'
|
|
121
|
+
|| classification.kind === 'data-url'
|
|
122
|
+
|| classification.kind === 'remote-url'
|
|
123
|
+
|| classification.kind === 'remote-url-insecure'
|
|
124
|
+
|| classification.kind === 'absolute-filesystem-path'
|
|
125
|
+
|| classification.kind === 'root-relative-path'
|
|
126
|
+
|| classification.kind === 'other-scheme'
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
|
|
117
130
|
async function inspectImageContract(slidesDir, fileName, inspection) {
|
|
118
131
|
const critical = [];
|
|
119
132
|
const warning = [];
|
|
@@ -135,15 +148,7 @@ async function inspectImageContract(slidesDir, fileName, inspection) {
|
|
|
135
148
|
target.push(issue);
|
|
136
149
|
}
|
|
137
150
|
|
|
138
|
-
if (
|
|
139
|
-
classification.kind === 'empty'
|
|
140
|
-
|| classification.kind === 'data-url'
|
|
141
|
-
|| classification.kind === 'remote-url'
|
|
142
|
-
|| classification.kind === 'remote-url-insecure'
|
|
143
|
-
|| classification.kind === 'absolute-filesystem-path'
|
|
144
|
-
|| classification.kind === 'root-relative-path'
|
|
145
|
-
|| classification.kind === 'other-scheme'
|
|
146
|
-
) {
|
|
151
|
+
if (shouldSkipLocalAssetExistenceCheck(classification)) {
|
|
147
152
|
continue;
|
|
148
153
|
}
|
|
149
154
|
|
|
@@ -201,15 +206,7 @@ async function inspectImageContract(slidesDir, fileName, inspection) {
|
|
|
201
206
|
|
|
202
207
|
for (const source of background.urls) {
|
|
203
208
|
const classification = classifyImageSource(source);
|
|
204
|
-
if (
|
|
205
|
-
classification.kind === 'empty'
|
|
206
|
-
|| classification.kind === 'data-url'
|
|
207
|
-
|| classification.kind === 'remote-url'
|
|
208
|
-
|| classification.kind === 'remote-url-insecure'
|
|
209
|
-
|| classification.kind === 'absolute-filesystem-path'
|
|
210
|
-
|| classification.kind === 'root-relative-path'
|
|
211
|
-
|| classification.kind === 'other-scheme'
|
|
212
|
-
) {
|
|
209
|
+
if (shouldSkipLocalAssetExistenceCheck(classification)) {
|
|
213
210
|
continue;
|
|
214
211
|
}
|
|
215
212
|
|
|
@@ -233,6 +230,94 @@ async function inspectImageContract(slidesDir, fileName, inspection) {
|
|
|
233
230
|
return { critical, warning };
|
|
234
231
|
}
|
|
235
232
|
|
|
233
|
+
async function inspectVideoContract(slidesDir, fileName, inspection) {
|
|
234
|
+
const critical = [];
|
|
235
|
+
const warning = [];
|
|
236
|
+
const slidePath = join(slidesDir, fileName);
|
|
237
|
+
|
|
238
|
+
for (const video of inspection.videos) {
|
|
239
|
+
const sources = [...new Set([
|
|
240
|
+
typeof video.src === 'string' ? video.src : '',
|
|
241
|
+
...video.sources,
|
|
242
|
+
].map((source) => source.trim()).filter(Boolean))];
|
|
243
|
+
|
|
244
|
+
const issues = buildVideoContractReport({
|
|
245
|
+
slideFile: fileName,
|
|
246
|
+
sources: sources.map((source) => ({
|
|
247
|
+
element: buildElementPath(video.element),
|
|
248
|
+
source,
|
|
249
|
+
})),
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
for (const issue of issues) {
|
|
253
|
+
const target = issue.severity === 'critical' ? critical : warning;
|
|
254
|
+
target.push(issue);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
for (const source of sources) {
|
|
258
|
+
const classification = classifyImageSource(source);
|
|
259
|
+
if (shouldSkipLocalAssetExistenceCheck(classification)) {
|
|
260
|
+
continue;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
const assetPath = resolveSlideSourcePath(slidePath, source);
|
|
264
|
+
if (!(await fileExists(assetPath))) {
|
|
265
|
+
critical.push(buildImageIssue(
|
|
266
|
+
'critical',
|
|
267
|
+
'missing-local-video-asset',
|
|
268
|
+
'Local video asset is missing.',
|
|
269
|
+
{
|
|
270
|
+
slide: fileName,
|
|
271
|
+
element: buildElementPath(video.element),
|
|
272
|
+
source,
|
|
273
|
+
assetPath,
|
|
274
|
+
},
|
|
275
|
+
));
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
const poster = typeof video.poster === 'string' ? video.poster.trim() : '';
|
|
280
|
+
if (!poster) {
|
|
281
|
+
continue;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
const posterIssues = buildImageContractReport({
|
|
285
|
+
slideFile: fileName,
|
|
286
|
+
sources: [{
|
|
287
|
+
element: buildElementPath(video.element),
|
|
288
|
+
source: poster,
|
|
289
|
+
}],
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
for (const issue of posterIssues) {
|
|
293
|
+
const target = issue.severity === 'critical' ? critical : warning;
|
|
294
|
+
target.push(issue);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
const posterClassification = classifyImageSource(poster);
|
|
298
|
+
if (shouldSkipLocalAssetExistenceCheck(posterClassification)) {
|
|
299
|
+
continue;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
const posterPath = resolveSlideSourcePath(slidePath, poster);
|
|
303
|
+
if (!(await fileExists(posterPath))) {
|
|
304
|
+
critical.push(buildImageIssue(
|
|
305
|
+
'critical',
|
|
306
|
+
'missing-local-video-poster-asset',
|
|
307
|
+
'Video poster image is missing.',
|
|
308
|
+
{
|
|
309
|
+
slide: fileName,
|
|
310
|
+
element: buildElementPath(video.element),
|
|
311
|
+
source: poster,
|
|
312
|
+
assetPath: posterPath,
|
|
313
|
+
},
|
|
314
|
+
));
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
return { critical, warning };
|
|
319
|
+
}
|
|
320
|
+
|
|
236
321
|
export async function findSlideFiles(slidesDir) {
|
|
237
322
|
const entries = await readdir(slidesDir, { withFileTypes: true });
|
|
238
323
|
return entries
|
|
@@ -417,6 +502,31 @@ export async function inspectSlide(page, fileName, slidesDir) {
|
|
|
417
502
|
return values;
|
|
418
503
|
};
|
|
419
504
|
|
|
505
|
+
// Detect persisted runtime-only editor/viewer injections.
|
|
506
|
+
const baseElements = Array.from(document.querySelectorAll('head base[href]'));
|
|
507
|
+
for (const base of baseElements) {
|
|
508
|
+
critical.push({
|
|
509
|
+
code: 'persisted-editor-base-tag',
|
|
510
|
+
message: 'Slide contains a <base> tag injected by the editor runtime. Remove it so asset paths resolve correctly outside the editor.',
|
|
511
|
+
element: 'head > base',
|
|
512
|
+
detail: base.getAttribute('href'),
|
|
513
|
+
});
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
const editorScriptSignatures = ['[slides-grab:image]', '[slides-grab:'];
|
|
517
|
+
const scripts = Array.from(document.querySelectorAll('head script:not([src])'));
|
|
518
|
+
for (const script of scripts) {
|
|
519
|
+
const text = script.textContent || '';
|
|
520
|
+
const matched = editorScriptSignatures.some((sig) => text.includes(sig));
|
|
521
|
+
if (matched) {
|
|
522
|
+
critical.push({
|
|
523
|
+
code: 'persisted-editor-script',
|
|
524
|
+
message: 'Slide contains a runtime-only editor script that should not be persisted. Remove the injected <script> block.',
|
|
525
|
+
element: 'head > script',
|
|
526
|
+
});
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
|
|
420
530
|
const bodyRect = document.body.getBoundingClientRect();
|
|
421
531
|
const frameRect = {
|
|
422
532
|
left: bodyRect.left,
|
|
@@ -519,6 +629,15 @@ export async function inspectSlide(page, fileName, slidesDir) {
|
|
|
519
629
|
alt: (element.getAttribute('alt') || '').trim(),
|
|
520
630
|
}));
|
|
521
631
|
|
|
632
|
+
const videos = Array.from(document.querySelectorAll('video')).map((element) => ({
|
|
633
|
+
element: elementPath(element),
|
|
634
|
+
src: (element.getAttribute('src') || '').trim(),
|
|
635
|
+
sources: Array.from(element.querySelectorAll('source[src]'))
|
|
636
|
+
.map((source) => (source.getAttribute('src') || '').trim())
|
|
637
|
+
.filter(Boolean),
|
|
638
|
+
poster: (element.getAttribute('poster') || '').trim(),
|
|
639
|
+
}));
|
|
640
|
+
|
|
522
641
|
const backgrounds = [document.body, ...Array.from(document.body.querySelectorAll('*'))]
|
|
523
642
|
.map((element) => {
|
|
524
643
|
const computedBackgroundImage = window.getComputedStyle(element).backgroundImage;
|
|
@@ -539,6 +658,7 @@ export async function inspectSlide(page, fileName, slidesDir) {
|
|
|
539
658
|
critical,
|
|
540
659
|
warning,
|
|
541
660
|
images,
|
|
661
|
+
videos,
|
|
542
662
|
backgrounds,
|
|
543
663
|
};
|
|
544
664
|
},
|
|
@@ -553,9 +673,14 @@ export async function inspectSlide(page, fileName, slidesDir) {
|
|
|
553
673
|
images: inspection.images,
|
|
554
674
|
backgrounds: inspection.backgrounds,
|
|
555
675
|
});
|
|
676
|
+
const videoContractIssues = await inspectVideoContract(slidesDir, fileName, {
|
|
677
|
+
videos: inspection.videos,
|
|
678
|
+
});
|
|
556
679
|
|
|
557
680
|
inspection.critical.push(...imageContractIssues.critical);
|
|
558
681
|
inspection.warning.push(...imageContractIssues.warning);
|
|
682
|
+
inspection.critical.push(...videoContractIssues.critical);
|
|
683
|
+
inspection.warning.push(...videoContractIssues.warning);
|
|
559
684
|
|
|
560
685
|
const summary = {
|
|
561
686
|
criticalCount: inspection.critical.length,
|
|
@@ -620,13 +745,21 @@ export function formatValidationFailureForExport(result, exportLabel = 'Export')
|
|
|
620
745
|
|
|
621
746
|
const EXPORT_BLOCKING_IMAGE_CONTRACT_CODES = new Set([
|
|
622
747
|
'absolute-filesystem-image-path',
|
|
748
|
+
'absolute-filesystem-video-path',
|
|
623
749
|
'missing-local-asset',
|
|
624
750
|
'missing-local-background-asset',
|
|
751
|
+
'missing-local-video-asset',
|
|
752
|
+
'missing-local-video-poster-asset',
|
|
625
753
|
'remote-background-image-url',
|
|
626
754
|
'remote-background-image-url-insecure',
|
|
627
755
|
'remote-image-url',
|
|
628
756
|
'remote-image-url-insecure',
|
|
757
|
+
'remote-video-url',
|
|
758
|
+
'remote-video-url-insecure',
|
|
629
759
|
'root-relative-image-path',
|
|
760
|
+
'root-relative-video-path',
|
|
761
|
+
'unsupported-image-url-scheme',
|
|
762
|
+
'unsupported-video-url-scheme',
|
|
630
763
|
'unsupported-background-image',
|
|
631
764
|
]);
|
|
632
765
|
|