diff --git a/services/web-ui/public/screens-library.jsx b/services/web-ui/public/screens-library.jsx
index 42a7665..526f5ae 100644
--- a/services/web-ui/public/screens-library.jsx
+++ b/services/web-ui/public/screens-library.jsx
@@ -50,6 +50,24 @@ function Library({ navigate, onOpenAsset, openProject, onClearProject }) {
const [allAssets, setAllAssets] = React.useState(window.ZAMPP_DATA.ASSETS || []);
const [ctxMenu, setCtxMenu] = React.useState(null); // { asset, x, y }
const [renamingAsset, setRenamingAsset] = React.useState(null);
+ // Asset queued for hi-res download. Null means no modal showing. Set when
+ // the user clicks Download and has NOT dismissed the "are you sure" warning.
+ const [pendingDownload, setPendingDownload] = React.useState(null);
+
+ // Single entry point for hi-res downloads from the library. If the user
+ // ticked "Don't show again" in the warning modal, skip straight to the
+ // download; otherwise pop the modal with this asset queued. The modal's
+ // confirm handler calls runDownload directly.
+ const requestDownload = function(asset) {
+ if (!asset || !asset.original_s3_key) return;
+ try {
+ if (localStorage.getItem('df.lib.download.warnDismissed') === '1') {
+ runDownload(asset);
+ return;
+ }
+ } catch (_) { /* localStorage unavailable — show modal to be safe */ }
+ setPendingDownload(asset);
+ };
const [creatingBin, setCreatingBin] = React.useState(false);
const [newBinName, setNewBinName] = React.useState('');
const [draggingAssetId, setDraggingAssetId] = React.useState(null);
@@ -320,6 +338,7 @@ function Library({ navigate, onOpenAsset, openProject, onClearProject }) {
return ;
})}
@@ -363,6 +382,21 @@ function Library({ navigate, onOpenAsset, openProject, onClearProject }) {
onChanged={refreshAssets}
onOpen={function() { onOpenAsset(ctxMenu.asset); }}
onRename={function(a) { setCtxMenu(null); setRenamingAsset(a); }}
+ onDownload={function(a) { setCtxMenu(null); requestDownload(a); }}
+ />
+ )}
+ {pendingDownload && (
+
)}
{renamingAsset && (
@@ -410,7 +444,29 @@ function Library({ navigate, onOpenAsset, openProject, onClearProject }) {
);
}
-function AssetContextMenu({ asset, x, y, bins, onClose, onChanged, onOpen, onRename }) {
+// Hi-res download trigger shared by the card and the context menu. Resolves
+// a presigned S3 URL via /assets/:id/hires (returns { url, filename, ext })
+// and clicks a hidden anchor so the browser does the download. The download
+// itself is direct S3 — never proxied through mam-api — so big files don't
+// touch the API container.
+function runDownload(asset) {
+ if (!asset || !asset.id) return;
+ return window.ZAMPP_API.fetch('/assets/' + asset.id + '/hires')
+ .then(function(r) {
+ if (!r || !r.url) { alert('No hi-res source available for this asset.'); return; }
+ const a = document.createElement('a');
+ a.href = r.url;
+ a.download = r.filename || ((asset.display_name || asset.name || asset.id) + '.' + (r.ext || 'mov'));
+ a.target = '_blank';
+ a.rel = 'noopener';
+ document.body.appendChild(a);
+ a.click();
+ document.body.removeChild(a);
+ })
+ .catch(function(e) { alert('Download failed: ' + (e.message || 'unknown error')); });
+}
+
+function AssetContextMenu({ asset, x, y, bins, onClose, onChanged, onOpen, onRename, onDownload }) {
const ref = React.useRef(null);
// Pin the menu inside the viewport even if the user right-clicked near
// the bottom-right edge of the grid.
@@ -454,6 +510,9 @@ function AssetContextMenu({ asset, x, y, bins, onClose, onChanged, onOpen, onRen
{asset.display_name || asset.name}
Open
Rename…
+ {asset.original_s3_key && onDownload && (
+ Download original…
+ )}
{(bins && bins.length > 0) ? (
<>
@@ -486,7 +545,7 @@ function AssetContextMenu({ asset, x, y, bins, onClose, onChanged, onOpen, onRen
);
}
-function AssetCard({ asset, onOpen, onContextMenu, onDragStart, draggable }) {
+function AssetCard({ asset, onOpen, onContextMenu, onDownload, onDragStart, draggable }) {
const [hoverStream, setHoverStream] = React.useState(null);
const [hovered, setHovered] = React.useState(false);
const timerRef = React.useRef(null);
@@ -554,6 +613,18 @@ function AssetCard({ asset, onOpen, onContextMenu, onDragStart, draggable }) {
{asset.status === 'processing' && Processing }
{asset.status === 'error' && Error }
+ {/* Hi-res download trigger — only shown when the asset has an
+ original_s3_key (everything queued through ingest / conform).
+ Hidden until card hover, lives in top-right of the thumb. */}
+ {asset.original_s3_key && onDownload && (
+
+
+
+ )}
{(asset.type === 'video' || !asset.type) && asset.duration !== '—' && {asset.duration}
}
@@ -634,5 +705,49 @@ function RenameAssetModal({ asset, onClose, onSaved }) {
);
}
+// First-run-style warning before a hi-res download. Tells the operator the
+// file is full-length, may be multi-GB, and that speed depends on their
+// connection. "Don't show again on this device" persists the dismissal so
+// subsequent downloads (and every Download click after dismissal) skip
+// straight to the file. Settings → Account exposes a control to re-enable.
+function DownloadWarningModal({ asset, onClose, onConfirm }) {
+ const [dismiss, setDismiss] = React.useState(false);
+ const name = asset.display_name || asset.name || asset.id;
+ return (
+
+
+
+
Download original ingest
+
+
+
+
+ You're about to download the full-length original ingest for
+ {name} .
+
+
+ Originals are often multi-gigabyte. Download speed depends on
+ your network connection; don't start this on a metered link
+ or a busy uplink.
+
+
+
+ Don't show this again on this device
+
+
+
+ Cancel
+ Download
+
+
+
+ );
+}
+
window.Library = Library;
window.AssetCard = AssetCard;
diff --git a/services/web-ui/public/styles-screens.css b/services/web-ui/public/styles-screens.css
index fce0775..7dc8401 100644
--- a/services/web-ui/public/styles-screens.css
+++ b/services/web-ui/public/styles-screens.css
@@ -44,6 +44,35 @@
display: flex; gap: 4px;
}
+/* Hi-res download button — top-right corner of an asset thumbnail.
+ Hidden by default, revealed on card hover or button focus. Avoids
+ crowding the resting-state thumb (issue #145). */
+.thumb-download-btn {
+ position: absolute;
+ right: 6px; top: 6px;
+ width: 22px; height: 22px;
+ display: flex; align-items: center; justify-content: center;
+ padding: 0;
+ background: rgba(0, 0, 0, 0.7);
+ color: #fff;
+ border: 1px solid rgba(255, 255, 255, 0.12);
+ border-radius: 4px;
+ cursor: pointer;
+ opacity: 0;
+ transform: translateY(-2px);
+ transition: opacity 80ms ease-out, transform 120ms ease-out, background 80ms;
+ backdrop-filter: blur(4px);
+}
+.asset-card:hover .thumb-download-btn,
+.thumb-download-btn:focus-visible {
+ opacity: 1;
+ transform: translateY(0);
+}
+.thumb-download-btn:hover {
+ background: rgba(0, 0, 0, 0.85);
+ border-color: rgba(255, 255, 255, 0.2);
+}
+
/* ========== Waveform ========== */
.waveform { width: 70%; height: 50%; opacity: 0.85; }
.audio-meter.h { display: flex; gap: 2px; height: 12px; align-items: stretch; }