feat: in-library asset preview + Premiere plugin installer
Click any asset card to open a modal with the H.264 proxy playing inline (or audio/image, per media_type). Esc or click outside closes. Sidebar shows status/codec/resolution/fps/duration/size/created plus tags and notes. Plugin install side: added install-windows.ps1 that copies the CEP panel to %APPDATA%\Adobe\CEP\extensions, flips PlayerDebugMode=1 across the CSXS.8-13 hives, and prints the next steps. Plugin already wired against the current API. * services/web-ui/public/js/preview.js: standalone IIFE that lazy-injects the modal markup + CSS on first use. Renders <video controls> (or <audio>, <img>) sourced from /api/v1/assets/:id/stream, with sidebar from /api/v1/assets/:id. Falls back to a clear empty state when proxy is still processing. * services/web-ui/public/index.html: loads preview.js, wires asset-card click to window.openAssetPreview(asset.id), guards against delete-button clicks bubbling. * services/premiere-plugin/install-windows.ps1: one-shot Windows installer for the CEP extension.
This commit is contained in:
parent
3ea896c368
commit
ea28c5189d
3 changed files with 246 additions and 0 deletions
39
services/premiere-plugin/install-windows.ps1
Normal file
39
services/premiere-plugin/install-windows.ps1
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
# Wild Dragon MAM - Premiere Pro plugin installer (Windows)
|
||||
# Run from PowerShell as the same user that runs Premiere Pro (NOT as admin)
|
||||
#
|
||||
# Usage:
|
||||
# 1. Open this folder in PowerShell
|
||||
# 2. Set-ExecutionPolicy -Scope Process -ExecutionPolicy Bypass
|
||||
# 3. .\install-windows.ps1
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
$src = $PSScriptRoot
|
||||
$dest = Join-Path $env:APPDATA 'Adobe\CEP\extensions\com.wilddragon.mam.panel'
|
||||
|
||||
Write-Host "Source: $src"
|
||||
Write-Host "Target: $dest"
|
||||
|
||||
if (Test-Path $dest) {
|
||||
Write-Host "Removing existing extension at $dest"
|
||||
Remove-Item -Recurse -Force $dest
|
||||
}
|
||||
New-Item -ItemType Directory -Force -Path $dest | Out-Null
|
||||
Copy-Item -Recurse -Force "$src\*" $dest -Exclude 'install-windows.ps1'
|
||||
|
||||
Write-Host 'Files copied.'
|
||||
|
||||
# Enable CEP debug mode for the supported CEP versions (Premiere Pro uses CSXS.11+).
|
||||
foreach ($v in 8..13) {
|
||||
$regPath = "HKCU:\Software\Adobe\CSXS.$v"
|
||||
if (-not (Test-Path $regPath)) { New-Item -Path $regPath -Force | Out-Null }
|
||||
Set-ItemProperty -Path $regPath -Name 'PlayerDebugMode' -Value 1 -Type String
|
||||
Write-Host "Set PlayerDebugMode=1 on $regPath"
|
||||
}
|
||||
|
||||
Write-Host ''
|
||||
Write-Host 'Done. Restart Premiere Pro, then:'
|
||||
Write-Host ' Window -> Extensions -> Wild Dragon MAM'
|
||||
Write-Host ''
|
||||
Write-Host 'Server URL inside the panel should be the MAM web-UI, e.g.:'
|
||||
Write-Host ' http://10.0.0.25:47434'
|
||||
|
|
@ -458,6 +458,7 @@
|
|||
</div>
|
||||
|
||||
<script src="js/api.js?v=4"></script>
|
||||
<script src="js/preview.js?v=1"></script>
|
||||
<script>
|
||||
const state = {
|
||||
projects: [],
|
||||
|
|
@ -644,6 +645,11 @@
|
|||
if (asset.thumbnail_s3_key) thumbObserver.observe(img);
|
||||
else img.style.display = 'none';
|
||||
|
||||
card.addEventListener('click', (e) => {
|
||||
if (e.target.closest('.asset-action-btn')) return; // delete button etc.
|
||||
if (window.openAssetPreview) window.openAssetPreview(asset.id);
|
||||
});
|
||||
|
||||
grid.appendChild(card);
|
||||
});
|
||||
}
|
||||
|
|
|
|||
201
services/web-ui/public/js/preview.js
Normal file
201
services/web-ui/public/js/preview.js
Normal file
|
|
@ -0,0 +1,201 @@
|
|||
// Asset preview modal - lazy-injects markup + styles, then renders video/audio/image
|
||||
// for a given asset id using /api/v1/assets/:id and /api/v1/assets/:id/stream.
|
||||
|
||||
(function () {
|
||||
let _root = null;
|
||||
let _currentAssetId = null;
|
||||
|
||||
function ensureRoot() {
|
||||
if (_root) return _root;
|
||||
const css = document.createElement('style');
|
||||
css.textContent = STYLES;
|
||||
document.head.appendChild(css);
|
||||
|
||||
_root = document.createElement('div');
|
||||
_root.className = 'preview-overlay';
|
||||
_root.innerHTML = TEMPLATE;
|
||||
document.body.appendChild(_root);
|
||||
|
||||
_root.addEventListener('click', (e) => {
|
||||
if (e.target === _root) closePreview();
|
||||
});
|
||||
_root.querySelector('.preview-close-btn').addEventListener('click', closePreview);
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape' && _root.classList.contains('open')) closePreview();
|
||||
});
|
||||
return _root;
|
||||
}
|
||||
|
||||
async function openPreview(assetId) {
|
||||
if (!assetId) return;
|
||||
ensureRoot();
|
||||
_currentAssetId = assetId;
|
||||
_root.classList.add('open');
|
||||
|
||||
const stage = _root.querySelector('.preview-stage');
|
||||
const titleEl = _root.querySelector('.preview-title');
|
||||
const metaEl = _root.querySelector('.preview-meta');
|
||||
const tagsEl = _root.querySelector('.preview-tags');
|
||||
const notesEl = _root.querySelector('.preview-notes');
|
||||
|
||||
stage.innerHTML = '<div class="preview-empty">Loading…</div>';
|
||||
titleEl.textContent = '';
|
||||
metaEl.innerHTML = '';
|
||||
tagsEl.innerHTML = '';
|
||||
notesEl.textContent = '';
|
||||
|
||||
let asset;
|
||||
try {
|
||||
const r = await fetch(`/api/v1/assets/${assetId}`, { credentials: 'include' });
|
||||
if (!r.ok) throw new Error(r.statusText);
|
||||
asset = await r.json();
|
||||
} catch (err) {
|
||||
stage.innerHTML = `<div class="preview-empty">Could not load asset (${err.message})</div>`;
|
||||
return;
|
||||
}
|
||||
if (_currentAssetId !== assetId) return;
|
||||
|
||||
titleEl.textContent = asset.display_name || asset.filename || '(untitled)';
|
||||
metaEl.innerHTML = buildMetaHtml(asset);
|
||||
tagsEl.innerHTML = (asset.tags || []).map(t => `<span class="preview-tag">${esc(t)}</span>`).join('') || '<span class="preview-empty" style="padding:0;font-size:var(--text-xs)">No tags</span>';
|
||||
notesEl.textContent = asset.notes || '';
|
||||
|
||||
renderStage(stage, asset);
|
||||
}
|
||||
|
||||
async function renderStage(stage, asset) {
|
||||
const mt = asset.media_type;
|
||||
const hasStreamableProxy = !!asset.proxy_s3_key;
|
||||
|
||||
if (mt === 'video' || mt === 'audio') {
|
||||
if (!hasStreamableProxy) {
|
||||
stage.innerHTML = '<div class="preview-empty">Proxy still processing… try again in a moment.</div>';
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const r = await fetch(`/api/v1/assets/${asset.id}/stream`, { credentials: 'include' });
|
||||
if (!r.ok) throw new Error(r.statusText);
|
||||
const { url } = await r.json();
|
||||
const tag = mt === 'audio' ? 'audio' : 'video';
|
||||
stage.innerHTML = `<${tag} controls autoplay playsinline src="${esc(url)}"></${tag}>`;
|
||||
} catch (err) {
|
||||
stage.innerHTML = `<div class="preview-empty">Stream URL failed: ${esc(err.message)}</div>`;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (mt === 'image') {
|
||||
// Use thumbnail endpoint as a stand-in until /assets/:id/original is added
|
||||
try {
|
||||
const r = await fetch(`/api/v1/assets/${asset.id}/thumbnail`, { credentials: 'include' });
|
||||
if (r.ok) {
|
||||
const { url } = await r.json();
|
||||
if (url) { stage.innerHTML = `<img src="${esc(url)}" alt="">`; return; }
|
||||
}
|
||||
} catch (_) {}
|
||||
stage.innerHTML = '<div class="preview-empty">Image preview unavailable.</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
stage.innerHTML = `<div class="preview-empty">No preview available for media type "${esc(mt || 'unknown')}".</div>`;
|
||||
}
|
||||
|
||||
function closePreview() {
|
||||
if (!_root) return;
|
||||
_currentAssetId = null;
|
||||
_root.classList.remove('open');
|
||||
// Stop any media playback to release the connection
|
||||
const stage = _root.querySelector('.preview-stage');
|
||||
if (stage) stage.innerHTML = '';
|
||||
}
|
||||
|
||||
function buildMetaHtml(a) {
|
||||
const rows = [
|
||||
['Status', a.status || ''],
|
||||
['Type', a.media_type || ''],
|
||||
['Codec', a.codec || ''],
|
||||
['Resolution', a.resolution || ''],
|
||||
['FPS', a.fps != null ? `${a.fps} fps` : ''],
|
||||
['Duration', fmtMs(a.duration_ms)],
|
||||
['Size', fmtBytes(a.file_size)],
|
||||
['Created', a.created_at ? new Date(a.created_at).toLocaleString() : ''],
|
||||
].filter(([, v]) => v !== '' && v != null);
|
||||
return rows.map(([k, v]) => `<dt>${k}</dt><dd>${esc(v)}</dd>`).join('');
|
||||
}
|
||||
|
||||
function fmtMs(ms) {
|
||||
if (!ms) return '';
|
||||
const s = Math.floor(ms / 1000);
|
||||
const h = Math.floor(s / 3600);
|
||||
const m = Math.floor((s % 3600) / 60);
|
||||
const ss = s % 60;
|
||||
return h ? `${h}:${String(m).padStart(2,'0')}:${String(ss).padStart(2,'0')}` : `${m}:${String(ss).padStart(2,'0')}`;
|
||||
}
|
||||
function fmtBytes(b) {
|
||||
if (!b) return '';
|
||||
b = Number(b);
|
||||
if (b < 1024) return b + ' B';
|
||||
if (b < 1024*1024) return (b/1024).toFixed(1) + ' KB';
|
||||
if (b < 1024*1024*1024) return (b/1024/1024).toFixed(1) + ' MB';
|
||||
return (b/1024/1024/1024).toFixed(2) + ' GB';
|
||||
}
|
||||
function esc(s) {
|
||||
if (s == null) return '';
|
||||
return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||
}
|
||||
|
||||
// Expose entry point
|
||||
window.openAssetPreview = openPreview;
|
||||
window.closeAssetPreview = closePreview;
|
||||
|
||||
const TEMPLATE = `
|
||||
<div class="preview-modal" role="dialog" aria-modal="true" aria-label="Asset preview">
|
||||
<div class="preview-stage"></div>
|
||||
<aside class="preview-sidebar">
|
||||
<div class="preview-sidebar-header">
|
||||
<div class="preview-title"></div>
|
||||
<button class="preview-close-btn" aria-label="Close preview">
|
||||
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M3 3l10 10M13 3L3 13"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="preview-sidebar-body">
|
||||
<div>
|
||||
<div class="preview-section-title" style="margin-bottom:var(--sp-2)">Details</div>
|
||||
<dl class="preview-meta"></dl>
|
||||
</div>
|
||||
<div>
|
||||
<div class="preview-section-title" style="margin-bottom:var(--sp-2)">Tags</div>
|
||||
<div class="preview-tags"></div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="preview-section-title" style="margin-bottom:var(--sp-2)">Notes</div>
|
||||
<div class="preview-notes" style="font-size:var(--text-sm);color:var(--text-secondary);white-space:pre-wrap;line-height:1.5;"></div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</div>`;
|
||||
|
||||
const STYLES = `
|
||||
.preview-overlay{position:fixed;inset:0;background:oklch(6% 0.010 250 / 0.85);display:none;align-items:center;justify-content:center;z-index:50;backdrop-filter:blur(4px)}
|
||||
.preview-overlay.open{display:flex}
|
||||
.preview-modal{background:var(--bg-panel);border:1px solid var(--border);border-radius:var(--r-lg);width:min(1100px,94vw);height:min(720px,90vh);display:grid;grid-template-columns:1fr 320px;overflow:hidden;box-shadow:0 30px 80px oklch(0% 0 0 / 0.6)}
|
||||
.preview-stage{background:oklch(2% 0.005 250);position:relative;display:flex;align-items:center;justify-content:center;overflow:hidden}
|
||||
.preview-stage video,.preview-stage audio,.preview-stage img{max-width:100%;max-height:100%;display:block}
|
||||
.preview-stage audio{width:80%}
|
||||
.preview-empty{color:var(--text-tertiary);font-size:var(--text-sm);padding:var(--sp-6);text-align:center}
|
||||
.preview-sidebar{border-left:1px solid var(--border);display:flex;flex-direction:column;overflow:hidden;background:var(--bg-panel)}
|
||||
.preview-sidebar-header{display:flex;align-items:flex-start;justify-content:space-between;padding:var(--sp-4);border-bottom:1px solid var(--border);gap:var(--sp-3)}
|
||||
.preview-title{font-size:var(--text-md);font-weight:500;color:var(--text-primary);word-break:break-all;line-height:1.3;min-width:0}
|
||||
.preview-close-btn{flex-shrink:0;width:28px;height:28px;border-radius:var(--r-md);background:transparent;border:none;color:var(--text-secondary);cursor:pointer;display:flex;align-items:center;justify-content:center}
|
||||
.preview-close-btn:hover{background:var(--bg-hover);color:var(--text-primary)}
|
||||
.preview-close-btn svg{width:16px;height:16px}
|
||||
.preview-sidebar-body{flex:1;overflow-y:auto;padding:var(--sp-4);display:flex;flex-direction:column;gap:var(--sp-4)}
|
||||
.preview-meta{display:grid;grid-template-columns:max-content 1fr;gap:var(--sp-2) var(--sp-3);font-size:var(--text-xs);margin:0}
|
||||
.preview-meta dt{color:var(--text-tertiary);letter-spacing:0.05em;text-transform:uppercase}
|
||||
.preview-meta dd{color:var(--text-primary);margin:0;word-break:break-all;font-variant-numeric:tabular-nums}
|
||||
.preview-tags{display:flex;flex-wrap:wrap;gap:var(--sp-2)}
|
||||
.preview-tag{font-size:var(--text-xs);padding:2px 8px;border-radius:10px;background:var(--bg-surface);border:1px solid var(--border);color:var(--text-secondary)}
|
||||
.preview-section-title{font-size:var(--text-xs);font-weight:500;color:var(--text-tertiary);letter-spacing:0.08em;text-transform:uppercase}
|
||||
@media (max-width:800px){.preview-modal{grid-template-columns:1fr;grid-template-rows:1fr auto;height:95vh}.preview-sidebar{border-left:none;border-top:1px solid var(--border);max-height:40vh}}
|
||||
`;
|
||||
})();
|
||||
Loading…
Reference in a new issue