- Add brand_context field (job model, API, frontend form) so clients can list brand names present in their video; Gemini uses these names instead of generic descriptors (e.g. "Sellotape" not "sticky tape") - Add ethical guidelines section to both Gemini prompts covering person-first language, consistent race/gender description only when plot-relevant, no guessing at unconfirmed identity - Revamp audio description rules: priority ordering (essential → high-priority → time-permitting), pre-teaching placement, no cinematic jargon, succinct style replacing the former "20% longer" instruction - Thread brand_context through full stack: routes → job doc → ingest task → translate task → both Gemini prompt templates Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
289 lines
8.2 KiB
TypeScript
289 lines
8.2 KiB
TypeScript
import { useState, useCallback, useRef } from 'react';
|
|
import { apiClient } from '../lib/api';
|
|
import { generateFileId, generateTitleFromFilename } from '../lib/fileUtils';
|
|
import type { RequestedOutputs } from '../types/api';
|
|
import type { UploadStatus, UploadProgressItem } from '../components/UploadProgressList';
|
|
|
|
export interface FileListItem {
|
|
id: string;
|
|
file: File;
|
|
autoTitle: string;
|
|
}
|
|
|
|
export interface SharedJobSettings {
|
|
requestedOutputs: RequestedOutputs;
|
|
brandContext?: string;
|
|
}
|
|
|
|
interface UseMultiUploadOptions {
|
|
maxConcurrent?: number;
|
|
onJobCreated?: (jobId: string, filename: string) => void;
|
|
onAllComplete?: (results: UploadProgressItem[]) => void;
|
|
}
|
|
|
|
interface UseMultiUploadReturn {
|
|
// State
|
|
files: FileListItem[];
|
|
uploads: UploadProgressItem[];
|
|
isUploading: boolean;
|
|
isComplete: boolean;
|
|
|
|
// Actions
|
|
addFiles: (newFiles: File[]) => void;
|
|
removeFile: (id: string) => void;
|
|
clearFiles: () => void;
|
|
startUpload: (settings: SharedJobSettings) => Promise<UploadProgressItem[]>;
|
|
retryFailed: (settings: SharedJobSettings) => Promise<void>;
|
|
reset: () => void;
|
|
|
|
// Computed
|
|
totalFiles: number;
|
|
completedCount: number;
|
|
failedCount: number;
|
|
successCount: number;
|
|
}
|
|
|
|
export function useMultiUpload(options: UseMultiUploadOptions = {}): UseMultiUploadReturn {
|
|
const { maxConcurrent = 3, onJobCreated, onAllComplete } = options;
|
|
|
|
const [files, setFiles] = useState<FileListItem[]>([]);
|
|
const [uploads, setUploads] = useState<UploadProgressItem[]>([]);
|
|
const [isUploading, setIsUploading] = useState(false);
|
|
const [isComplete, setIsComplete] = useState(false);
|
|
|
|
// Store settings ref for retry
|
|
const settingsRef = useRef<SharedJobSettings | null>(null);
|
|
|
|
const addFiles = useCallback((newFiles: File[]) => {
|
|
const items: FileListItem[] = newFiles.map(file => ({
|
|
id: generateFileId(),
|
|
file,
|
|
autoTitle: generateTitleFromFilename(file.name),
|
|
}));
|
|
setFiles(prev => [...prev, ...items]);
|
|
// Reset complete state when adding new files
|
|
setIsComplete(false);
|
|
setUploads([]);
|
|
}, []);
|
|
|
|
const removeFile = useCallback((id: string) => {
|
|
setFiles(prev => prev.filter(f => f.id !== id));
|
|
}, []);
|
|
|
|
const clearFiles = useCallback(() => {
|
|
setFiles([]);
|
|
setUploads([]);
|
|
setIsComplete(false);
|
|
}, []);
|
|
|
|
const reset = useCallback(() => {
|
|
setFiles([]);
|
|
setUploads([]);
|
|
setIsUploading(false);
|
|
setIsComplete(false);
|
|
settingsRef.current = null;
|
|
}, []);
|
|
|
|
const updateUploadProgress = useCallback((id: string, update: Partial<UploadProgressItem>) => {
|
|
setUploads(prev => prev.map(u => u.id === id ? { ...u, ...update } : u));
|
|
}, []);
|
|
|
|
const uploadSingleFile = useCallback(async (
|
|
item: FileListItem,
|
|
settings: SharedJobSettings
|
|
): Promise<UploadProgressItem> => {
|
|
const uploadItem: UploadProgressItem = {
|
|
id: item.id,
|
|
filename: item.file.name,
|
|
autoTitle: item.autoTitle,
|
|
status: 'uploading',
|
|
progress: 0,
|
|
};
|
|
|
|
updateUploadProgress(item.id, { status: 'uploading', progress: 0 });
|
|
|
|
try {
|
|
const job = await apiClient.createJob(
|
|
{
|
|
title: item.autoTitle,
|
|
requested_outputs: settings.requestedOutputs,
|
|
brand_context: settings.brandContext,
|
|
},
|
|
item.file,
|
|
(progressEvent) => {
|
|
const percent = Math.round((progressEvent.loaded * 100) / progressEvent.total);
|
|
updateUploadProgress(item.id, { progress: percent });
|
|
}
|
|
);
|
|
|
|
const result: UploadProgressItem = {
|
|
...uploadItem,
|
|
status: 'success',
|
|
progress: 100,
|
|
jobId: job.id,
|
|
};
|
|
updateUploadProgress(item.id, result);
|
|
onJobCreated?.(job.id, item.file.name);
|
|
return result;
|
|
} catch (error) {
|
|
const errorMessage = extractErrorMessage(error);
|
|
const result: UploadProgressItem = {
|
|
...uploadItem,
|
|
status: 'error',
|
|
error: errorMessage,
|
|
};
|
|
updateUploadProgress(item.id, result);
|
|
return result;
|
|
}
|
|
}, [updateUploadProgress, onJobCreated]);
|
|
|
|
const startUpload = useCallback(async (settings: SharedJobSettings): Promise<UploadProgressItem[]> => {
|
|
if (files.length === 0) return [];
|
|
|
|
settingsRef.current = settings;
|
|
setIsUploading(true);
|
|
setIsComplete(false);
|
|
|
|
// Initialize upload items
|
|
const initialUploads: UploadProgressItem[] = files.map(f => ({
|
|
id: f.id,
|
|
filename: f.file.name,
|
|
autoTitle: f.autoTitle,
|
|
status: 'pending' as UploadStatus,
|
|
progress: 0,
|
|
}));
|
|
setUploads(initialUploads);
|
|
|
|
const results: UploadProgressItem[] = [];
|
|
const queue = [...files];
|
|
const active: Promise<void>[] = [];
|
|
|
|
// Process queue with concurrency limit
|
|
while (queue.length > 0 || active.length > 0) {
|
|
// Fill up to maxConcurrent
|
|
while (active.length < maxConcurrent && queue.length > 0) {
|
|
const item = queue.shift()!;
|
|
const promise = uploadSingleFile(item, settings)
|
|
.then(result => {
|
|
results.push(result);
|
|
})
|
|
.finally(() => {
|
|
const index = active.indexOf(promise);
|
|
if (index > -1) active.splice(index, 1);
|
|
});
|
|
active.push(promise);
|
|
}
|
|
|
|
// Wait for at least one to complete
|
|
if (active.length > 0) {
|
|
await Promise.race(active);
|
|
}
|
|
}
|
|
|
|
setIsUploading(false);
|
|
setIsComplete(true);
|
|
onAllComplete?.(results);
|
|
return results;
|
|
}, [files, maxConcurrent, uploadSingleFile, onAllComplete]);
|
|
|
|
const retryFailed = useCallback(async (settings: SharedJobSettings): Promise<void> => {
|
|
const failedItems = uploads
|
|
.filter(u => u.status === 'error')
|
|
.map(u => files.find(f => f.id === u.id))
|
|
.filter((f): f is FileListItem => f !== undefined);
|
|
|
|
if (failedItems.length === 0) return;
|
|
|
|
settingsRef.current = settings;
|
|
setIsUploading(true);
|
|
setIsComplete(false);
|
|
|
|
// Reset failed items to pending
|
|
setUploads(prev => prev.map(u =>
|
|
u.status === 'error' ? { ...u, status: 'pending' as UploadStatus, error: undefined, progress: 0 } : u
|
|
));
|
|
|
|
const queue = [...failedItems];
|
|
const active: Promise<void>[] = [];
|
|
|
|
while (queue.length > 0 || active.length > 0) {
|
|
while (active.length < maxConcurrent && queue.length > 0) {
|
|
const item = queue.shift()!;
|
|
const promise: Promise<void> = uploadSingleFile(item, settings)
|
|
.then(() => {})
|
|
.finally(() => {
|
|
const index = active.indexOf(promise);
|
|
if (index > -1) active.splice(index, 1);
|
|
});
|
|
active.push(promise);
|
|
}
|
|
|
|
if (active.length > 0) {
|
|
await Promise.race(active);
|
|
}
|
|
}
|
|
|
|
setIsUploading(false);
|
|
setIsComplete(true);
|
|
}, [files, uploads, maxConcurrent, uploadSingleFile]);
|
|
|
|
// Computed values
|
|
const totalFiles = files.length;
|
|
const completedCount = uploads.filter(u => u.status === 'success' || u.status === 'error').length;
|
|
const successCount = uploads.filter(u => u.status === 'success').length;
|
|
const failedCount = uploads.filter(u => u.status === 'error').length;
|
|
|
|
return {
|
|
files,
|
|
uploads,
|
|
isUploading,
|
|
isComplete,
|
|
addFiles,
|
|
removeFile,
|
|
clearFiles,
|
|
startUpload,
|
|
retryFailed,
|
|
reset,
|
|
totalFiles,
|
|
completedCount,
|
|
failedCount,
|
|
successCount,
|
|
};
|
|
}
|
|
|
|
interface AxiosErrorLike {
|
|
code?: string;
|
|
message?: string;
|
|
response?: {
|
|
status?: number;
|
|
data?: { detail?: string };
|
|
};
|
|
}
|
|
|
|
function extractErrorMessage(error: unknown): string {
|
|
const err = error as AxiosErrorLike;
|
|
|
|
if (err.code === 'ERR_NETWORK') {
|
|
return 'Network error - cannot reach server';
|
|
}
|
|
if (err.code === 'ECONNABORTED' || err.message?.includes('timeout')) {
|
|
return 'Upload timeout - file may be too large';
|
|
}
|
|
if (err.response?.status === 413) {
|
|
return 'File too large (max 500MB)';
|
|
}
|
|
if (err.response?.status === 400) {
|
|
return err.response?.data?.detail || 'Invalid file or request';
|
|
}
|
|
if (err.response?.status === 401) {
|
|
return 'Session expired - please log in again';
|
|
}
|
|
if (err.response?.status === 403) {
|
|
return 'Permission denied';
|
|
}
|
|
if (err.response?.status === 500) {
|
|
return 'Server error - please try again';
|
|
}
|
|
|
|
return err.message || 'Unknown error';
|
|
}
|