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

@ -152,100 +152,120 @@ export function importFromPicker(sku: string, fileId: string, mimeType: string,
// STEP 1: Acquire/Create File in Root (Safe Zone)
let finalFile: GoogleAppsScript.Drive.File;
let sidecarThumbFile: GoogleAppsScript.Drive.File | null = null;
try {
if (fileId && !imageUrl) {
// Case A: Existing Drive File (Copy it)
// Note: makeCopy(name) w/o folder argument copies to the same parent as original usually, or root?
// Actually explicitly copying to Root is safer for "new" file.
const source = DriveApp.getFileById(fileId);
finalFile = source.makeCopy(name); // Default location
console.log(`Step 1 Success: Drive File copied to Root/Default. ID: ${finalFile.getId()}`);
} else if (imageUrl) {
console.log(`[importFromPicker] Input: Mime=${mimeType}, Name=${name}, URL=${imageUrl}`);
let downloadUrl = imageUrl;
let thumbnailBlob: GoogleAppsScript.Base.Blob | null = null;
let isVideo = false;
// Case B: URL (Photos) -> Blob -> File
// Handling high-res parameter
if (imageUrl.includes("googleusercontent.com")) {
if (mimeType && mimeType.startsWith("video/")) {
// For videos, =dv retrieves the actual video file (download video)
if (!imageUrl.includes("=dv")) {
imageUrl += "=dv";
}
isVideo = true;
// 1. Prepare Video Download URL
if (!downloadUrl.includes("=dv")) {
downloadUrl += "=dv";
}
// 2. Fetch Thumbnail for Sidecar
// Google Photos base URLs allow resizing.
const baseUrl = imageUrl.split('=')[0];
const thumbUrl = baseUrl + "=w600-h600-no"; // Clean frame
console.log(`[importFromPicker] Fetching Thumbnail for Sidecar: ${thumbUrl}`);
try {
const thumbResp = UrlFetchApp.fetch(thumbUrl, {
headers: { Authorization: `Bearer ${ScriptApp.getOAuthToken()}` },
muteHttpExceptions: true
});
if (thumbResp.getResponseCode() === 200) {
// Force JPEG
thumbnailBlob = thumbResp.getBlob().getAs(MimeType.JPEG);
} else {
console.warn(`Failed to fetch thumbnail: ${thumbResp.getResponseCode()}`);
}
} catch (e) {
console.warn("Thumbnail fetch failed", e);
}
} else {
// For images, =d retrieves the full download
if (!imageUrl.includes("=d")) {
imageUrl += "=d";
// Images
if (!downloadUrl.includes("=d")) {
downloadUrl += "=d";
}
}
}
console.log(`[importFromPicker] Fetching URL: ${imageUrl}`);
const response = UrlFetchApp.fetch(imageUrl, {
// 3. Download Main Content
console.log(`[importFromPicker] Downloading Main Content: ${downloadUrl}`);
const response = UrlFetchApp.fetch(downloadUrl, {
headers: {
Authorization: `Bearer ${ScriptApp.getOAuthToken()}`
},
muteHttpExceptions: true
});
console.log(`Download Response Code: ${response.getResponseCode()}`);
if (response.getResponseCode() !== 200) {
const errorBody = response.getContentText().substring(0, 500);
throw new Error(`Request failed for ${imageUrl} returned code ${response.getResponseCode()}. Truncated server response: ${errorBody}`);
throw new Error(`Request failed for ${downloadUrl} returned code ${response.getResponseCode()}. Truncated server response: ${errorBody}`);
}
const blob = response.getBlob();
console.log(`Blob Content-Type: ${blob.getContentType()}`);
let fileName = name || `photo_${Date.now()}.jpg`;
// Fix Filename Extension if MimeType mismatch
// (e.g. we downloaded a video, but filename is .jpg)
if (blob.getContentType().startsWith('video/') && fileName.match(/\.jpg|\.png|\.jpeg$/i)) {
console.log(`[importFromPicker] Filename extension correction needed for video. Old: ${fileName}`);
fileName = fileName.replace(/\.[^/.]+$/, "") + ".mp4";
console.log(`[importFromPicker] New Filename: ${fileName}`);
}
blob.setName(fileName);
// 4. Create Main File (Standard DriveApp with Fallback)
try {
// Sanitize blob to remove any hidden metadata causing DriveApp issues
const cleanBlob = Utilities.newBlob(blob.getBytes(), blob.getContentType(), fileName);
finalFile = DriveApp.createFile(cleanBlob); // Creates in Root
console.log(`Step 1 Success: File created in Root. ID: ${finalFile.getId()}, Mime: ${finalFile.getMimeType()}`);
finalFile = DriveApp.createFile(blob);
} catch (createErr) {
console.warn("DriveApp.createFile failed with clean blob. Trying Advanced Drive API...", createErr);
try {
// Fallback to Advanced Drive Service (v3 usually, or v2)
// Note: v2 uses 'insert' & 'title', v3 uses 'create' & 'name'
// We try v3 first as it's the modern default.
console.warn("Standard DriveApp.createFile failed, trying Advanced Drive API...", createErr);
if (typeof Drive !== 'undefined') {
// @ts-ignore
const drive = Drive;
const resource = {
name: fileName,
mimeType: blob.getContentType(),
description: `Source: ${imageUrl}`
};
const inserted = drive.Files.create(resource, blob);
finalFile = DriveApp.getFileById(inserted.id);
} else {
throw createErr;
}
}
if (typeof Drive === 'undefined') {
throw new Error("Advanced Drive Service is not enabled. Please enable 'Drive API' in Apps Script Services.");
}
finalFile.setDescription(`Source: ${imageUrl}`);
console.log(`Step 1 Success (Standard/Fallback): ID: ${finalFile.getId()}`);
const drive = Drive as any;
let insertedFile;
// 5. Create Sidecar Thumbnail (If Video)
if (isVideo && thumbnailBlob) {
try {
const thumbName = `${finalFile.getId()}_thumb.jpg`;
thumbnailBlob.setName(thumbName);
sidecarThumbFile = DriveApp.createFile(thumbnailBlob);
console.log(`Step 1b Success: Sidecar Thumbnail Created. ID: ${sidecarThumbFile.getId()}`);
if (drive.Files.create) {
// v3
const fileResource = { name: fileName, mimeType: blob.getContentType() };
insertedFile = drive.Files.create(fileResource, blob);
} else if (drive.Files.insert) {
// v2 fallback
const fileResource = { title: fileName, mimeType: blob.getContentType() };
insertedFile = drive.Files.insert(fileResource, blob);
} else {
throw new Error("Unknown Drive API version (neither create nor insert found).");
}
// Helper to ensure props are set (using Drive service directly if needed to avoid loops, but mediaHandlers uses initialized service)
// Link them
driveService.updateFileProperties(finalFile.getId(), { custom_thumbnail_id: sidecarThumbFile.getId() });
driveService.updateFileProperties(sidecarThumbFile.getId(), { type: 'thumbnail', parent_video_id: finalFile.getId() });
finalFile = DriveApp.getFileById(insertedFile.id);
console.log(`Step 1 Success (Advanced API): Photo downloaded to Root. ID: ${finalFile.getId()}`);
} catch (advErr) {
const metadata = `Type: ${blob.getContentType()}, Size: ${blob.getBytes().length}`;
console.error(`All file creation methods failed. Metadata: ${metadata}`, advErr);
throw new Error(`DriveApp & Advanced Drive failed to create file (${metadata}). Error: ${advErr.message}`);
}
} catch (thumbErr) {
console.error("Failed to create sidecar thumbnail", thumbErr);
}
}
} else {
@ -253,7 +273,7 @@ export function importFromPicker(sku: string, fileId: string, mimeType: string,
}
} catch (e) {
console.error("Step 1 Failed (File Creation)", e);
throw e; // Re-throw modified error
throw e;
}
// STEP 2: Get Target Folder
@ -263,20 +283,21 @@ export function importFromPicker(sku: string, fileId: string, mimeType: string,
console.log(`Step 2 Success: Target folder found/created. Name: ${folder.getName()}`);
} catch (e) {
console.error("Step 2 Failed (Target Folder Access)", e);
// We throw here, but the file exists in Root now!
throw new Error(`File saved to Drive Root, but failed to put in SKU folder: ${e.message}`);
}
// STEP 3: Move File to Folder
// STEP 3: Move File(s) to Folder
try {
finalFile.moveTo(folder);
console.log(`Step 3 Success: File moved to target folder.`);
if (sidecarThumbFile) {
sidecarThumbFile.moveTo(folder);
}
console.log(`Step 3 Success: Files moved to target folder.`);
} catch (e) {
console.error("Step 3 Failed (Move)", e);
throw new Error(`File created (ID: ${finalFile.getId()}), but failed to move to folder: ${e.message}`);
}
}