Implement sidecar video thumbnails and improved processing UI

- Implemented "sidecar" thumbnail logic: imports video thumbnails from Google Photos as hidden Drive files to display immediately while videos process.
- Updated MediaService to serve sidecar thumbnails via server-side Base64 encoding, bypassing CORS restrictions.
- Implemented lifecycle management: detects video processing completion to automatically cleanup sidecar files and fallback to native Drive thumbnails.
- Enhanced Media Manager UI: added processing warning banner and refined processing tile styling (centered, lighter overlay).
- Upgraded Drive API to v3 and improved file creation robustness with Advanced API fallbacks.
This commit is contained in:
Ben Miller
2025-12-30 23:41:59 -07:00
parent bade8a3020
commit 690f8c5c38
7 changed files with 358 additions and 95 deletions

View File

@ -70,6 +70,9 @@ export class MediaService {
// 1. Get Drive Files
const folder = this.driveService.getOrCreateFolder(sku, this.config.productPhotosFolderId)
// We need strict file list.
// Optimization: getFiles() usually returns limited info.
// We might need to iterate and pull props if getFiles() doesn't include appProperties (DriveApp doesn't).
const driveFiles = this.driveService.getFiles(folder.getId())
// 2. Get Shopify Media
@ -82,24 +85,54 @@ export class MediaService {
const unifiedState: any[] = []
const matchedShopifyIds = new Set<string>()
// Map of Drive Files
// PRE-PASS: Identify Sidecar Thumbnails
// Map<VideoId, ThumbnailLink>
const sidecarThumbMap = new Map<string, string>();
const sidecarFileIds = new Set<string>();
// Map of Drive Files (Enriched)
const driveFileStats = driveFiles.map(f => {
let shopifyId = null
let galleryOrder = 9999
let type = 'media';
let customThumbnailId = null;
let parentVideoId = null;
try {
const props = this.driveService.getFileProperties(f.getId())
if (props['shopify_media_id']) {
shopifyId = props['shopify_media_id']
}
if (props['gallery_order']) {
galleryOrder = parseInt(props['gallery_order'])
}
if (props['shopify_media_id']) shopifyId = props['shopify_media_id']
if (props['gallery_order']) galleryOrder = parseInt(props['gallery_order'])
if (props['type']) type = props['type'];
if (props['custom_thumbnail_id']) customThumbnailId = props['custom_thumbnail_id'];
if (props['parent_video_id']) parentVideoId = props['parent_video_id'];
} catch (e) {
console.warn(`Failed to get properties for ${f.getName()}`)
}
return { file: f, shopifyId, galleryOrder }
return { file: f, shopifyId, galleryOrder, type, customThumbnailId, parentVideoId }
})
// Populate Sidecar Map
driveFileStats.forEach(stat => {
if (stat.type === 'thumbnail' && stat.parentVideoId) {
sidecarFileIds.add(stat.file.getId());
// URL-based approach failed (CORS/Auth).
// Switch to Server-Side Base64 encoding (Robust).
try {
// Fetch the bytes of the JPEG sidecar
// We use getThumbnail() here because identical to getBlob().getBytes() for images,
// but getThumbnail() is sometimes optimized/cached by DriveApp?
// actually getBlob() is safer for the "original" sidecar content.
const bytes = stat.file.getBlob().getBytes();
const b64 = Utilities.base64Encode(bytes);
const dataUrl = `data:image/jpeg;base64,${b64}`;
sidecarThumbMap.set(stat.parentVideoId, dataUrl);
} catch (e) {
console.warn(`[MediaService] Failed to read sidecar file ${stat.file.getName()}: ${e}`);
}
}
});
// Sort: Gallery Order ASC, then Filename ASC
driveFileStats.sort((a, b) => {
if (a.galleryOrder !== b.galleryOrder) {
@ -108,8 +141,12 @@ export class MediaService {
return a.file.getName().localeCompare(b.file.getName())
})
// Match Logic (Strict ID Match Only)
driveFileStats.forEach(d => {
// Skip Sidecar Files in main list
if (sidecarFileIds.has(d.file.getId())) return;
let match = null
let isProcessing = false
let thumbnail = "";
@ -120,21 +157,102 @@ export class MediaService {
if (match) matchedShopifyIds.add(match.id)
}
// NO Filename Fallback matching per new design "Strict Linkage"
// Thumbnail Logic
if (match && match.preview && match.preview.image && match.preview.image.originalSrc) {
thumbnail = match.preview.image.originalSrc;
} else {
// Drive Thumbnail Strategy
// Determine if Native Drive Thumbnail is ready/valid
let nativeThumbReady = false;
let nativeThumbUrl = "";
try {
// Try to get Drive thumbnail
thumbnail = `data:image/jpeg;base64,${Utilities.base64Encode(d.file.getThumbnail().getBytes())}`;
// We assume if getThumbnail() succeeds and returns "substantial" data, it's ready.
// Or check availability of thumbnailLink if we had used Advanced API.
// Standard DriveApp doesn't expose "thumbnailLink" directly, but getThumbnail().
// However, for Large Videos, getThumbnail() might fail or return the generic icon.
// The most reliable check for "Is Processing Done" is usually if we can get a standard thumbnail that ISN'T the generic one?
// Hard to tell generic from bytes.
// Alternative: If we have a Sidecar, WE ARE IN CHARGE.
// We only switch if we are SURE.
// Let's us try to fetch the thumbnail bytes.
const thumbBlob = d.file.getThumbnail();
if (thumbBlob && thumbBlob.getContentType() !== 'application/vnd.google-apps.folder') {
// Check size? Generic icons are small?
// Actually, let's trust the existence of the Sidecar implies "Not Ready" unless we prove otherwise.
// But we want to CLEANUP.
// Let's use Advanced Drive API to check `thumbnailLink` existence for this specific file, if it has a sidecar.
// This minimizes API calls to ONLY when we have a sidecar candidate.
if (sidecarThumbMap.has(d.file.getId())) {
const fileId = d.file.getId();
// @ts-ignore
const drive = Drive;
const meta = drive.Files.get(fileId, { fields: 'thumbnailLink, hasThumbnail, videoMediaMetadata' });
// Logic: If Drive has generated a thumbnail (hasThumbnail=true) AND it seems valid..
// Note: Drive sets hasThumbnail=true even for generic icons sometimes?
// But `thumbnailLink` definitely exists.
// For videos, `videoMediaMetadata` might NOT have 'width' while processing?
// Let's check `videoMediaMetadata.width`.
if (meta.thumbnailLink && meta.videoMediaMetadata && meta.videoMediaMetadata.width) {
// SUCCESS: Drive has finished processing (we have dimensions).
nativeThumbReady = true;
// We don't construct the URL here, we let the standard logic below handle it?
// No, we need the bytes for the frontend or a link.
// `thumbnailLink` is short lived.
// Let's use the native generation below.
console.log(`[MediaService] Video ${d.file.getName()} finished processing. Cleaning sidecar.`);
// Cleanup Sidecar Loop
// TRASH the sidecar file.
// We need the sidecar ID. We have to map IDs or iterate.
// Optimization: We didn't store Sidecar ID in the simpler Map.
// Let's find it.
const sidecarId = Array.from(sidecarFileIds).find(id => {
// This is slow: O(N) lookup.
// But we only do this ONCE per file lifecycle.
// Actually better to store ID in map?
// Let's just find the file in `driveFiles` that corresponds.
// We have `d.customThumbnailId`!
return id === d.customThumbnailId;
});
if (sidecarId) {
try {
this.driveService.trashFile(sidecarId);
sidecarFileIds.delete(sidecarId); // Remove from set so we don't trip later
sidecarThumbMap.delete(d.file.getId());
console.log(`[MediaService] Trashed sidecar ${sidecarId}`);
} catch (trashErr) {
console.warn(`[MediaService] Failed to trash sidecar ${sidecarId}`, trashErr);
}
}
}
}
}
} catch (e) {
console.warn(`Failed to get thumbnail for ${d.file.getName()} (likely processing): ${e}`);
// Return a generic placeholder (Gray 1x1 pixel) + Flag as processing
// thumbnail = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=";
// Better placeholder: https://ssl.gstatic.com/docs/doclist/images/icon_10_movie_list.png (Video Icon) or just gray
thumbnail = "https://ssl.gstatic.com/docs/doclist/images/icon_128_video_blue.png"; // Official Video Icon
isProcessing = true;
// Ignore
}
// 1. Check Sidecar (If it still exists after potential cleanup)
if (sidecarThumbMap.has(d.file.getId())) {
console.log(`[MediaService] Using Sidecar Thumbnail for ${d.file.getName()}`);
thumbnail = sidecarThumbMap.get(d.file.getId()) || "";
isProcessing = true; // SHOW HOURGLASS (Request #3)
} else {
// 2. Native / Fallback
try {
// Try to get Drive thumbnail
const nativeThumb = `data:image/jpeg;base64,${Utilities.base64Encode(d.file.getThumbnail().getBytes())}`;
if (nativeThumb.length > 100) { // Check if valid (sometimes returns empty?)
thumbnail = nativeThumb;
}
} catch (e) {
// Processing / Error
console.warn(`Failed to get native thumbnail for ${d.file.getName()}: ${e}`);
isProcessing = true; // Assume processing
thumbnail = "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIGhlaWdodD0iNDgiIHdpZHRoPSI0OCIgdmlld0JveD0iMCAwIDQ4IDQ4Ij48cGF0aCBmaWxsPSIjNDI4NUY0IiBkPSJNMzYgOEgxMmMtMi4yMSAwLTQgMS43OS00IDR2MjRjMCAyLjIxIDEuNzkgNCA0IDRoMjRjMi4yMSAwIDQtMS43OSA0LTRWMTJjMC0yLjIxLTEuNzktNC00LTR6TTIwIDMxVjE3bDEyIDctMTIgN3oiLz48L3N2Zz4=";
}
}
}
@ -154,7 +272,6 @@ export class MediaService {
: `https://drive.google.com/uc?export=download&id=${d.file.getId()}`,
isProcessing: isProcessing
})
// console.log(`[MediaService] File ${d.file.getName()} (${d.file.getId()}): Mime=${d.file.getMimeType()}, ContentUrl=https://drive.google.com/uc?export=download&id=${d.file.getId()}`)
})
// Find Shopify Orphans
@ -243,6 +360,24 @@ export class MediaService {
logs.push(`- Deleted from Shopify (${item.shopifyId})`)
}
if (item.driveId) {
// Check for Associated Sidecar Thumbs (Request #2)
try {
const f = driveSvc.getFileById(item.driveId);
// We could inspect properties, or just try to find based on convention if we don't have props handy.
// But `getUnifiedMediaState` logic shows we store `custom_thumbnail_id`.
// However, `item` here comes from `getUnifiedMediaState`, but DOES IT include the custom prop?
// Currently `unifiedState` items don't return `customThumbnailId` property explicitly in the Object.
// We should probably fetch it or have included it.
// Re-fetch props to be safe/clean.
const props = driveSvc.getFileProperties(item.driveId);
if (props && props['custom_thumbnail_id']) {
driveSvc.trashFile(props['custom_thumbnail_id']);
logs.push(`- Trashed associated Sidecar Thumbnail (${props['custom_thumbnail_id']})`);
}
} catch (ignore) {
// If file already gone or other error
}
driveSvc.trashFile(item.driveId)
logs.push(`- Trashed in Drive (${item.driveId})`)
}