diff --git a/video/README.md b/video/README.md new file mode 100644 index 00000000..fbf0b59b --- /dev/null +++ b/video/README.md @@ -0,0 +1,33 @@ +## Video app — deployment & upload troubleshooting + +When uploads fail on a deployed instance but work locally, this is commonly caused by missing environment variables or the runtime not allowing temporary files to be read by the server. + +Quick checks: + +- Ensure the server environment has the API key available under `VITE_GEMINI_API_KEY`. + - Locally the dev server reads `.env` (see `package.json` dev script) or you can set the environment variable before running the server. +- If your hosting provider prevents the runtime from writing to `/tmp`, change `multer`'s destination in `server/index.mjs` to a writable location. + - If your hosting provider prevents the runtime from writing to `/tmp`, set the `VIDEO_UPLOAD_TMP_DIR` env var to a writable directory; the server will use that directory for temporary uploads. +- If the upload fails the server will now return a helpful JSON message in the response body (e.g. `{ "error": "VITE_GEMINI_API_KEY is not set on the server" }`). These messages will be visible in the UI. + +How to run locally for quick debugging: + +1. Add a `.env` file in `/video` with a valid key (or set `VITE_GEMINI_API_KEY` in your shell): + + VITE_GEMINI_API_KEY=your_api_key_here + +2. Start the app: + + npm run dev + +3. Use the included curl helper (`test-upload.sh`) to simulate uploading a file to the server: + + ./test-upload.sh /path/to/sample.mp4 + +This will show the raw API response and help identify cases like missing API keys, invalid mime types, or other upload errors. + +Advanced testing: + +- When some deployments strip or normalize Content-Type headers, the server will accept `application/octet-stream` uploads for files with a known video extension (e.g. .mp4) and will promote the MIME type accordingly. Use `test-mime-fallback.sh` to explicitly test that behavior: + + ./test-mime-fallback.sh /path/to/sample.mp4 diff --git a/video/server/index.mjs b/video/server/index.mjs index 9ed24079..418aa1de 100644 --- a/video/server/index.mjs +++ b/video/server/index.mjs @@ -20,14 +20,18 @@ import {checkProgress, promptVideo, uploadVideo} from './upload.mjs' const app = express() app.use(express.json()) -const upload = multer({dest: '/tmp/'}) +// Allow configuring the temporary uploads directory (some hosts restrict /tmp) +const tmpDir = process.env.VIDEO_UPLOAD_TMP_DIR || '/tmp/' +const upload = multer({dest: tmpDir}) app.post('/api/upload', upload.single('video'), async (req, res) => { try { const file = req.file const resp = await uploadVideo(file) res.json({data: resp}) } catch (error) { - res.status(500).json({error}) + // Error can be an Error object; return a structured JSON message to the client + console.error('/api/upload error:', error) + res.status(500).json({error: error?.message || String(error)}) } }) @@ -50,7 +54,8 @@ app.post('/api/prompt', async (req, res) => { ) res.json(videoResponse) } catch (error) { - res.json({error}, {status: 400}) + console.error('/api/prompt error:', error) + res.status(400).json({error: error?.message || String(error)}) } }) diff --git a/video/server/upload.mjs b/video/server/upload.mjs index 050106fc..dbe135fd 100644 --- a/video/server/upload.mjs +++ b/video/server/upload.mjs @@ -14,16 +14,66 @@ import {GoogleGenerativeAI} from '@google/generative-ai' import {GoogleAIFileManager} from '@google/generative-ai/server' +import path from 'path' const key = process.env.VITE_GEMINI_API_KEY + +if (!key) { + // Fail fast with a clear message so deployments without a key don't silently + // accept uploads then fail later. + console.error('VITE_GEMINI_API_KEY is missing from environment variables') + throw new Error('VITE_GEMINI_API_KEY is not set on the server') +} + const fileManager = new GoogleAIFileManager(key) const genAI = new GoogleGenerativeAI(key) export const uploadVideo = async file => { try { + // Basic validation: ensure the incoming file looks like a video and has a + // filename. This avoids uploading unsupported files (or empty bodies) to + // the file manager which will fail later and is harder to debug. + if (!file || !file.path || !file.originalname) { + const msg = 'Missing file or invalid upload payload (file.path or originalname)' + console.error(msg, file) + throw new Error(msg) + } + + // Prefer explicit video MIME types; multer may sometimes set generic + // application/octet-stream depending on environment. Allow the common + // video types, and accept application/octet-stream only when the file + // extension strongly indicates a video (this helps deployed hosts which + // sometimes strip or normalize Content-Type headers). + const mimeTypeRaw = file.mimetype || 'application/octet-stream' + let mimeType = mimeTypeRaw + + const ext = path.extname(file.originalname || '').toLowerCase() + const knownVideoExt = new Set(['.mp4', '.mov', '.webm', '.mkv', '.avi', '.ogg']) + + const extMap = { + '.mp4': 'video/mp4', + '.mov': 'video/quicktime', + '.webm': 'video/webm', + '.mkv': 'video/x-matroska', + '.avi': 'video/x-msvideo', + '.ogg': 'video/ogg' + } + + if (mimeTypeRaw.startsWith('video/')) { + // OK as-is + } else if (mimeTypeRaw === 'application/octet-stream' && knownVideoExt.has(ext)) { + // Fallback: promote to a reasonable video MIME type based on extension + mimeType = extMap[ext] || 'application/octet-stream' + console.warn(`uploadVideo: promoted mimeType from application/octet-stream to ${mimeType} based on extension ${ext}`) + } else { + const msg = `Unsupported mimeType: ${mimeTypeRaw}. Expected a video/* type or application/octet-stream with a video extension.` + console.error(msg) + throw new Error(msg) + } + const uploadResult = await fileManager.uploadFile(file.path, { displayName: file.originalname, - mimeType: file.mimetype + mimeType: mimeType }) return uploadResult.file } catch (error) { @@ -37,8 +87,8 @@ export const checkProgress = async fileId => { const result = await fileManager.getFile(fileId) return result } catch (error) { - console.error(error) - return {error} + console.error('checkProgress errored:', error) + throw error } } @@ -61,7 +111,7 @@ export const promptVideo = async (uploadResult, prompt, model) => { feedback: result.response.promptFeedback } } catch (error) { - console.error(error) - return {error} + console.error('promptVideo errored:', error) + throw error } } diff --git a/video/src/App.jsx b/video/src/App.jsx index f904cddd..b7719d59 100644 --- a/video/src/App.jsx +++ b/video/src/App.jsx @@ -33,6 +33,7 @@ export default function App() { const [isLoading, setIsLoading] = useState(false) const [showSidebar, setShowSidebar] = useState(true) const [isLoadingVideo, setIsLoadingVideo] = useState(false) + // videoError will hold a user-friendly message string or false when no error const [videoError, setVideoError] = useState(false) const [customPrompt, setCustomPrompt] = useState('') const [chartMode, setChartMode] = useState(chartModes[0]) @@ -96,14 +97,27 @@ export default function App() { const formData = new FormData() formData.set('video', e.dataTransfer.files[0]) - const resp = await ( - await fetch('/api/upload', { + try { + const uploadResponse = await fetch('/api/upload', { method: 'POST', body: formData }) - ).json() - setFile(resp.data) - checkProgress(resp.data.name) + + if (!uploadResponse.ok) { + const errJson = await uploadResponse.json().catch(() => ({})) + const msg = errJson?.error || `Upload failed with status ${uploadResponse.status}` + setVideoError(msg) + setIsLoadingVideo(false) + return + } + + const resp = await uploadResponse.json() + setFile(resp.data) + checkProgress(resp.data.name) + } catch (err) { + setVideoError(err?.message || String(err)) + setIsLoadingVideo(false) + } } const checkProgress = async fileId => { diff --git a/video/src/VideoPlayer.jsx b/video/src/VideoPlayer.jsx index b09bd64b..0ce1dca5 100644 --- a/video/src/VideoPlayer.jsx +++ b/video/src/VideoPlayer.jsx @@ -176,11 +176,11 @@ export default function VideoPlayer({ ) : (
- {isLoadingVideo - ? 'Processing video...' - : videoError - ? 'Error processing video.' - : 'Drag and drop a video file here to get started.'} + {isLoadingVideo + ? 'Processing video...' + : videoError + ? `Error processing video: ${typeof videoError === 'string' ? videoError : 'An unknown error occurred.'}` + : 'Drag and drop a video file here to get started.'}