@x12i/youtube-video-uploader-cli 1.1.0 → 1.3.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.
@@ -0,0 +1,476 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import pc from 'picocolors';
4
+ import { searchMedia } from '../providers/index.js';
5
+ import { rankCandidates, selectBest } from './select.js';
6
+ import { downloadAsset } from './download.js';
7
+ import { composeVideo, getMediaDuration } from './compose.js';
8
+ import {
9
+ readUsedAssetsLedger,
10
+ recordUsedAsset,
11
+ DEFAULT_MEDIA_CACHE_DIR,
12
+ } from './cache.js';
13
+
14
+ /**
15
+ * Derive a clean, concise search query from metadata fields.
16
+ *
17
+ * @param {object} item
18
+ * @param {string} [item.title]
19
+ * @param {string[]} [item.tags]
20
+ * @param {string} [item.artist]
21
+ * @param {object} [item.album]
22
+ * @param {string} [item.filename]
23
+ * @returns {string}
24
+ */
25
+ export function deriveSearchQuery(item = {}) {
26
+ let combined = '';
27
+ if (item.title) combined += ' ' + item.title;
28
+ if (Array.isArray(item.tags)) combined += ' ' + item.tags.join(' ');
29
+ if (item.album?.title) combined += ' ' + item.album.title;
30
+ if (item.artist) combined += ' ' + item.artist;
31
+ if (!combined.trim() && item.filename) {
32
+ combined = path.basename(item.filename, path.extname(item.filename));
33
+ }
34
+
35
+ const stopWords = new Set(['the', 'and', 'for', 'with', 'feat', 'ft', 'official', 'audio', 'video', 'music', 'track', 'version', 'mix']);
36
+ const words = combined
37
+ .toLowerCase()
38
+ .replace(/[^\w\s-]/g, ' ')
39
+ .split(/\s+/)
40
+ .filter(w => w.length > 2 && !/^\d+$/.test(w) && !stopWords.has(w));
41
+
42
+ const uniqueWords = [...new Set(words)];
43
+ return uniqueWords.slice(0, 8).join(' ') || (item.title || 'ambient nature');
44
+ }
45
+
46
+ /**
47
+ * Generate human-readable credit line for an asset.
48
+ *
49
+ * @param {import('../providers/index.js').UnifiedResult} asset
50
+ * @returns {string}
51
+ */
52
+ export function formatMediaCredit(asset) {
53
+ const creatorName = asset.creator?.name || 'Creator';
54
+ const providerName = asset.provider === 'pexels' ? 'Pexels' : 'Pixabay';
55
+ const mediaType = asset.type === 'video' ? 'Video' : 'Photo';
56
+ return `${mediaType} by ${creatorName} on ${providerName}`;
57
+ }
58
+
59
+ /**
60
+ * Generate or update ATTRIBUTIONS.md in the target folder.
61
+ *
62
+ * @param {string} targetDir
63
+ * @param {Array<{ songKey: string, resolvedMedia: object }>} resolvedItems
64
+ */
65
+ export function writeAttributionsFile(targetDir, resolvedItems) {
66
+ if (!resolvedItems || resolvedItems.length === 0) return;
67
+
68
+ const attributionsPath = path.join(targetDir, 'ATTRIBUTIONS.md');
69
+ const lines = [
70
+ '# Stock Media Attributions',
71
+ '',
72
+ 'This project uses stock media from Pexels and Pixabay under their respective licenses.',
73
+ '',
74
+ '| Song / Item | Provider | Asset ID | Creator | License | Link |',
75
+ '|---|---|---|---|---|---|',
76
+ ];
77
+
78
+ for (const item of resolvedItems) {
79
+ const rm = item.resolvedMedia;
80
+ if (!rm) continue;
81
+ const songName = item.songKey || 'Item';
82
+ const creator = rm.creator?.name || (rm.credit ? rm.credit.replace(/^.* by (.*) on .*$/, '$1') : 'Creator');
83
+ lines.push(
84
+ `| \`${songName}\` | ${rm.provider} | ${rm.assetId} | ${creator} | ${rm.license} | [View Asset](${rm.pageUrl}) |`
85
+ );
86
+ }
87
+
88
+ lines.push('');
89
+ fs.writeFileSync(attributionsPath, lines.join('\n'), 'utf-8');
90
+ }
91
+
92
+ /**
93
+ * Find local audio file for a given song key or file entry.
94
+ *
95
+ * @param {string} targetDir
96
+ * @param {string} key
97
+ * @returns {string | null}
98
+ */
99
+ function findAudioFile(targetDir, key) {
100
+ const directPath = path.isAbsolute(key) ? key : path.join(targetDir, key);
101
+ const ext = path.extname(directPath).toLowerCase();
102
+ if (['.wav', '.mp3', '.m4a', '.flac', '.aac', '.ogg'].includes(ext) && fs.existsSync(directPath)) {
103
+ return directPath;
104
+ }
105
+
106
+ // Check same basename with audio extensions
107
+ const baseName = path.basename(key, ext);
108
+ const audioExtensions = ['.wav', '.mp3', '.m4a', '.flac', '.aac', '.ogg'];
109
+ for (const aExt of audioExtensions) {
110
+ const candidate = path.join(targetDir, baseName + aExt);
111
+ if (fs.existsSync(candidate)) {
112
+ return candidate;
113
+ }
114
+ }
115
+
116
+ return null;
117
+ }
118
+
119
+ /**
120
+ * Orchestrate media resolution for a metadata.json file.
121
+ *
122
+ * @param {string} metadataFilePathOrDir
123
+ * @param {object} [options]
124
+ * @param {boolean} [options.compose=false]
125
+ * @param {boolean} [options.force=false]
126
+ * @param {boolean} [options.dryRun=false]
127
+ * @param {boolean} [options.allowRepeatMedia=false]
128
+ * @param {string} [options.provider]
129
+ * @param {'video' | 'photo'} [options.type]
130
+ * @param {'landscape' | 'portrait' | 'square'} [options.orientation]
131
+ * @param {string} [options.cacheDir]
132
+ * @param {string} [options.pexelsKey]
133
+ * @param {string} [options.pixabayKey]
134
+ * @param {(event: object) => void} [options.onEvent]
135
+ * @returns {Promise<{
136
+ * resolved: Array<{ songKey: string, resolvedMedia: object, generatedVideoPath?: string }>,
137
+ * skipped: Array<{ songKey: string, reason: string }>,
138
+ * errors: Array<{ songKey: string, error: string }>
139
+ * }>}
140
+ */
141
+ export async function resolveMediaForMetadata(metadataFilePathOrDir, options = {}) {
142
+ const inputPath = path.resolve(metadataFilePathOrDir || process.cwd());
143
+ const stat = fs.existsSync(inputPath) ? fs.statSync(inputPath) : null;
144
+
145
+ let metadataPath = inputPath;
146
+ let targetDir = inputPath;
147
+
148
+ if (stat && stat.isDirectory()) {
149
+ metadataPath = path.join(inputPath, 'metadata.json');
150
+ targetDir = inputPath;
151
+ } else {
152
+ targetDir = path.dirname(inputPath);
153
+ }
154
+
155
+ if (!fs.existsSync(metadataPath)) {
156
+ throw new Error(`Metadata file not found: ${metadataPath}`);
157
+ }
158
+
159
+ const rawConfig = JSON.parse(fs.readFileSync(metadataPath, 'utf-8'));
160
+ const mediaPolicy = rawConfig.mediaPolicy || {};
161
+ const cacheDir = path.resolve(targetDir, options.cacheDir || mediaPolicy.cacheDir || DEFAULT_MEDIA_CACHE_DIR);
162
+
163
+ const defaultProvider = options.provider || mediaPolicy.provider || 'auto';
164
+ const defaultType = options.type || mediaPolicy.type || 'video';
165
+ const defaultOrientation = options.orientation || mediaPolicy.orientation || 'landscape';
166
+ const defaultAvoidReuse = options.allowRepeatMedia ? false : (mediaPolicy.avoidReuse !== false);
167
+ const defaultMergeStrategy = mediaPolicy.mergeStrategy || 'fallback';
168
+ const defaultProviderOrder = mediaPolicy.providerOrder || ['pexels', 'pixabay'];
169
+ const minWidth = mediaPolicy.minWidth || 1280;
170
+ const minHeight = mediaPolicy.minHeight || 720;
171
+ const safeSearch = mediaPolicy.safeSearch !== false;
172
+ const composeMode = mediaPolicy.composeMode || 'video-loop';
173
+
174
+ const usedLedger = readUsedAssetsLedger(cacheDir);
175
+ const inMemoryUsedSet = new Set(Object.keys(usedLedger));
176
+
177
+ const resolved = [];
178
+ const skipped = [];
179
+ const errors = [];
180
+ const allResolvedForAttributions = [];
181
+
182
+ const emit = (event) => {
183
+ if (typeof options.onEvent === 'function') {
184
+ options.onEvent(event);
185
+ }
186
+ };
187
+
188
+ // Determine items to process: rich `songs` schema or flat `videos` array
189
+ const isSongsSchema = Boolean(rawConfig.songs && typeof rawConfig.songs === 'object' && !Array.isArray(rawConfig.songs));
190
+ const itemsToProcess = [];
191
+
192
+ if (isSongsSchema) {
193
+ const keys = Array.isArray(rawConfig.playlist?.orderedSongKeys)
194
+ ? rawConfig.playlist.orderedSongKeys
195
+ : Object.keys(rawConfig.songs);
196
+ for (const key of keys) {
197
+ if (rawConfig.songs[key]) {
198
+ itemsToProcess.push({ key, data: rawConfig.songs[key], isSong: true });
199
+ }
200
+ }
201
+ // Any remaining songs
202
+ for (const [key, data] of Object.entries(rawConfig.songs)) {
203
+ if (!itemsToProcess.some(i => i.key === key)) {
204
+ itemsToProcess.push({ key, data, isSong: true });
205
+ }
206
+ }
207
+ } else if (Array.isArray(rawConfig.videos)) {
208
+ rawConfig.videos.forEach((vid, idx) => {
209
+ itemsToProcess.push({ key: vid.filename || `video_${idx}`, data: vid, isSong: false, index: idx });
210
+ });
211
+ }
212
+
213
+ for (const item of itemsToProcess) {
214
+ const songKey = item.key;
215
+ const songData = item.data;
216
+ const existingResolved = songData.resolvedMedia;
217
+
218
+ // Check if existing file is already a valid .mp4 on disk
219
+ const filename = songData.filename || songKey;
220
+ const absoluteVideoPath = path.isAbsolute(filename) ? filename : path.join(targetDir, filename);
221
+ const hasExistingVideo = fs.existsSync(absoluteVideoPath) && absoluteVideoPath.endsWith('.mp4');
222
+
223
+ if (existingResolved && !options.force) {
224
+ skipped.push({ songKey, reason: 'Already has resolvedMedia' });
225
+ emit({ type: 'item_skipped', songKey, reason: 'Already resolved' });
226
+ allResolvedForAttributions.push({ songKey, resolvedMedia: existingResolved });
227
+ continue;
228
+ }
229
+
230
+ if (hasExistingVideo && !songData.media?.autoResolve && !options.force && !songData.autoResolve) {
231
+ skipped.push({ songKey, reason: 'Existing local video file already present' });
232
+ emit({ type: 'item_skipped', songKey, reason: 'Existing video on disk' });
233
+ continue;
234
+ }
235
+
236
+ const itemMediaConfig = songData.media || {};
237
+ const itemQuery = itemMediaConfig.query || deriveSearchQuery({
238
+ title: songData.youtube?.snippet?.title || songData.title || songKey,
239
+ tags: songData.youtube?.snippet?.tags || songData.tags,
240
+ artist: rawConfig.album?.artist || rawConfig.artist,
241
+ album: rawConfig.album,
242
+ filename: songKey,
243
+ });
244
+
245
+ const itemType = itemMediaConfig.type || defaultType;
246
+ const itemOrientation = itemMediaConfig.orientation || defaultOrientation;
247
+ const itemProvider = itemMediaConfig.provider || defaultProvider;
248
+
249
+ // Check audio duration if local audio file exists
250
+ let audioDuration = undefined;
251
+ const audioFilePath = findAudioFile(targetDir, songKey);
252
+ if (audioFilePath) {
253
+ try {
254
+ audioDuration = await getMediaDuration(audioFilePath);
255
+ } catch {}
256
+ }
257
+
258
+ emit({
259
+ type: 'search_start',
260
+ songKey,
261
+ query: itemQuery,
262
+ provider: itemProvider,
263
+ mediaType: itemType,
264
+ });
265
+
266
+ let searchResults = [];
267
+ try {
268
+ searchResults = await searchMedia({
269
+ query: itemQuery,
270
+ type: itemType,
271
+ orientation: itemOrientation,
272
+ provider: itemProvider,
273
+ mergeStrategy: defaultMergeStrategy,
274
+ providerOrder: defaultProviderOrder,
275
+ safeSearch,
276
+ cacheDir,
277
+ pexelsKey: options.pexelsKey,
278
+ pixabayKey: options.pixabayKey,
279
+ });
280
+
281
+ // If no results, retry with broadened query (drop last word)
282
+ if (searchResults.length === 0 && itemQuery.includes(' ')) {
283
+ const shortened = itemQuery.split(' ').slice(0, -1).join(' ');
284
+ if (shortened.trim()) {
285
+ searchResults = await searchMedia({
286
+ query: shortened,
287
+ type: itemType,
288
+ orientation: itemOrientation,
289
+ provider: itemProvider,
290
+ mergeStrategy: defaultMergeStrategy,
291
+ providerOrder: defaultProviderOrder,
292
+ safeSearch,
293
+ cacheDir,
294
+ pexelsKey: options.pexelsKey,
295
+ pixabayKey: options.pixabayKey,
296
+ });
297
+ }
298
+ }
299
+ } catch (err) {
300
+ errors.push({ songKey, error: `Search failed: ${err.message}` });
301
+ emit({ type: 'item_error', songKey, error: err.message });
302
+ songData.needsManualReview = true;
303
+ continue;
304
+ }
305
+
306
+ if (searchResults.length === 0) {
307
+ errors.push({ songKey, error: `No stock media found for query: "${itemQuery}"` });
308
+ emit({ type: 'item_error', songKey, error: `No results for "${itemQuery}"` });
309
+ songData.needsManualReview = true;
310
+ continue;
311
+ }
312
+
313
+ const rankingCriteria = {
314
+ orientation: itemOrientation,
315
+ targetDuration: audioDuration,
316
+ minWidth,
317
+ minHeight,
318
+ avoidReuse: defaultAvoidReuse,
319
+ cacheDir,
320
+ usedAssetSet: inMemoryUsedSet,
321
+ providerOrder: defaultProviderOrder,
322
+ };
323
+
324
+ const ranked = rankCandidates(searchResults, rankingCriteria);
325
+ if (ranked.length === 0) {
326
+ errors.push({ songKey, error: 'All search candidates were filtered out by criteria or reuse policy.' });
327
+ emit({ type: 'item_error', songKey, error: 'All candidates excluded' });
328
+ songData.needsManualReview = true;
329
+ continue;
330
+ }
331
+
332
+ emit({
333
+ type: 'search_results',
334
+ songKey,
335
+ totalFound: searchResults.length,
336
+ topCandidate: ranked[0].candidate,
337
+ score: ranked[0].score,
338
+ });
339
+
340
+ if (options.dryRun) {
341
+ const top = ranked[0].candidate;
342
+ const credit = formatMediaCredit(top);
343
+ resolved.push({
344
+ songKey,
345
+ resolvedMedia: {
346
+ provider: top.provider,
347
+ assetId: top.id,
348
+ type: top.type,
349
+ pageUrl: top.pageUrl,
350
+ localPath: `.media-cache/assets/${top.provider}-${top.id}.${top.type === 'video' ? 'mp4' : 'jpg'}`,
351
+ width: top.width,
352
+ height: top.height,
353
+ duration: top.duration,
354
+ license: top.license,
355
+ credit,
356
+ resolvedAt: new Date().toISOString(),
357
+ },
358
+ });
359
+ continue;
360
+ }
361
+
362
+ // Try downloading candidates in ranked order until one succeeds
363
+ let downloadedAsset = null;
364
+ let winner = null;
365
+ for (const itemRanked of ranked) {
366
+ const candidate = itemRanked.candidate;
367
+ try {
368
+ emit({ type: 'download_start', songKey, assetId: candidate.id, provider: candidate.provider });
369
+ const downloadRes = await downloadAsset(candidate, { cacheDir });
370
+ downloadedAsset = downloadRes;
371
+ winner = candidate;
372
+ emit({ type: 'download_success', songKey, localPath: downloadRes.localPath });
373
+ break;
374
+ } catch (dlErr) {
375
+ // Try next candidate
376
+ }
377
+ }
378
+
379
+ if (!downloadedAsset || !winner) {
380
+ errors.push({ songKey, error: 'Failed to download any candidate asset.' });
381
+ emit({ type: 'item_error', songKey, error: 'Download failed for all candidates' });
382
+ songData.needsManualReview = true;
383
+ continue;
384
+ }
385
+
386
+ // Mark asset used in memory & ledger
387
+ const assetKey = `${winner.provider}:${winner.id}`;
388
+ inMemoryUsedSet.add(assetKey);
389
+ recordUsedAsset(cacheDir, {
390
+ provider: winner.provider,
391
+ assetId: winner.id,
392
+ songKey,
393
+ });
394
+
395
+ const credit = formatMediaCredit(winner);
396
+ const resolvedRecord = {
397
+ provider: winner.provider,
398
+ assetId: winner.id,
399
+ type: winner.type,
400
+ pageUrl: winner.pageUrl,
401
+ localPath: path.relative(targetDir, downloadedAsset.localPath),
402
+ width: winner.width,
403
+ height: winner.height,
404
+ duration: winner.duration,
405
+ license: winner.license,
406
+ credit,
407
+ resolvedAt: new Date().toISOString(),
408
+ };
409
+
410
+ let generatedVideoPath = undefined;
411
+ const shouldCompose = options.compose || Boolean(options.autoMedia);
412
+
413
+ if (shouldCompose) {
414
+ if (!audioFilePath) {
415
+ errors.push({ songKey, error: `Cannot compose video: audio file not found for ${songKey}` });
416
+ emit({ type: 'item_error', songKey, error: `Audio file not found for ${songKey}` });
417
+ } else {
418
+ const baseName = path.basename(songKey, path.extname(songKey));
419
+ const outName = `${baseName}.generated.mp4`;
420
+ const outputPath = path.join(targetDir, outName);
421
+
422
+ emit({ type: 'compose_start', songKey, audioFile: audioFilePath, background: downloadedAsset.localPath });
423
+ try {
424
+ const compResult = await composeVideo({
425
+ backgroundPath: downloadedAsset.localPath,
426
+ audioPath: audioFilePath,
427
+ outputPath,
428
+ mode: winner.type === 'photo' ? 'ken-burns' : composeMode,
429
+ targetWidth: 1920,
430
+ targetHeight: 1080,
431
+ });
432
+ generatedVideoPath = compResult.outputPath;
433
+ emit({ type: 'compose_success', songKey, outputPath });
434
+
435
+ // Update song filename in metadata to point to generated video
436
+ if (item.isSong) {
437
+ // Update filename if present or store generated path
438
+ songData.filename = outName;
439
+ } else {
440
+ songData.filename = outName;
441
+ }
442
+ } catch (composeErr) {
443
+ errors.push({ songKey, error: `Composition failed: ${composeErr.message}` });
444
+ emit({ type: 'item_error', songKey, error: `Composition failed: ${composeErr.message}` });
445
+ }
446
+ }
447
+ }
448
+
449
+ songData.resolvedMedia = resolvedRecord;
450
+ delete songData.needsManualReview;
451
+
452
+ resolved.push({
453
+ songKey,
454
+ resolvedMedia: resolvedRecord,
455
+ generatedVideoPath,
456
+ });
457
+ allResolvedForAttributions.push({
458
+ songKey,
459
+ resolvedMedia: resolvedRecord,
460
+ });
461
+
462
+ emit({ type: 'item_resolved', songKey, resolvedMedia: resolvedRecord });
463
+ }
464
+
465
+ // Save metadata.json if not dryRun
466
+ if (!options.dryRun && resolved.length > 0) {
467
+ fs.writeFileSync(metadataPath, JSON.stringify(rawConfig, null, 2), 'utf-8');
468
+ writeAttributionsFile(targetDir, allResolvedForAttributions);
469
+ }
470
+
471
+ return {
472
+ resolved,
473
+ skipped,
474
+ errors,
475
+ };
476
+ }
@@ -0,0 +1,176 @@
1
+ import { isAssetUsed, DEFAULT_MEDIA_CACHE_DIR } from './cache.js';
2
+
3
+ /**
4
+ * Calculate candidate score breakdown and weighted total score.
5
+ *
6
+ * @param {import('../providers/index.js').UnifiedResult} candidate
7
+ * @param {object} criteria
8
+ * @returns {{ score: number, breakdown: { rank: number, resolution: number, orientation: number, duration: number, novelty: number } }}
9
+ */
10
+ export function scoreCandidate(candidate, criteria = {}) {
11
+ const {
12
+ preferredWidth = 1920,
13
+ preferredHeight = 1080,
14
+ minWidth = 1280,
15
+ minHeight = 720,
16
+ orientation = 'landscape',
17
+ targetDuration,
18
+ cacheDir = DEFAULT_MEDIA_CACHE_DIR,
19
+ usedAssetSet,
20
+ } = criteria;
21
+
22
+ // 1. Provider Relevance Rank (weight 0.40)
23
+ const rank = typeof candidate.rank === 'number' ? candidate.rank : 0;
24
+ const rankScore = 1 / (1 + rank);
25
+
26
+ // 2. Resolution (weight 0.20)
27
+ const preferredArea = preferredWidth * preferredHeight;
28
+ const minArea = minWidth * minHeight;
29
+ const candidateArea = (candidate.width || 0) * (candidate.height || 0);
30
+
31
+ let resolutionScore = 1.0;
32
+ if (candidateArea >= preferredArea) {
33
+ resolutionScore = 1.0;
34
+ } else if (candidateArea <= minArea) {
35
+ resolutionScore = 0.0;
36
+ } else if (preferredArea > minArea) {
37
+ resolutionScore = (candidateArea - minArea) / (preferredArea - minArea);
38
+ }
39
+
40
+ // 3. Orientation Match (weight 0.15)
41
+ let orientationScore = 0.0;
42
+ if (candidate.orientation === orientation) {
43
+ orientationScore = 1.0;
44
+ } else if (candidate.orientation === 'square' || orientation === 'square') {
45
+ orientationScore = 0.3;
46
+ } else {
47
+ orientationScore = 0.0;
48
+ }
49
+
50
+ // 4. Duration Fit (weight 0.20) - Video only
51
+ let durationScore = 1.0;
52
+ if (candidate.type === 'video' && targetDuration && typeof candidate.duration === 'number') {
53
+ const dur = candidate.duration;
54
+ if (dur >= targetDuration && dur <= targetDuration * 1.5) {
55
+ durationScore = 1.0;
56
+ } else if (dur < targetDuration) {
57
+ durationScore = Math.max(0, dur / targetDuration);
58
+ } else {
59
+ // dur > targetDuration * 1.5
60
+ const overflow = dur - (targetDuration * 1.5);
61
+ durationScore = Math.max(0, 1 - (overflow / (targetDuration * 3)));
62
+ }
63
+ }
64
+
65
+ // 5. Novelty (weight 0.05)
66
+ let noveltyScore = 1.0;
67
+ const isUsed = usedAssetSet
68
+ ? usedAssetSet.has(`${candidate.provider}:${candidate.id}`)
69
+ : isAssetUsed(cacheDir, candidate.provider, candidate.id);
70
+
71
+ if (isUsed) {
72
+ noveltyScore = 0.0;
73
+ }
74
+
75
+ const totalScore = Math.round((
76
+ 0.40 * rankScore +
77
+ 0.20 * resolutionScore +
78
+ 0.15 * orientationScore +
79
+ 0.20 * durationScore +
80
+ 0.05 * noveltyScore
81
+ ) * 1e6) / 1e6;
82
+
83
+ return {
84
+ score: totalScore,
85
+ breakdown: {
86
+ rank: Math.round(rankScore * 1e6) / 1e6,
87
+ resolution: Math.round(resolutionScore * 1e6) / 1e6,
88
+ orientation: Math.round(orientationScore * 1e6) / 1e6,
89
+ duration: Math.round(durationScore * 1e6) / 1e6,
90
+ novelty: Math.round(noveltyScore * 1e6) / 1e6,
91
+ },
92
+ };
93
+ }
94
+
95
+ /**
96
+ * Filter and rank all candidate media results.
97
+ *
98
+ * @param {import('../providers/index.js').UnifiedResult[]} results
99
+ * @param {object} criteria
100
+ * @returns {Array<{ candidate: import('../providers/index.js').UnifiedResult, score: number, breakdown: any }>}
101
+ */
102
+ export function rankCandidates(results = [], criteria = {}) {
103
+ const {
104
+ avoidReuse = true,
105
+ cacheDir = DEFAULT_MEDIA_CACHE_DIR,
106
+ usedAssetSet,
107
+ minDuration,
108
+ maxDuration,
109
+ minWidth,
110
+ minHeight,
111
+ providerOrder = ['pexels', 'pixabay'],
112
+ } = criteria;
113
+
114
+ // Filter candidates
115
+ const filtered = results.filter((candidate) => {
116
+ if (!candidate || !candidate.id) return false;
117
+
118
+ // Novelty hard exclusion
119
+ if (avoidReuse) {
120
+ const isUsed = usedAssetSet
121
+ ? usedAssetSet.has(`${candidate.provider}:${candidate.id}`)
122
+ : isAssetUsed(cacheDir, candidate.provider, candidate.id);
123
+ if (isUsed) return false;
124
+ }
125
+
126
+ // Min dimensions check
127
+ if (minWidth && candidate.width && candidate.width < minWidth) return false;
128
+ if (minHeight && candidate.height && candidate.height < minHeight) return false;
129
+
130
+ // Min/Max duration check for video
131
+ if (candidate.type === 'video' && typeof candidate.duration === 'number') {
132
+ if (minDuration && candidate.duration < minDuration) return false;
133
+ if (maxDuration && candidate.duration > maxDuration) return false;
134
+ }
135
+
136
+ return true;
137
+ });
138
+
139
+ const scored = filtered.map((candidate) => {
140
+ const { score, breakdown } = scoreCandidate(candidate, criteria);
141
+ return { candidate, score, breakdown };
142
+ });
143
+
144
+ // Sort descending by score, tie-break with providerOrder and original rank
145
+ scored.sort((a, b) => {
146
+ const scoreDiff = b.score - a.score;
147
+ if (Math.abs(scoreDiff) > 0.0001) {
148
+ return scoreDiff;
149
+ }
150
+
151
+ const provIndexA = providerOrder.indexOf(a.candidate.provider);
152
+ const provIndexB = providerOrder.indexOf(b.candidate.provider);
153
+ const resolvedProvA = provIndexA === -1 ? 99 : provIndexA;
154
+ const resolvedProvB = provIndexB === -1 ? 99 : provIndexB;
155
+
156
+ if (resolvedProvA !== resolvedProvB) {
157
+ return resolvedProvA - resolvedProvB;
158
+ }
159
+
160
+ return (a.candidate.rank ?? 0) - (b.candidate.rank ?? 0);
161
+ });
162
+
163
+ return scored;
164
+ }
165
+
166
+ /**
167
+ * Select the single best matching candidate.
168
+ *
169
+ * @param {import('../providers/index.js').UnifiedResult[]} results
170
+ * @param {object} criteria
171
+ * @returns {import('../providers/index.js').UnifiedResult | null}
172
+ */
173
+ export function selectBest(results = [], criteria = {}) {
174
+ const ranked = rankCandidates(results, criteria);
175
+ return ranked.length > 0 ? ranked[0].candidate : null;
176
+ }