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.
@@ -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
+ }
@@ -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