feat(editor): media-panel search, sequence duration badge, parseFloat guard

- Media panel gains a search input that filters the clip list in real time
  (case-insensitive match on display_name / filename)
- Timeline toolbar shows total sequence duration (e.g. 00:05:23;14) and
  frame rate, updated whenever clips change or a sequence is opened
- parseFloat() guard on state.seq.frame_rate so a NUMERIC string from
  Postgres never leaks into Timeline.render() / applyHistory()
This commit is contained in:
Zac Gaetano 2026-05-19 23:27:25 -04:00
parent 4d0e715982
commit 36e668455f

View file

@ -112,6 +112,31 @@
color: var(--text-tertiary);
}
.media-panel-search {
padding: var(--sp-2) var(--sp-3);
border-bottom: 1px solid var(--border);
flex-shrink: 0;
}
.media-panel-search input {
width: 100%;
height: 24px;
padding: 0 var(--sp-2);
font-size: var(--text-xs);
background: var(--bg-surface);
border: 1px solid var(--border);
border-radius: var(--r-sm);
color: var(--text-primary);
outline: none;
box-sizing: border-box;
}
.media-panel-search input:focus {
border-color: var(--accent-border);
}
.media-panel-search input::placeholder {
color: var(--text-tertiary);
}
.media-asset-list {
flex: 1;
overflow-y: auto;
@ -193,8 +218,27 @@
.tl-sep { width: 1px; height: 18px; background: var(--border); }
.tl-save-status {
.tl-seq-info {
margin-left: auto;
display: flex;
align-items: center;
gap: var(--sp-3);
}
.tl-seq-duration {
font-size: var(--text-xs);
font-variant-numeric: tabular-nums;
font-family: var(--font-mono, monospace);
color: var(--text-secondary);
letter-spacing: .03em;
}
.tl-seq-fps {
font-size: var(--text-xs);
color: var(--text-tertiary);
}
.tl-save-status {
font-size: var(--text-xs);
color: var(--text-tertiary);
}
@ -425,6 +469,9 @@
<option value="">All assets</option>
</select>
</div>
<div class="media-panel-search">
<input type="search" id="mediaSearch" placeholder="Search clips…" autocomplete="off" spellcheck="false">
</div>
<div class="media-asset-list" id="mediaAssetList"></div>
</div>
@ -439,7 +486,11 @@
<button class="tl-tool-btn" id="redoBtn" title="Redo (Ctrl+Shift+Z)">&#8618;</button>
<div class="tl-sep"></div>
<button class="tl-tool-btn" id="helpBtn" title="Keyboard shortcuts (?)">?</button>
<span class="tl-save-status" id="saveStatus"></span>
<div class="tl-seq-info">
<span class="tl-seq-fps" id="seqFps"></span>
<span class="tl-seq-duration" id="seqDuration"></span>
<span class="tl-save-status" id="saveStatus"></span>
</div>
</div>
<div class="timeline-container" id="timelineContainer"></div>
</div>
@ -593,6 +644,11 @@ document.addEventListener('DOMContentLoaded', async () => {
});
})();
// Media search
document.getElementById('mediaSearch').addEventListener('input', function() {
renderMediaList();
});
await loadProject();
await loadSequences();
await loadMediaAssets();
@ -641,8 +697,10 @@ async function openSequence(id) {
state.history = [cloneClips(state.seq.clips)];
state.historyIdx = 0;
document.getElementById('seqSelect').value = id;
Timeline.render(state.seq.clips, { fps: state.seq.frame_rate || 59.94 });
const fps = parseFloat(state.seq.frame_rate) || 59.94;
Timeline.render(state.seq.clips, { fps });
updateProgramScrub();
updateSeqInfo();
}
// ════════════════════════════════════════════════════════════════
@ -668,9 +726,23 @@ function fmtMs(ms) {
}
function renderMediaList() {
const list = document.getElementById('mediaAssetList');
const list = document.getElementById('mediaAssetList');
const query = (document.getElementById('mediaSearch').value || '').trim().toLowerCase();
const assets = query
? state.assets.filter(a =>
(a.display_name || '').toLowerCase().includes(query) ||
(a.filename || '').toLowerCase().includes(query))
: state.assets;
list.innerHTML = '';
state.assets.forEach(function(asset) {
if (assets.length === 0) {
list.innerHTML = '<div style="padding:var(--sp-4) var(--sp-3);font-size:var(--text-xs);color:var(--text-tertiary);">' +
(query ? 'No clips match "' + esc(query) + '"' : 'No assets in this bin.') + '</div>';
return;
}
assets.forEach(function(asset) {
const el = document.createElement('div');
el.className = 'media-asset-item';
@ -708,6 +780,23 @@ function renderMediaList() {
});
}
// ════════════════════════════════════════════════════════════════
// SEQUENCE INFO BADGE
// ════════════════════════════════════════════════════════════════
function updateSeqInfo() {
if (!state.seq) {
document.getElementById('seqDuration').textContent = '';
document.getElementById('seqFps').textContent = '';
return;
}
const fps = parseFloat(state.seq.frame_rate) || 59.94;
const clips = state.seq.clips || [];
const maxOut = clips.reduce(function(m, c) { return Math.max(m, c.timeline_out_frames || 0); }, 0);
const durTC = maxOut > 0 ? TC.framesToTC(maxOut) : '00:00:00;00';
document.getElementById('seqDuration').textContent = durTC;
document.getElementById('seqFps').textContent = fps + ' fps';
}
// ════════════════════════════════════════════════════════════════
// SOURCE MONITOR
// ════════════════════════════════════════════════════════════════
@ -920,6 +1009,7 @@ function onClipsChanged(clips) {
state.history.push(cloneClips(clips));
state.historyIdx = state.history.length - 1;
state.seq.clips = clips;
updateSeqInfo();
markDirty();
}
@ -992,7 +1082,9 @@ function redo() {
function applyHistory() {
const clips = cloneClips(state.history[state.historyIdx]);
state.seq.clips = clips;
Timeline.render(clips, { fps: state.seq.frame_rate || 59.94 });
const fps = parseFloat(state.seq.frame_rate) || 59.94;
Timeline.render(clips, { fps });
updateSeqInfo();
markDirty();
}
@ -1077,7 +1169,7 @@ function toggleKbdHelp() {
}
document.addEventListener('DOMContentLoaded', function() {
document.getElementById('closeKbdHelp').onclick = closeKbdHelp;
document.getElementById('closeKbdHelp').onclick = closeKbdHelp;
document.getElementById('kbdHelpBackdrop').onclick = closeKbdHelp;
});