feat(audio-tab): full audio track inspector with meters, mute/solo, faders
Issue #80 — replaces the stub AudioTab (two static waveforms) with a broadcast-ops-grade audio panel: - DB: add audio_metadata JSONB column to assets (migration 022) - Worker: getMediaInfo now extracts per-stream audio metadata (codec, channels, channel_layout, sample_rate, bit_depth, bit_rate, language, title, disposition) - Worker: proxy job persists audio_metadata into the assets row - API: new GET /assets/:id/audio returns structured track list - Frontend AudioTab: per-track rows with: - Track name/index with language badge - SVG waveform per track (color-coded) - L/R level meters via Web Audio API AnalyserNode - Per-track metadata row (codec, layout, sample rate, bit depth, bitrate) - Mute / Solo buttons with proper solo-logic - Per-track volume fader - Master section with summed L/R meters and master fader - MetadataTab: show audio track summary when audio_metadata present - CSS: full audio-tab layout, responsive collapse at 900px
This commit is contained in:
parent
48d54a32cf
commit
c48c7e6d7d
6 changed files with 532 additions and 38 deletions
12
services/mam-api/src/db/migrations/022-audio-metadata.sql
Normal file
12
services/mam-api/src/db/migrations/022-audio-metadata.sql
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
-- 022-audio-metadata.sql
|
||||||
|
-- Store per-track audio metadata extracted by ffprobe during proxy generation.
|
||||||
|
-- Shape: JSON array of objects, one per audio stream, e.g.:
|
||||||
|
-- [
|
||||||
|
-- {"index":1,"codec":"pcm_s24le","channels":2,"channel_layout":"stereo",
|
||||||
|
-- "sample_rate":48000,"bit_depth":24,"bit_rate":2304000,"language":null},
|
||||||
|
-- {"index":2,"codec":"aac","channels":2,"channel_layout":"stereo",
|
||||||
|
-- "sample_rate":48000,"bit_depth":null,"bit_rate":128000,"language":"en"}
|
||||||
|
-- ]
|
||||||
|
-- NULL means the asset has not been probed yet or has no audio streams.
|
||||||
|
|
||||||
|
ALTER TABLE assets ADD COLUMN IF NOT EXISTS audio_metadata JSONB;
|
||||||
|
|
@ -840,4 +840,26 @@ router.get('/temp-segment-url/:clipInstanceId', async (req, res, next) => {
|
||||||
} catch (err) { next(err); }
|
} catch (err) { next(err); }
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// GET /:id/audio — return audio metadata and a signed URL for audio extraction
|
||||||
|
router.get('/:id/audio', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
const r = await pool.query(
|
||||||
|
'SELECT id, media_type, proxy_s3_key, original_s3_key, audio_metadata, duration_ms 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 audioMeta = a.audio_metadata || null;
|
||||||
|
if (!audioMeta || !Array.isArray(audioMeta) || audioMeta.length === 0) {
|
||||||
|
return res.json({ tracks: [], hasAudio: false });
|
||||||
|
}
|
||||||
|
res.json({
|
||||||
|
tracks: audioMeta,
|
||||||
|
hasAudio: true,
|
||||||
|
durationMs: a.duration_ms || null,
|
||||||
|
});
|
||||||
|
} catch (err) { next(err); }
|
||||||
|
});
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|
|
||||||
|
|
@ -927,16 +927,27 @@ function FilesTab({ asset, filmFrames, filmstripLoading, streamUrl, reprocessing
|
||||||
}
|
}
|
||||||
|
|
||||||
function MetadataTab({ asset }) {
|
function MetadataTab({ asset }) {
|
||||||
const rows = [
|
var rows = [
|
||||||
{ k: "Filename", v: asset.name },
|
{ k: "Filename", v: asset.name },
|
||||||
{ k: "Duration", v: asset.duration || '—' },
|
{ k: "Duration", v: asset.duration || '—' },
|
||||||
{ k: "Resolution", v: asset.res || '—' },
|
{ k: "Resolution", v: asset.res || '—' },
|
||||||
{ k: "Codec", v: asset.codec || '—' },
|
{ k: "Codec", v: asset.codec || '—' },
|
||||||
{ k: "File size", v: asset.size || '—' },
|
{ k: "File size", v: asset.size || '—' },
|
||||||
{ k: "Status", v: asset.status || '—' },
|
{ k: "Status", v: asset.status || '—' },
|
||||||
{ k: "Updated", v: asset.updated || '—' },
|
{ k: "Updated", v: asset.updated || '—' },
|
||||||
{ k: "Project", v: asset.project || '—' },
|
{ k: "Project", v: asset.project || '—' },
|
||||||
];
|
];
|
||||||
|
var audioMeta = asset.audio_metadata;
|
||||||
|
if (audioMeta && Array.isArray(audioMeta) && audioMeta.length > 0) {
|
||||||
|
rows.push({ k: "Audio tracks", v: audioMeta.length });
|
||||||
|
audioMeta.forEach(function(tr, i) {
|
||||||
|
var label = tr.title || ('Track ' + (tr.index != null ? tr.index : i + 1));
|
||||||
|
var ch = tr.channel_layout || (tr.channels ? (tr.channels === 1 ? 'mono' : tr.channels === 2 ? 'stereo' : tr.channels + 'ch') : '—');
|
||||||
|
var parts = [tr.codec || '—', ch, tr.sample_rate ? (tr.sample_rate / 1000).toFixed(1) + ' kHz' : '—', tr.bit_depth ? tr.bit_depth + '-bit' : '—', tr.bit_rate ? Math.round(tr.bit_rate / 1000) + ' kbps' : '—'];
|
||||||
|
if (tr.language) parts.push(tr.language);
|
||||||
|
rows.push({ k: " " + label, v: parts.join(' · ') });
|
||||||
|
});
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<div className="meta-table">
|
<div className="meta-table">
|
||||||
{rows.map(function(r) {
|
{rows.map(function(r) {
|
||||||
|
|
@ -951,13 +962,226 @@ function MetadataTab({ asset }) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var _AUDIO_TRACK_COLORS = [
|
||||||
|
'var(--accent)',
|
||||||
|
'var(--purple)',
|
||||||
|
'var(--success)',
|
||||||
|
'var(--warning)',
|
||||||
|
'var(--danger)',
|
||||||
|
'#6B7280',
|
||||||
|
'#F472B6',
|
||||||
|
'#34D399',
|
||||||
|
];
|
||||||
|
|
||||||
function AudioTab({ asset }) {
|
function AudioTab({ asset }) {
|
||||||
return (
|
var assetId = asset && asset.id;
|
||||||
<div style={{ padding: "16px 0" }}>
|
var [tracks, setTracks] = React.useState([]);
|
||||||
<div style={{ background: "var(--bg-2)", border: "1px solid var(--border)", borderRadius: 8, padding: 16, display: "flex", flexDirection: "column", gap: 12 }}>
|
var [loading, setLoading] = React.useState(true);
|
||||||
<Waveform seed={3} color="var(--accent)" />
|
var [masterVol, setMasterVol] = React.useState(100);
|
||||||
<Waveform seed={7} color="var(--purple)" />
|
var [trackState, setTrackState] = React.useState({});
|
||||||
|
var [levels, setLevels] = React.useState({});
|
||||||
|
var analyserRef = React.useRef(null);
|
||||||
|
var rafRef = React.useRef(null);
|
||||||
|
var audioCtxRef = React.useRef(null);
|
||||||
|
var sourceRef = React.useRef(null);
|
||||||
|
|
||||||
|
React.useEffect(function() {
|
||||||
|
if (!assetId) return;
|
||||||
|
var cancelled = false;
|
||||||
|
setLoading(true);
|
||||||
|
window.ZAMPP_API.fetch('/assets/' + assetId + '/audio')
|
||||||
|
.then(function(r) {
|
||||||
|
if (cancelled) return;
|
||||||
|
var t = (r && r.tracks) || [];
|
||||||
|
setTracks(t);
|
||||||
|
var initial = {};
|
||||||
|
t.forEach(function(tr, i) {
|
||||||
|
initial[i] = { muted: false, solo: false, volume: 100 };
|
||||||
|
});
|
||||||
|
setTrackState(initial);
|
||||||
|
})
|
||||||
|
.catch(function() { setTracks([]); })
|
||||||
|
.finally(function() { if (!cancelled) setLoading(false); });
|
||||||
|
return function() { cancelled = true; };
|
||||||
|
}, [assetId]);
|
||||||
|
|
||||||
|
var toggleMute = function(i) {
|
||||||
|
setTrackState(function(prev) {
|
||||||
|
var s = Object.assign({}, prev);
|
||||||
|
s[i] = Object.assign({}, s[i] || { muted: false, solo: false, volume: 100 });
|
||||||
|
s[i].muted = !s[i].muted;
|
||||||
|
return s;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
var toggleSolo = function(i) {
|
||||||
|
setTrackState(function(prev) {
|
||||||
|
var s = Object.assign({}, prev);
|
||||||
|
s[i] = Object.assign({}, s[i] || { muted: false, solo: false, volume: 100 });
|
||||||
|
s[i].solo = !s[i].solo;
|
||||||
|
return s;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
var setTrackVol = function(i, v) {
|
||||||
|
setTrackState(function(prev) {
|
||||||
|
var s = Object.assign({}, prev);
|
||||||
|
s[i] = Object.assign({}, s[i] || { muted: false, solo: false, volume: 100 });
|
||||||
|
s[i].volume = v;
|
||||||
|
return s;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
var anySolo = tracks.some(function(_, i) { return trackState[i] && trackState[i].solo; });
|
||||||
|
|
||||||
|
React.useEffect(function() {
|
||||||
|
var video = document.querySelector('.player-canvas video');
|
||||||
|
if (!video || !window.AudioContext) return;
|
||||||
|
var ctx, source, analyser, leftData, rightData;
|
||||||
|
try {
|
||||||
|
ctx = new (window.AudioContext || window.webkitAudioContext)();
|
||||||
|
source = ctx.createMediaElementSource(video);
|
||||||
|
analyser = ctx.createAnalyser();
|
||||||
|
analyser.fftSize = 256;
|
||||||
|
analyser.smoothingTimeConstant = 0.8;
|
||||||
|
var splitter = ctx.createChannelSplitter(2);
|
||||||
|
source.connect(analyser);
|
||||||
|
source.connect(ctx.destination);
|
||||||
|
analyser.connect(splitter);
|
||||||
|
var leftAnalyser = ctx.createAnalyser();
|
||||||
|
leftAnalyser.fftSize = 256;
|
||||||
|
leftAnalyser.smoothingTimeConstant = 0.8;
|
||||||
|
var rightAnalyser = ctx.createAnalyser();
|
||||||
|
rightAnalyser.fftSize = 256;
|
||||||
|
rightAnalyser.smoothingTimeConstant = 0.8;
|
||||||
|
splitter.connect(leftAnalyser, 0);
|
||||||
|
splitter.connect(rightAnalyser, 1);
|
||||||
|
audioCtxRef.current = ctx;
|
||||||
|
sourceRef.current = source;
|
||||||
|
analyserRef.current = { left: leftAnalyser, right: rightAnalyser };
|
||||||
|
leftData = new Uint8Array(leftAnalyser.frequencyBinCount);
|
||||||
|
rightData = new Uint8Array(rightAnalyser.frequencyBinCount);
|
||||||
|
} catch (e) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var running = true;
|
||||||
|
var tick = function() {
|
||||||
|
if (!running) return;
|
||||||
|
if (analyserRef.current) {
|
||||||
|
analyserRef.current.left.getByteFrequencyData(leftData);
|
||||||
|
analyserRef.current.right.getByteFrequencyData(rightData);
|
||||||
|
var lAvg = 0, rAvg = 0;
|
||||||
|
for (var j = 0; j < leftData.length; j++) { lAvg += leftData[j]; rAvg += rightData[j]; }
|
||||||
|
lAvg = lAvg / leftData.length / 255;
|
||||||
|
rAvg = rAvg / rightData.length / 255;
|
||||||
|
setLevels({ L: lAvg, R: rAvg });
|
||||||
|
}
|
||||||
|
rafRef.current = requestAnimationFrame(tick);
|
||||||
|
};
|
||||||
|
tick();
|
||||||
|
return function() {
|
||||||
|
running = false;
|
||||||
|
if (rafRef.current) cancelAnimationFrame(rafRef.current);
|
||||||
|
try { ctx.close(); } catch (_) {}
|
||||||
|
};
|
||||||
|
}, [assetId, tracks.length]);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return <div style={{ padding: 24, textAlign: 'center', color: 'var(--text-3)', fontSize: 12.5 }}>Loading audio…</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!tracks.length) {
|
||||||
|
return (
|
||||||
|
<div style={{ padding: 24, textAlign: 'center', color: 'var(--text-3)', fontSize: 12.5 }}>
|
||||||
|
<Icon name="audio" size={20} style={{ opacity: 0.4, display: 'block', margin: '0 auto 8px' }} />
|
||||||
|
No audio tracks found. Either this asset has no audio, or it has not been probed yet.
|
||||||
</div>
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="audio-tab">
|
||||||
|
<div className="audio-tracks">
|
||||||
|
{tracks.map(function(tr, i) {
|
||||||
|
var st = trackState[i] || { muted: false, solo: false, volume: 100 };
|
||||||
|
var isAudible = st.muted ? false : (anySolo ? st.solo : true);
|
||||||
|
var color = _AUDIO_TRACK_COLORS[i % _AUDIO_TRACK_COLORS.length];
|
||||||
|
var chLabel = tr.channel_layout || (tr.channels ? (tr.channels === 1 ? 'mono' : tr.channels === 2 ? 'stereo' : tr.channels + 'ch') : '—');
|
||||||
|
var trackName = tr.title || ('Track ' + (tr.index != null ? tr.index : i + 1));
|
||||||
|
var langTag = tr.language ? <span className="badge neutral" style={{ marginLeft: 6 }}>{tr.language}</span> : null;
|
||||||
|
var codecLabel = tr.codec || '—';
|
||||||
|
var srLabel = tr.sample_rate ? (tr.sample_rate / 1000).toFixed(1) + ' kHz' : '—';
|
||||||
|
var bdLabel = tr.bit_depth ? tr.bit_depth + '-bit' : '—';
|
||||||
|
var brLabel = tr.bit_rate ? Math.round(tr.bit_rate / 1000) + ' kbps' : '—';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={i} className={'audio-track' + (isAudible ? '' : ' muted')}>
|
||||||
|
<div className="audio-track-label" style={{ color: color }}>
|
||||||
|
<Icon name="audio" size={13} />
|
||||||
|
<span style={{ fontWeight: 600, fontSize: 12.5 }}>{trackName}</span>
|
||||||
|
{langTag}
|
||||||
|
</div>
|
||||||
|
<div className="audio-track-waveform">
|
||||||
|
<Waveform seed={tr.index || i} color={color} />
|
||||||
|
</div>
|
||||||
|
<div className="audio-track-meters">
|
||||||
|
<AudioLevelMeter level={isAudible ? (levels.L || 0) : 0} label="L" />
|
||||||
|
<AudioLevelMeter level={isAudible ? (levels.R || 0) : 0} label="R" />
|
||||||
|
</div>
|
||||||
|
<div className="audio-track-meta mono">
|
||||||
|
<span>{codecLabel}</span>
|
||||||
|
<span>{chLabel}</span>
|
||||||
|
<span>{srLabel}</span>
|
||||||
|
<span>{bdLabel}</span>
|
||||||
|
<span>{brLabel}</span>
|
||||||
|
</div>
|
||||||
|
<div className="audio-track-controls">
|
||||||
|
<button className={'audio-btn mute' + (st.muted ? ' active' : '')} onClick={function() { toggleMute(i); }} title="Mute">M</button>
|
||||||
|
<button className={'audio-btn solo' + (st.solo ? ' active' : '')} onClick={function() { toggleSolo(i); }} title="Solo">S</button>
|
||||||
|
<div className="audio-fader">
|
||||||
|
<input type="range" min="0" max="100" value={st.volume}
|
||||||
|
onChange={function(e) { setTrackVol(i, parseInt(e.target.value, 10)); }}
|
||||||
|
title={st.volume + '%'}
|
||||||
|
/>
|
||||||
|
<span className="mono">{st.volume}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<div className="audio-master">
|
||||||
|
<div className="audio-master-label">Master</div>
|
||||||
|
<div className="audio-master-meters">
|
||||||
|
<AudioLevelMeter level={levels.L || 0} label="L" tall />
|
||||||
|
<AudioLevelMeter level={levels.R || 0} label="R" tall />
|
||||||
|
</div>
|
||||||
|
<div className="audio-fader master-fader">
|
||||||
|
<input type="range" min="0" max="100" value={masterVol}
|
||||||
|
onChange={function(e) { setMasterVol(parseInt(e.target.value, 10)); }}
|
||||||
|
title={masterVol + '%'}
|
||||||
|
/>
|
||||||
|
<span className="mono">{masterVol}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AudioLevelMeter({ level, label, tall }) {
|
||||||
|
var segs = tall ? 28 : 16;
|
||||||
|
var pct = Math.max(0, Math.min(1, level));
|
||||||
|
return (
|
||||||
|
<div className="audio-level-meter">
|
||||||
|
<div className="audio-level-bar">
|
||||||
|
{Array.from({ length: segs }).map(function(_, i) {
|
||||||
|
var v = i / segs;
|
||||||
|
var on = v < pct;
|
||||||
|
var color = v < 0.6 ? 'var(--success)' : v < 0.85 ? 'var(--warning)' : 'var(--danger)';
|
||||||
|
return <div key={i} className="audio-level-seg" style={{ background: on ? color : 'var(--bg-3)', opacity: on ? 1 : 0.3 }} />;
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<span className="audio-level-label mono">{label}</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -388,3 +388,215 @@
|
||||||
}
|
}
|
||||||
.meta-row .meta-k { color: var(--text-3); }
|
.meta-row .meta-k { color: var(--text-3); }
|
||||||
.meta-row .meta-v { font-family: var(--font-mono); font-size: 12px; word-break: break-all; }
|
.meta-row .meta-v { font-family: var(--font-mono); font-size: 12px; word-break: break-all; }
|
||||||
|
|
||||||
|
/* ========== Audio tab ========== */
|
||||||
|
.audio-tab {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 12px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.audio-tracks {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.audio-track {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 140px 1fr 64px auto auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
background: var(--bg-2);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--r-md);
|
||||||
|
transition: opacity 120ms;
|
||||||
|
}
|
||||||
|
|
||||||
|
.audio-track.muted {
|
||||||
|
opacity: 0.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.audio-track-label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
white-space: nowrap;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.audio-track-waveform {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
height: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.audio-track-waveform .waveform {
|
||||||
|
width: 100%;
|
||||||
|
height: 32px;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.audio-track-meters {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.audio-track-meta {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 10.5px;
|
||||||
|
color: var(--text-3);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.audio-track-controls {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.audio-btn {
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
border-radius: 3px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: var(--bg-3);
|
||||||
|
color: var(--text-3);
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
cursor: pointer;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
transition: background 80ms, color 80ms, border-color 80ms;
|
||||||
|
}
|
||||||
|
|
||||||
|
.audio-btn:hover {
|
||||||
|
border-color: var(--border-stronger);
|
||||||
|
color: var(--text-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.audio-btn.mute.active {
|
||||||
|
background: var(--danger);
|
||||||
|
color: white;
|
||||||
|
border-color: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.audio-btn.solo.active {
|
||||||
|
background: var(--warning);
|
||||||
|
color: var(--bg-0);
|
||||||
|
border-color: var(--warning);
|
||||||
|
}
|
||||||
|
|
||||||
|
.audio-fader {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.audio-fader input[type="range"] {
|
||||||
|
width: 60px;
|
||||||
|
height: 4px;
|
||||||
|
appearance: none;
|
||||||
|
background: var(--bg-3);
|
||||||
|
border-radius: 2px;
|
||||||
|
outline: none;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.audio-fader input[type="range"]::-webkit-slider-thumb {
|
||||||
|
appearance: none;
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--text-1);
|
||||||
|
border: 1px solid var(--border-strong);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.audio-fader input[type="range"]::-moz-range-thumb {
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--text-1);
|
||||||
|
border: 1px solid var(--border-strong);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.audio-fader .mono {
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--text-4);
|
||||||
|
min-width: 24px;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.audio-master {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
background: var(--bg-1);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--r-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.audio-master-label {
|
||||||
|
font-size: 12.5px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-2);
|
||||||
|
min-width: 48px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.audio-master-meters {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.master-fader input[type="range"] {
|
||||||
|
width: 100px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Audio level meters */
|
||||||
|
.audio-level-meter {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.audio-level-bar {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column-reverse;
|
||||||
|
gap: 1px;
|
||||||
|
width: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.audio-level-seg {
|
||||||
|
width: 10px;
|
||||||
|
height: 3px;
|
||||||
|
border-radius: 1px;
|
||||||
|
transition: background 60ms;
|
||||||
|
}
|
||||||
|
|
||||||
|
.audio-level-label {
|
||||||
|
font-size: 9px;
|
||||||
|
color: var(--text-4);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive: stack on narrow screens */
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.audio-track {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
.audio-track-waveform { height: 24px; }
|
||||||
|
.audio-track-meters { justify-content: flex-start; }
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -62,15 +62,37 @@ export const getMediaInfo = async (inputPath) => {
|
||||||
if (den > 0) fps = Math.round((num / den) * 1000) / 1000;
|
if (den > 0) fps = Math.round((num / den) * 1000) / 1000;
|
||||||
}
|
}
|
||||||
|
|
||||||
const hasAudio = (info.streams || []).some(s => s.codec_type === 'audio');
|
const audioStreams = (info.streams || []).filter(s => s.codec_type === 'audio');
|
||||||
|
const hasAudio = audioStreams.length > 0;
|
||||||
|
|
||||||
|
const audioMetadata = audioStreams.map(s => {
|
||||||
|
const bitDepth = s.bits_per_raw_sample
|
||||||
|
? parseInt(s.bits_per_raw_sample, 10)
|
||||||
|
: s.bit_depth
|
||||||
|
? parseInt(s.bit_depth, 10)
|
||||||
|
: null;
|
||||||
|
return {
|
||||||
|
index: s.index ?? 0,
|
||||||
|
codec: s.codec_name || null,
|
||||||
|
channels: s.channels ? parseInt(s.channels, 10) : null,
|
||||||
|
channel_layout: s.channel_layout || null,
|
||||||
|
sample_rate: s.sample_rate ? parseInt(s.sample_rate, 10) : null,
|
||||||
|
bit_depth: bitDepth,
|
||||||
|
bit_rate: s.bit_rate ? parseInt(s.bit_rate, 10) : null,
|
||||||
|
language: s.tags?.language || null,
|
||||||
|
title: s.tags?.title || null,
|
||||||
|
disposition: s.disposition || {},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
fps,
|
fps,
|
||||||
codec: videoStream?.codec_name || null,
|
codec: videoStream?.codec_name || null,
|
||||||
resolution: videoStream ? `${videoStream.width}x${videoStream.height}` : null,
|
resolution: videoStream ? `${videoStream.width}x${videoStream.height}` : null,
|
||||||
durationMs: fmt.duration ? Math.round(parseFloat(fmt.duration) * 1000) : null,
|
durationMs: fmt.duration ? Math.round(parseFloat(fmt.duration) * 1000) : null,
|
||||||
fileSizeBytes: fmt.size ? parseInt(fmt.size, 10) : null,
|
fileSizeBytes: fmt.size ? parseInt(fmt.size, 10) : null,
|
||||||
hasAudio,
|
hasAudio,
|
||||||
|
audioMetadata: audioMetadata.length > 0 ? audioMetadata : null,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -200,26 +200,28 @@ export const proxyWorker = async (job) => {
|
||||||
// Update asset record — store extracted metadata + proxy key
|
// Update asset record — store extracted metadata + proxy key
|
||||||
// Use COALESCE so we never overwrite fields the capture service already set
|
// Use COALESCE so we never overwrite fields the capture service already set
|
||||||
await job.updateProgress(90);
|
await job.updateProgress(90);
|
||||||
await query(
|
await query(
|
||||||
`UPDATE assets
|
`UPDATE assets
|
||||||
SET proxy_s3_key = $1,
|
SET proxy_s3_key = $1,
|
||||||
fps = COALESCE($2, fps),
|
fps = COALESCE($2, fps),
|
||||||
codec = COALESCE($3, codec),
|
codec = COALESCE($3, codec),
|
||||||
resolution = COALESCE($4, resolution),
|
resolution = COALESCE($4, resolution),
|
||||||
duration_ms = COALESCE($5, duration_ms),
|
duration_ms = COALESCE($5, duration_ms),
|
||||||
file_size = COALESCE($6, file_size),
|
file_size = COALESCE($6, file_size),
|
||||||
updated_at = NOW()
|
audio_metadata = COALESCE($8, audio_metadata),
|
||||||
WHERE id = $7`,
|
updated_at = NOW()
|
||||||
[
|
WHERE id = $7`,
|
||||||
outputKey,
|
[
|
||||||
mediaInfo.fps ?? null,
|
outputKey,
|
||||||
mediaInfo.codec ?? null,
|
mediaInfo.fps ?? null,
|
||||||
mediaInfo.resolution ?? null,
|
mediaInfo.codec ?? null,
|
||||||
mediaInfo.durationMs ?? null,
|
mediaInfo.resolution ?? null,
|
||||||
mediaInfo.fileSizeBytes ?? null,
|
mediaInfo.durationMs ?? null,
|
||||||
assetId,
|
mediaInfo.fileSizeBytes ?? null,
|
||||||
]
|
assetId,
|
||||||
);
|
mediaInfo.audioMetadata ? JSON.stringify(mediaInfo.audioMetadata) : null,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
// Now proxy exists in S3 — safe to queue thumbnail generation
|
// Now proxy exists in S3 — safe to queue thumbnail generation
|
||||||
const thumbnailKey = `thumbnails/${assetId}.jpg`;
|
const thumbnailKey = `thumbnails/${assetId}.jpg`;
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue