Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions video/README.md
Original file line number Diff line number Diff line change
@@ -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
11 changes: 8 additions & 3 deletions video/server/index.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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)})
}
})

Expand All @@ -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)})
}
})

Expand Down
60 changes: 55 additions & 5 deletions video/server/upload.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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
}
}

Expand All @@ -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
}
}
24 changes: 19 additions & 5 deletions video/src/App.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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])
Expand Down Expand Up @@ -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 => {
Expand Down
10 changes: 5 additions & 5 deletions video/src/VideoPlayer.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -176,11 +176,11 @@ export default function VideoPlayer({
) : (
<div className="emptyVideo">
<p>
{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.'}
</p>
</div>
)}
Expand Down
17 changes: 17 additions & 0 deletions video/test-mime-fallback.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
#!/usr/bin/env bash
# Helper to upload using application/octet-stream to test MIME fallback behavior
# Usage: ./test-mime-fallback.sh /path/to/file.mp4
set -eu
if [ $# -ne 1 ]; then
echo "Usage: $0 /path/to/video.mp4"
exit 1
fi
FILE=$1
ENDPOINT=${ENDPOINT:-http://localhost:8000/api/upload}
if [ ! -f "$FILE" ]; then
echo "File not found: $FILE"
exit 1
fi

# Force the file content-type to application/octet-stream when sending the multipart form
curl -v -F "video=@${FILE};type=application/octet-stream" "$ENDPOINT" | jq || true
19 changes: 19 additions & 0 deletions video/test-upload.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
#!/usr/bin/env bash
# Simple helper to upload a file to a running local dev server
# Usage: ./test-upload.sh /path/to/file.mp4

set -eu
if [ $# -ne 1 ]; then
echo "Usage: $0 /path/to/video.mp4"
exit 1
fi

FILE=$1
ENDPOINT=${ENDPOINT:-http://localhost:8000/api/upload}

if [ ! -f "$FILE" ]; then
echo "File not found: $FILE"
exit 1
fi

curl -v -F "video=@${FILE}" "$ENDPOINT" | jq || true