diff --git a/services/mam-api/src/routes/assets.js b/services/mam-api/src/routes/assets.js index 06af5d0..8b222d6 100644 --- a/services/mam-api/src/routes/assets.js +++ b/services/mam-api/src/routes/assets.js @@ -2,7 +2,8 @@ import express from 'express'; import { Queue } from 'bullmq'; import { v4 as uuidv4 } from 'uuid'; import pool from '../db/pool.js'; -import { getSignedUrlForObject, deleteObject } from '../s3/client.js'; +import { getSignedUrlForObject, deleteObject, s3Client, S3_BUCKET } from '../s3/client.js'; +import { GetObjectCommand } from '@aws-sdk/client-s3'; import { requireAuth } from '../middleware/auth.js'; const router = express.Router(); @@ -420,14 +421,45 @@ router.get('/:id/stream', async (req, res, next) => { if (a.status === 'live') { return res.json({ url: `/live/${a.id}/index.m3u8`, type: 'hls', live: true }); } - const key = a.proxy_s3_key || a.original_s3_key; - if (!key) return res.status(404).json({ error: 'No stream available yet' }); - const url = await getSignedUrlForObject(key); - res.json({ url }); + if (a.proxy_s3_key) { + return res.json({ url: `/api/v1/assets/${id}/video`, type: 'mp4' }); + } + const orig = a.original_s3_key; + if (orig && orig.toLowerCase().endsWith('.mp4')) { + return res.json({ url: `/api/v1/assets/${id}/video`, type: 'mp4' }); + } + return res.json({ url: null, type: null, reason: 'no_proxy' }); } catch (err) { next(err); } }); + +// GET /:id/video - Stream proxy for browser video playback (bypasses S3 direct) +router.get('/:id/video', async (req, res, next) => { + try { + const { id } = req.params; + const r = await pool.query('SELECT proxy_s3_key, original_s3_key FROM assets WHERE id = $1', [id]); + if (r.rows.length === 0) return res.status(404).json({ error: 'Asset not found' }); + const a = r.rows[0]; + const key = a.proxy_s3_key || (a.original_s3_key?.toLowerCase().endsWith('.mp4') ? a.original_s3_key : null); + if (!key) return res.status(404).json({ error: 'No browser-playable source' }); + const params = { Bucket: S3_BUCKET, Key: key }; + const rangeHeader = req.headers.range; + if (rangeHeader) params.Range = rangeHeader; + const s3Res = await s3Client.send(new GetObjectCommand(params)); + const status = rangeHeader ? 206 : 200; + const headers = { + 'Content-Type': 'video/mp4', + 'Accept-Ranges': 'bytes', + 'Cache-Control': 'no-store' + }; + if (s3Res.ContentLength) headers['Content-Length'] = String(s3Res.ContentLength); + if (s3Res.ContentRange) headers['Content-Range'] = s3Res.ContentRange; + res.writeHead(status, headers); + s3Res.Body.pipe(res); + } catch (err) { next(err); } +}); + // GET /:id/thumbnail - Signed URL for thumbnail image router.get('/:id/thumbnail', async (req, res, next) => { try { diff --git a/services/mam-api/src/s3/client.js b/services/mam-api/src/s3/client.js index aa0e506..5e8767c 100644 --- a/services/mam-api/src/s3/client.js +++ b/services/mam-api/src/s3/client.js @@ -12,16 +12,17 @@ const s3Client = new S3Client({ secretAccessKey: process.env.S3_SECRET_KEY, }, forcePathStyle: true, + requestChecksumCalculation: "WHEN_REQUIRED", + responseChecksumValidation: "WHEN_REQUIRED", }); export { s3Client }; -export const getSignedUrlForObject = async (key, expiresIn = 3600) => { +export const getSignedUrlForObject = async (key, expiresIn = 3600, contentType = null) => { try { - const command = new GetObjectCommand({ - Bucket: S3_BUCKET, - Key: key, - }); + const params = { Bucket: S3_BUCKET, Key: key }; + if (contentType) params.ResponseContentType = contentType; + const command = new GetObjectCommand(params); const url = await getSignedUrl(s3Client, command, { expiresIn }); return url; } catch (err) { diff --git a/services/web-ui/public/editor.html b/services/web-ui/public/editor.html index 07892b8..8f3e129 100644 --- a/services/web-ui/public/editor.html +++ b/services/web-ui/public/editor.html @@ -199,6 +199,10 @@ color: var(--text-tertiary); } + .timeline-container.tl-drag-over { + outline: 2px dashed var(--accent, #0fa3ff); + background: rgba(15,163,255,0.06); + } .timeline-container { flex: 1; overflow: hidden; @@ -441,6 +445,27 @@ document.addEventListener('DOMContentLoaded', async () => { onPlayheadMoved: onPlayheadMoved, }); + (function setupTimelineDrop() { + var tlc = document.getElementById('timelineContainer'); + tlc.addEventListener('dragover', function(e) { + e.preventDefault(); e.dataTransfer.dropEffect = 'copy'; + tlc.classList.add('tl-drag-over'); + }); + tlc.addEventListener('dragleave', function(e) { + if (!tlc.contains(e.relatedTarget)) tlc.classList.remove('tl-drag-over'); + }); + tlc.addEventListener('drop', async function(e) { + e.preventDefault(); tlc.classList.remove('tl-drag-over'); + var raw = e.dataTransfer.getData('application/x-zampp-asset'); + if (!raw) return; + var asset; try { asset = JSON.parse(raw); } catch(err) { return; } + if (!state.seq) { toast('No sequence open', '', 'warning'); return; } + var r = await getAssetStreamUrl(asset.id); + if (r.success && r.data && r.data.url) state.streamCache[asset.id] = r.data.url; + Timeline.addClip(Object.assign({}, asset, { streamUrl: state.streamCache[asset.id]||null }), 0, (asset.duration_ms||10000)/1000, 0); + }); + })(); + await loadProject(); await loadSequences(); await loadMediaAssets(); @@ -542,6 +567,11 @@ function renderMediaList() { (metaStr ? '' + esc(metaStr) + '' : '') + ''; + el.draggable = true; + el.ondragstart = function(e) { + e.dataTransfer.setData('application/x-zampp-asset', JSON.stringify({ id: asset.id, display_name: asset.display_name || asset.filename, duration_ms: asset.duration_ms })); + e.dataTransfer.effectAllowed = 'copy'; + }; el.ondblclick = function() { loadSourceAsset(asset); }; el.onclick = function() { list.querySelectorAll('.media-asset-item').forEach(function(e) { e.classList.remove('active'); }); @@ -588,12 +618,14 @@ async function loadSourceAsset(asset) { let url = state.streamCache[asset.id]; if (!url) { const r = await getAssetStreamUrl(asset.id); - if (!r.success || !r.data || !r.data.url) { toast('No proxy available', '', 'warning'); return; } - url = r.data.url; - state.streamCache[asset.id] = url; + if (r.success && r.data && r.data.url) { + url = r.data.url; + state.streamCache[asset.id] = url; + } else { + toast('No proxy — clip usable in timeline only', '', 'info'); + } } - vid.src = url; - vid.load(); + if (url) { vid.src = url; vid.load(); } updateSrcInOutMarkers(); }