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:
parent
5019563c38
commit
07ded22f8e
3 changed files with 80 additions and 15 deletions
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue