From 7d8ccc95e9f29a342a1e85d332d47b20d4419c90 Mon Sep 17 00:00:00 2001 From: ZGaetano Date: Mon, 18 May 2026 19:58:34 -0400 Subject: [PATCH 1/6] feat(ui): add 59.94 DF timecode utility module --- services/web-ui/public/js/timecode.js | 77 +++++++++++++++++++++++++++ 1 file changed, 77 insertions(+) create mode 100644 services/web-ui/public/js/timecode.js diff --git a/services/web-ui/public/js/timecode.js b/services/web-ui/public/js/timecode.js new file mode 100644 index 0000000..e7bebe4 --- /dev/null +++ b/services/web-ui/public/js/timecode.js @@ -0,0 +1,77 @@ +// services/web-ui/public/js/timecode.js +// 59.94 fps drop-frame timecode utilities. +// Exposes: window.TC + +(function (global) { + 'use strict'; + + // 59.94 = 60000/1001 + const FPS_EXACT = 60000 / 1001; + const NOM = 60; // nominal integer fps + const DROP = 4; // frames dropped per minute (except every 10th) + const FRAMES_PER_MIN = NOM * 60 - DROP; // 3596 + const FRAMES_PER_10MIN = FRAMES_PER_MIN * 10 + DROP; // 35964 + const FRAMES_PER_HOUR = FRAMES_PER_10MIN * 6; // 215784 + + function pad2(n) { return String(Math.floor(n)).padStart(2, '0'); } + + /** + * Convert a frame count to a 59.94 DF timecode string: HH:MM:SS;FF + */ + function framesToTC(totalFrames) { + const fc = Math.max(0, Math.round(totalFrames)); + const h = Math.floor(fc / FRAMES_PER_HOUR); + let rem = fc % FRAMES_PER_HOUR; + const tm = Math.floor(rem / FRAMES_PER_10MIN); // tens of minutes (0–5) + rem = rem % FRAMES_PER_10MIN; + let m = 0; + if (rem >= DROP) { + m = Math.floor((rem - DROP) / FRAMES_PER_MIN) + 1; + rem = (rem - DROP) % FRAMES_PER_MIN; + } + const M = tm * 10 + m; + const s = Math.floor(rem / NOM); + const ff = rem % NOM; + return `${pad2(h)}:${pad2(M)}:${pad2(s)};${pad2(ff)}`; + } + + /** + * Convert a 59.94 DF timecode string (HH:MM:SS;FF or HH:MM:SS:FF) to frame count. + */ + function tcToFrames(tc) { + if (!tc) return 0; + const clean = String(tc).replace(';', ':'); + const parts = clean.split(':').map(Number); + if (parts.length !== 4) return 0; + const [h, m, s, f] = parts; + const totalMinutes = h * 60 + m; + return (NOM * 3600 * h) + + (NOM * 60 * m) + + (NOM * s) + + f + - DROP * (totalMinutes - Math.floor(totalMinutes / 10)); + } + + /** + * Convert seconds (float) → frame count at 59.94. + */ + function secondsToFrames(seconds) { + return Math.round(seconds * FPS_EXACT); + } + + /** + * Convert frame count → seconds (float) at 59.94. + */ + function framesToSeconds(frames) { + return frames / FPS_EXACT; + } + + global.TC = { + framesToTC, + tcToFrames, + secondsToFrames, + framesToSeconds, + FPS: FPS_EXACT, + }; + +})(window); From ad6e83634584348635f47c7988c30dc50acd1e9a Mon Sep 17 00:00:00 2001 From: ZGaetano Date: Mon, 18 May 2026 19:58:35 -0400 Subject: [PATCH 2/6] feat(ui): add sequence API helpers to api.js --- services/web-ui/public/js/api.js | 66 ++++++++++++++++++++++++++++++++ 1 file changed, 66 insertions(+) diff --git a/services/web-ui/public/js/api.js b/services/web-ui/public/js/api.js index d3a14df..a4280e5 100644 --- a/services/web-ui/public/js/api.js +++ b/services/web-ui/public/js/api.js @@ -456,3 +456,69 @@ async function createToken(data) { async function revokeToken(id) { return api(`/tokens/${id}`, { method: 'DELETE' }); } + +// ============================================================ +// SEQUENCE API CALLS +// ============================================================ + +async function getSequences(projectId) { + return api(`/sequences?project_id=${projectId}`); +} + +async function createSequence(data) { + return api('/sequences', { + method: 'POST', + body: JSON.stringify(data), + }); +} + +async function getSequence(sequenceId) { + return api(`/sequences/${sequenceId}`); +} + +async function updateSequence(sequenceId, data) { + return api(`/sequences/${sequenceId}`, { + method: 'PUT', + body: JSON.stringify(data), + }); +} + +async function deleteSequence(sequenceId) { + return api(`/sequences/${sequenceId}`, { method: 'DELETE' }); +} + +/** + * Replace all clips in a sequence. + * @param {string} sequenceId + * @param {Array<{asset_id, track, timeline_in_frames, timeline_out_frames, source_in_frames, source_out_frames}>} clips + */ +async function syncSequenceClips(sequenceId, clips) { + return api(`/sequences/${sequenceId}/clips`, { + method: 'PUT', + body: JSON.stringify(clips), + }); +} + +/** + * Download EDL for the V1 track of a sequence. + * Triggers a file download in the browser. + */ +async function exportSequenceEDL(sequenceId, filename) { + try { + const response = await fetch(`${API_BASE}/sequences/${sequenceId}/export/edl`, { + method: 'POST', + credentials: 'include', + }); + if (!response.ok) throw new Error(`EDL export failed: ${response.status}`); + const blob = await response.blob(); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = filename || 'sequence.edl'; + a.click(); + URL.revokeObjectURL(url); + return { success: true }; + } catch (error) { + return { success: false, error: error.message }; + } +} From 10152b5ad7d54abb2997336bbc4bd90835f21759 Mon Sep 17 00:00:00 2001 From: ZGaetano Date: Mon, 18 May 2026 20:02:41 -0400 Subject: [PATCH 3/6] feat(ui): add DOM-based timeline engine (select, razor, playhead) --- services/web-ui/public/js/timeline.js | 505 ++++++++++++++++++++++++++ 1 file changed, 505 insertions(+) create mode 100644 services/web-ui/public/js/timeline.js diff --git a/services/web-ui/public/js/timeline.js b/services/web-ui/public/js/timeline.js new file mode 100644 index 0000000..bdc8bf8 --- /dev/null +++ b/services/web-ui/public/js/timeline.js @@ -0,0 +1,505 @@ +// services/web-ui/public/js/timeline.js +// DOM-based NLE timeline engine. Exposes: window.Timeline +// Depends on: window.TC (timecode.js must be loaded first) + +(function (global) { + 'use strict'; + + const TRACK_HEIGHT = 48; // px, each track row + const HEADER_W = 40; // px, track label column width + const MIN_CLIP_PX = 4; // minimum rendered clip width + + const TRACKS = [ + { id: 0, label: 'V1', type: 'video' }, + { id: 1, label: 'V2', type: 'video' }, + { id: 100, label: 'A1', type: 'audio' }, + { id: 101, label: 'A2', type: 'audio' }, + ]; + + // ── Internal state ────────────────────────────────────────────────────────── + const s = { + container: null, + rulerEl: null, + tracksEl: null, + playheadEl: null, + clips: [], // working copy; each has a local .id + scale: 100, // px per second + fps: 59.94, + playheadFrames: 0, + activeTool: 'select', // 'select' | 'razor' | 'hand' + selectedId: null, + onClipsChanged: null, // callback(clips[]) + onPlayheadMoved: null, // callback(frames) + }; + + let _uid = 0; + function uid() { return 'tl_' + (++_uid); } + function framesToPx(f) { return (f / s.fps) * s.scale; } + function pxToFrames(px) { return Math.round((px / s.scale) * s.fps); } + + // ── init ──────────────────────────────────────────────────────────────────── + + function init(container, options) { + options = options || {}; + s.container = container; + s.fps = options.fps || 59.94; + s.scale = options.scale || 100; + s.onClipsChanged = options.onClipsChanged || null; + s.onPlayheadMoved = options.onPlayheadMoved || null; + + container.innerHTML = ''; + container.style.cssText = [ + 'position:relative', 'overflow-x:auto', 'overflow-y:hidden', + 'user-select:none', 'display:flex', 'flex-direction:column', + ].join(';'); + + // Ruler row + s.rulerEl = document.createElement('div'); + s.rulerEl.className = 'tl-ruler'; + s.rulerEl.style.cssText = [ + 'position:sticky', 'top:0', 'z-index:10', + 'height:24px', 'flex-shrink:0', + 'background:var(--bg-base)', 'border-bottom:1px solid var(--border)', + 'cursor:pointer', + ].join(';'); + s.rulerEl.addEventListener('click', _onRulerClick); + container.appendChild(s.rulerEl); + + // Tracks wrapper (clips + playhead live here) + s.tracksEl = document.createElement('div'); + s.tracksEl.style.cssText = 'position:relative;flex:1;'; + TRACKS.forEach(function (t) { + var row = document.createElement('div'); + row.className = 'tl-track-row'; + row.style.cssText = [ + 'display:flex', 'height:' + TRACK_HEIGHT + 'px', + 'border-bottom:1px solid var(--border)', + ].join(';'); + + // Sticky label + var hdr = document.createElement('div'); + hdr.className = 'tl-track-hdr'; + hdr.textContent = t.label; + hdr.style.cssText = [ + 'width:' + HEADER_W + 'px', 'flex-shrink:0', + 'display:flex', 'align-items:center', 'justify-content:center', + 'font-size:10px', 'font-weight:600', 'letter-spacing:.05em', + 'color:var(--text-tertiary)', 'background:var(--bg-panel)', + 'border-right:1px solid var(--border)', + 'position:sticky', 'left:0', 'z-index:5', + ].join(';'); + row.appendChild(hdr); + + // Clip area + var area = document.createElement('div'); + area.className = 'tl-clip-area'; + area.dataset.trackId = t.id; + area.style.cssText = 'flex:1;position:relative;overflow:hidden;'; + area.addEventListener('click', _onAreaClick); + row.appendChild(area); + + s.tracksEl.appendChild(row); + }); + + // Playhead + s.playheadEl = document.createElement('div'); + s.playheadEl.className = 'tl-playhead'; + s.playheadEl.style.cssText = [ + 'position:absolute', 'top:0', 'bottom:0', + 'width:2px', 'background:var(--accent)', + 'pointer-events:none', 'z-index:20', + ].join(';'); + s.tracksEl.appendChild(s.playheadEl); + + container.appendChild(s.tracksEl); + + // Zoom: Ctrl+wheel + container.addEventListener('wheel', function (e) { + if (!e.ctrlKey) return; + e.preventDefault(); + var delta = e.deltaY < 0 ? 1.15 : 0.87; + s.scale = Math.min(500, Math.max(20, s.scale * delta)); + _renderClips(); + _renderRuler(); + }, { passive: false }); + + // Keyboard shortcuts (Delete, tool keys) + document.addEventListener('keydown', _onKeyDown); + + _renderRuler(); + } + + // ── Ruler ─────────────────────────────────────────────────────────────────── + + function _renderRuler() { + s.rulerEl.innerHTML = ''; + var totalSecs = 600; // 10 minutes + var totalPx = HEADER_W + totalSecs * s.scale; + + var canvas = document.createElement('canvas'); + canvas.width = totalPx; + canvas.height = 24; + canvas.style.cssText = 'display:block;width:' + totalPx + 'px;height:24px;'; + var ctx = canvas.getContext('2d'); + + var bgColor = getComputedStyle(document.documentElement) + .getPropertyValue('--bg-base').trim() || '#0d0f14'; + var borderColor = getComputedStyle(document.documentElement) + .getPropertyValue('--border').trim() || '#2a2d35'; + var textColor = getComputedStyle(document.documentElement) + .getPropertyValue('--text-tertiary').trim() || '#5a6070'; + + ctx.fillStyle = bgColor; + ctx.fillRect(0, 0, totalPx, 24); + + // Tick interval: coarser when zoomed out + var tickSecs = s.scale < 30 ? 10 : s.scale < 60 ? 5 : s.scale < 120 ? 2 : 1; + + ctx.strokeStyle = borderColor; + ctx.fillStyle = textColor; + ctx.font = '9px Inter, sans-serif'; + ctx.textAlign = 'left'; + + for (var t = 0; t <= totalSecs; t += tickSecs) { + var x = HEADER_W + t * s.scale; + ctx.beginPath(); + ctx.moveTo(x, 18); + ctx.lineTo(x, 24); + ctx.stroke(); + if (t % (tickSecs * 5) === 0) { + var mm = String(Math.floor(t / 60)).padStart(2, '0'); + var ss = String(t % 60).padStart(2, '0'); + ctx.fillText(mm + ':' + ss, x + 2, 14); + } + } + s.rulerEl.appendChild(canvas); + } + + function _onRulerClick(e) { + var rect = s.rulerEl.getBoundingClientRect(); + var x = e.clientX - rect.left - HEADER_W + s.container.scrollLeft; + if (x < 0) return; + var frames = pxToFrames(x); + setPlayhead(frames); + if (s.onPlayheadMoved) s.onPlayheadMoved(frames); + } + + // ── Render clips ──────────────────────────────────────────────────────────── + + function render(clips, options) { + options = options || {}; + s.clips = clips.map(function (c) { return Object.assign({}, c, { _id: c._id || uid() }); }); + if (options.fps) s.fps = options.fps; + _renderClips(); + } + + function _renderClips() { + TRACKS.forEach(function (t) { + var area = s.tracksEl.querySelector('.tl-clip-area[data-track-id="' + t.id + '"]'); + if (!area) return; + area.innerHTML = ''; + s.clips + .filter(function (c) { return c.track === t.id; }) + .sort(function (a, b) { return a.timeline_in_frames - b.timeline_in_frames; }) + .forEach(function (clip) { + area.appendChild(_makeClipEl(clip, t)); + }); + }); + _positionPlayhead(); + } + + function _makeClipEl(clip, track) { + var left = framesToPx(clip.timeline_in_frames); + var width = Math.max(MIN_CLIP_PX, framesToPx(clip.timeline_out_frames - clip.timeline_in_frames)); + var isSelected = clip._id === s.selectedId; + + var el = document.createElement('div'); + el.className = 'tl-clip'; + el.dataset.clipId = clip._id; + el.style.cssText = [ + 'position:absolute', + 'left:' + left + 'px', + 'width:' + width + 'px', + 'height:' + (TRACK_HEIGHT - 2) + 'px', + 'top:1px', + 'border-radius:3px', + 'box-sizing:border-box', + 'overflow:hidden', + 'display:flex', + 'align-items:center', + 'padding:0 6px', + 'background:' + (track.type === 'video' + ? 'oklch(55% 0.15 52 / 0.22)' + : 'oklch(55% 0.14 280 / 0.22)'), + 'border:1px solid ' + (isSelected + ? 'var(--accent)' + : 'var(--border-strong)'), + 'cursor:' + (s.activeTool === 'razor' ? 'crosshair' : 'default'), + ].join(';'); + + // Clip label + var lbl = document.createElement('span'); + lbl.textContent = clip.display_name || 'Clip'; + lbl.style.cssText = [ + 'font-size:9px', 'font-weight:500', + 'color:var(--text-secondary)', + 'overflow:hidden', 'text-overflow:ellipsis', 'white-space:nowrap', + 'pointer-events:none', 'flex:1', + ].join(';'); + el.appendChild(lbl); + + if (s.activeTool === 'select') { + // Trim handles (shown on hover) + var lh = _makeHandle('left'); + var rh = _makeHandle('right'); + el.appendChild(lh); + el.appendChild(rh); + + el.addEventListener('mouseenter', function () { + lh.style.opacity = '0.8'; + rh.style.opacity = '0.8'; + }); + el.addEventListener('mouseleave', function () { + lh.style.opacity = '0'; + rh.style.opacity = '0'; + }); + + // Drag to move (body only) + el.addEventListener('mousedown', function (e) { + if (e.target === lh || e.target === rh) return; + s.selectedId = clip._id; + _renderClips(); + _onMoveStart(e, clip); + }); + + lh.addEventListener('mousedown', function (e) { + e.stopPropagation(); + _onTrimStart(e, clip, 'left'); + }); + rh.addEventListener('mousedown', function (e) { + e.stopPropagation(); + _onTrimStart(e, clip, 'right'); + }); + } + + if (s.activeTool === 'razor') { + el.addEventListener('click', function (e) { _onRazorClick(e, clip); }); + } + + return el; + } + + function _makeHandle(side) { + var h = document.createElement('div'); + h.style.cssText = [ + 'position:absolute', + side + ':0', + 'top:0', 'bottom:0', + 'width:6px', + 'cursor:' + (side === 'left' ? 'w-resize' : 'e-resize'), + 'background:var(--accent)', + 'opacity:0', + 'transition:opacity 0.12s', + ].join(';'); + return h; + } + + // ── Select tool: move ─────────────────────────────────────────────────────── + + function _onMoveStart(e, clip) { + var startX = e.clientX; + var origIn = clip.timeline_in_frames; + var origOut = clip.timeline_out_frames; + var duration = origOut - origIn; + + function onMove(ev) { + var dx = ev.clientX - startX; + var dFrames = pxToFrames(dx); + clip.timeline_in_frames = Math.max(0, origIn + dFrames); + clip.timeline_out_frames = clip.timeline_in_frames + duration; + _renderClips(); + } + function onUp() { + document.removeEventListener('mousemove', onMove); + document.removeEventListener('mouseup', onUp); + if (s.onClipsChanged) s.onClipsChanged(s.clips.slice()); + } + document.addEventListener('mousemove', onMove); + document.addEventListener('mouseup', onUp); + } + + // ── Select tool: trim ─────────────────────────────────────────────────────── + + function _onTrimStart(e, clip, side) { + var startX = e.clientX; + var origTlIn = clip.timeline_in_frames; + var origTlOut = clip.timeline_out_frames; + var origSrcIn = clip.source_in_frames; + var origSrcOut = clip.source_out_frames; + // Maximum source out is the asset's full duration in frames + var assetFrames = clip.duration_ms + ? Math.round((clip.duration_ms / 1000) * s.fps) + : origSrcOut; + + function onMove(ev) { + var dx = ev.clientX - startX; + var dFr = pxToFrames(dx); + if (side === 'left') { + var newTlIn = Math.max(0, Math.min(origTlIn + dFr, origTlOut - 1)); + var dIn = newTlIn - origTlIn; + clip.timeline_in_frames = newTlIn; + clip.source_in_frames = Math.max(0, origSrcIn + dIn); + } else { + var newTlOut = Math.max(origTlIn + 1, origTlOut + dFr); + var dOut = newTlOut - origTlOut; + clip.timeline_out_frames = newTlOut; + clip.source_out_frames = Math.min(assetFrames, origSrcOut + dOut); + } + _renderClips(); + } + function onUp() { + document.removeEventListener('mousemove', onMove); + document.removeEventListener('mouseup', onUp); + if (s.onClipsChanged) s.onClipsChanged(s.clips.slice()); + } + document.addEventListener('mousemove', onMove); + document.addEventListener('mouseup', onUp); + } + + // ── Razor tool ────────────────────────────────────────────────────────────── + + function _onRazorClick(e, clip) { + // Frame at the click position within the clip element + var clipRect = e.currentTarget.getBoundingClientRect(); + var xWithinClip = e.clientX - clipRect.left; + var framesInClip = pxToFrames(xWithinClip); + var clipDuration = clip.timeline_out_frames - clip.timeline_in_frames; + + // Guard: don't split at the very edge + if (framesInClip <= 0 || framesInClip >= clipDuration) return; + + var splitTlFrame = clip.timeline_in_frames + framesInClip; + var splitSrcFrame = clip.source_in_frames + framesInClip; + + var left = Object.assign({}, clip, { + _id: uid(), + timeline_out_frames: splitTlFrame, + source_out_frames: splitSrcFrame, + }); + var right = Object.assign({}, clip, { + _id: uid(), + timeline_in_frames: splitTlFrame, + source_in_frames: splitSrcFrame, + }); + + var idx = s.clips.findIndex(function (c) { return c._id === clip._id; }); + s.clips.splice(idx, 1, left, right); + _renderClips(); + if (s.onClipsChanged) s.onClipsChanged(s.clips.slice()); + } + + // ── Area click (deselect) ─────────────────────────────────────────────────── + + function _onAreaClick(e) { + if (e.target !== e.currentTarget) return; // hit a clip, not empty space + if (s.activeTool === 'select') { + s.selectedId = null; + _renderClips(); + } + } + + // ── Keyboard ──────────────────────────────────────────────────────────────── + + function _onKeyDown(e) { + if (!s.container) return; + // Don't steal keys from input fields + if (['INPUT','TEXTAREA','SELECT'].includes(document.activeElement.tagName)) return; + + // Tool shortcuts + if (!e.ctrlKey && !e.metaKey && !e.altKey) { + if (e.key === 'v' || e.key === 'V') { setTool('select'); return; } + if (e.key === 'c' || e.key === 'C') { setTool('razor'); return; } + if (e.key === 'h' || e.key === 'H') { setTool('hand'); return; } + } + + // Delete selected clip + if ((e.key === 'Delete' || e.key === 'Backspace') && s.selectedId) { + e.preventDefault(); + s.clips = s.clips.filter(function (c) { return c._id !== s.selectedId; }); + s.selectedId = null; + _renderClips(); + if (s.onClipsChanged) s.onClipsChanged(s.clips.slice()); + } + } + + // ── Playhead ──────────────────────────────────────────────────────────────── + + function _positionPlayhead() { + if (!s.playheadEl) return; + s.playheadEl.style.left = (HEADER_W + framesToPx(s.playheadFrames)) + 'px'; + } + + function setPlayhead(frames) { + s.playheadFrames = Math.max(0, Math.round(frames)); + _positionPlayhead(); + } + + function getPlayhead() { return s.playheadFrames; } + + // ── Tool ──────────────────────────────────────────────────────────────────── + + function setTool(name) { + s.activeTool = name; + if (s.tracksEl) { + s.tracksEl.style.cursor = + name === 'razor' ? 'crosshair' : + name === 'hand' ? 'grab' : 'default'; + } + _renderClips(); + } + + function getTool() { return s.activeTool; } + + // ── Add clip at playhead ───────────────────────────────────────────────────── + + function addClip(asset, srcIn, srcOut, track) { + track = track !== undefined ? track : 0; + srcIn = srcIn || 0; + srcOut = srcOut || (asset.duration_ms ? asset.duration_ms / 1000 : 10); + + var srcInFr = TC.secondsToFrames(srcIn); + var srcOutFr = TC.secondsToFrames(srcOut); + var tlInFr = s.playheadFrames; + var tlOutFr = tlInFr + (srcOutFr - srcInFr); + + var clip = { + _id: uid(), + asset_id: asset.id || asset.asset_id, + display_name: asset.display_name || asset.filename || 'Clip', + duration_ms: asset.duration_ms || null, + streamUrl: asset.streamUrl || null, + track: track, + timeline_in_frames: tlInFr, + timeline_out_frames: tlOutFr, + source_in_frames: srcInFr, + source_out_frames: srcOutFr, + }; + + s.clips.push(clip); + _renderClips(); + // Advance playhead to end of new clip + setPlayhead(tlOutFr); + if (s.onPlayheadMoved) s.onPlayheadMoved(tlOutFr); + if (s.onClipsChanged) s.onClipsChanged(s.clips.slice()); + return clip; + } + + // ── Public API ─────────────────────────────────────────────────────────────── + + global.Timeline = { + init, render, setTool, getTool, + setPlayhead, getPlayhead, + addClip, + }; + +})(window); From 2fabc7329919cf95511e5e8e81c4f04ea7ba6762 Mon Sep 17 00:00:00 2001 From: ZGaetano Date: Mon, 18 May 2026 20:05:34 -0400 Subject: [PATCH 4/6] fix(ui): prevent keydown listener accumulation on re-init --- services/web-ui/public/js/timeline.js | 1 + 1 file changed, 1 insertion(+) diff --git a/services/web-ui/public/js/timeline.js b/services/web-ui/public/js/timeline.js index bdc8bf8..61d30f1 100644 --- a/services/web-ui/public/js/timeline.js +++ b/services/web-ui/public/js/timeline.js @@ -124,6 +124,7 @@ }, { passive: false }); // Keyboard shortcuts (Delete, tool keys) + document.removeEventListener('keydown', _onKeyDown); document.addEventListener('keydown', _onKeyDown); _renderRuler(); From 78a887a3e0169b8f3eac5c2c1876104f10d415fe Mon Sep 17 00:00:00 2001 From: ZGaetano Date: Mon, 18 May 2026 20:10:25 -0400 Subject: [PATCH 5/6] feat(ui): add NLE editor page (editor.html) --- services/web-ui/public/editor.html | 897 +++++++++++++++++++++++++++++ 1 file changed, 897 insertions(+) create mode 100644 services/web-ui/public/editor.html diff --git a/services/web-ui/public/editor.html b/services/web-ui/public/editor.html new file mode 100644 index 0000000..b15ab02 --- /dev/null +++ b/services/web-ui/public/editor.html @@ -0,0 +1,897 @@ + + + + + + Editor — Wild Dragon + + + + + + + +
+ + + + +
+ +
+
+ Editor + / + + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+ 00:00:00;00 + +
+ + +
+ + +
+
+ + +
+
+ +
+
+ + 00:00:00;00 + +
+
+ + +
+
+ Media + +
+
+
+ + +
+
+ + + +
+ + + +
+
+
+ +
+
+
+
+ +
+ + +
+
+
+ New sequence + +
+
+
+ + +
+
+ +
+ + + + + + + + From 8ab71239e31ccf5f20dfbda2c4b89a3006fc6c31 Mon Sep 17 00:00:00 2001 From: ZGaetano Date: Mon, 18 May 2026 20:14:29 -0400 Subject: [PATCH 6/6] feat(ui): add Open in Editor action to library cards --- services/web-ui/public/index.html | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/services/web-ui/public/index.html b/services/web-ui/public/index.html index a584cfb..b5be432 100644 --- a/services/web-ui/public/index.html +++ b/services/web-ui/public/index.html @@ -311,6 +311,10 @@ Library + + + Editor + Ingest @@ -652,6 +656,9 @@ ${asset.duration ? `${formatDuration(asset.duration)}` : ''}
+ @@ -693,6 +700,13 @@ else toast('Delete failed', r.error, 'error'); } + function openInEditor(assetId, e) { + e.stopPropagation(); + const projectId = state.currentProjectId; + if (!projectId) { toast('Select a project first', '', 'warning'); return; } + location.href = 'editor.html?project=' + projectId + '&asset=' + assetId; + } + // ── Search ──────────────────────────────── function setupSearch() { const inp = document.getElementById('searchInput');