video-accessibility-old/frontend/src/hooks/useMultiUpload.ts
Vadym Samoilenko 2e8a8dc287 feat: add brand context, ethics guidelines, and improved AD prompt rules
- 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>
2026-03-18 14:46:09 +00:00

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';
}