feat: video proxy streaming endpoint + editor drag-and-drop to timeline

- mam-api: add GET /api/v1/assets/:id/video streaming proxy that fetches
  from RustFS/S3 and pipes to browser with range-request support, bypassing
  direct S3 access from Chrome
- mam-api: fix /stream route to return /video proxy URL for both proxy and
  original-mp4 assets; return null cleanly for non-playable sources
- s3/client: set requestChecksumCalculation/responseChecksumValidation to
  WHEN_REQUIRED to suppress x-amz-checksum-mode header on signed URLs
- editor: fix loadSourceAsset to set state.sourceAsset even when no proxy
  exists (info toast instead of bail-out) so Insert/Overwrite still work
- editor: add drag-and-drop from media panel to timeline — items are now
  draggable, timeline container accepts drops and calls Timeline.addClip
  with the asset at playhead position
- editor: add tl-drag-over CSS highlight on timeline during drag
This commit is contained in:
Zac Gaetano 2026-05-19 22:47:33 -04:00
parent 5019563c38
commit 07ded22f8e
3 changed files with 80 additions and 15 deletions

View file

@ -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 {

View file

@ -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) {

View file

@ -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 ? '<span class="media-asset-meta">' + esc(metaStr) + '</span>' : '') +
'</div>';
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();
}