Compare commits

...

10 commits

Author SHA1 Message Date
Zac Gaetano
17bf086ef2 feat(web-ui): publish UXP panel v2.2.2 .ccx and repoint download buttons
Package the redesigned UXP panel as dragonflight-mam-2.2.2.ccx (plain zip
of manifest + index.html + styles.css + src/*, the standard UXP format that
double-click-installs on Windows and Mac via Creative Cloud).

- Add the .ccx to web-ui/public/downloads (served by nginx at /downloads).
- data.jsx: new v2.2.2 entry as latest (ccx field); old CEP releases kept.
- Editor screen: single "Download Panel (.ccx)" button.
- Settings -> Capture SDKs: .ccx download + updated UXP install copy;
  per-release buttons fall back to ZXP/Win for the old CEP entries.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-29 00:36:38 -04:00
Zac Gaetano
dac5213354 fix(uxp-panel): Export chooser is a full-panel screen, not a clipped popup
The floating popup anchored to the rail button clipped at the panel edge
(the bottom option was cut off). Replace it with a full-panel screen that
takes over the whole panel: a header with close, and two large option
rows (Conform Timeline -> MAM, Local Export) with icon + description.
Conform still routes to the codec-picker panel.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-28 23:57:54 -04:00
Zac Gaetano
3f203f326e feat(uxp-panel): single Export menu, Upload to MAM, Local Export, auto-upload
UI consolidation:
- One Export entry (rail) opens a popup menu: Conform Timeline -> MAM and
  Local Export. Retires the standalone Export & Conform / Fetch & Relink
  dock buttons and the plain "Push Timeline" flow.
- Remove Import All.
- New "Upload to MAM" dock button.

Upload: reads the highlighted project-panel item(s) via the premierepro
API (best-effort, guarded) and falls back to a native file picker. Pushes
via /upload/simple (small) or chunked init/part/complete (large).

Local Export: batch-trim the timeline's hi-res clips server-side (FFMPEG),
poll trim-status, download each temp-segment-url, relink in Premiere.
Relink keys on source media path (last-wins for multi-use sources).

Conform + Local Export auto-upload any timeline sources not yet in the MAM
before proceeding (renders from the hi-res original, available post-upload).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-28 23:48:30 -04:00
Zac Gaetano
7e9f1277d4 fix(uxp-panel): rebuild Export & Conform panel - declutter + fix overlap
The preset cards overlapped the codec/quality dropdowns because the
2-column .preset-grid used flex-wrap, and UXP miscomputes wrapped-flex
container height (collapses to ~0), so the fields below rendered on top.

Rethink:
- Presets are now a single-column list (title left, spec right); no
  flex-wrap, so nothing collapses or overlaps.
- Progressive disclosure: the Codec/Quality/Resolution/Audio dropdowns
  are wrapped in #conform-custom and hidden unless the Custom preset is
  chosen. Default view is just Target project + 4 format rows.

The preset->select value contract is unchanged; presets still populate
the (now hidden) selects that Start Conform reads.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-28 23:13:15 -04:00
Zac Gaetano
9d8adbbbc1 feat(uxp-panel): fix hover tooltips + add compact list view toggle
Tooltips: the floating .tip-bubble used position:fixed, which UXP's
engine does not render reliably, so tooltips never appeared. Switch to
position:absolute (body never scrolls, so viewport coords still map),
add scroll-offset compensation, and guard against UXP returning 0 for
window.innerWidth/innerHeight.

List view: add a toolbar toggle (grid vs compact list). List view shows
a small 50x28 thumbnail with name/meta and an inline status chip per
row, fitting many more clips on screen. Defaults to list, persists via
localStorage when the host allows it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-28 23:05:52 -04:00
Zac Gaetano
3430ef823e fix(uxp-panel): icon controls as <div role=button> so UXP renders them
ROOT CAUSE: UXP renders native <button> chrome that ignores CSS
`background` and does not draw <svg>-only button content. The original
panel "worked" only because its buttons had TEXT (native buttons render
their text label); the redesign stripped text out, leaving empty grey
pills. Non-<button> elements (the <span> status chips, the <label>
search field + magnifier) render custom backgrounds and SVG children
correctly in this exact UXP -- proof divs are the right vehicle.

FIX: convert the 12 rail/dock/menu icon controls from <button> to
<div role="button" tabindex="0">. Divs have no native `disabled`, so
main.js installs a `disabled` accessor that reflects to a [disabled]
attribute; CSS keys disabled styling off [disabled]. Reverted the
non-working ::before cover back to direct backgrounds (divs honor them).
Text buttons (Connect, slide-panel actions, glyph close) stay <button>.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-28 22:55:45 -04:00
Zac Gaetano
08a0fb1b60 fix(uxp-panel): use ::before pseudo-element to cover native button chrome
UXP's native button chrome overrides explicit `background` rules on
icon-only <button> elements -- appearance:none and background both lose.
Authored content (::before pseudo-elements) renders above native chrome,
so move all background/hover/active logic there. SVG icons and the
growing-count badge get z-index:1/2 to sit above the cover.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-05-28 21:09:09 -04:00
Zac Gaetano
dd438b597a fix(uxp-panel): render icons as filled SVGs so UXP draws them
UXP's SVG renderer does not draw Feather/Lucide-style stroke icons
(fill="none" stroke="currentColor"); they showed as blank/grey shapes
in Premiere. Convert all 14 panel icons to filled single-path
(Material-style) SVGs with explicit width/height attributes, which
UXP's simple-icon renderer handles reliably.

Also replace the transparent rail/icon button backgrounds with an
explicit --bg-base fill: appearance:none alone did not suppress UXP's
native grey button chrome, but an explicit background does (same trick
the working .btn rule relies on).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 20:44:50 -04:00
Zac Gaetano
8746d71af1 fix(premiere-panel): reset native button chrome on icon buttons
UXP renders native <button> appearance (grey rounded fill) unless
appearance:none is set. .btn masked it with an explicit background,
but .rail-btn / .iconbtn use a transparent background, so the native
chrome showed through as grey blobs and suppressed the SVG icons.
Add appearance:none to both, matching the input/select reset.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 20:32:30 -04:00
Zac Gaetano
6a161c7133 feat(premiere-panel): icon-rail redesign with hover tooltips
Replace the text-heavy panel layout with a minimal icon-first UI:
a VS Code-style vertical activity rail for view switching plus a
contextual icon dock for clip actions. Every control carries a
[data-tip] label surfaced on hover via a JS-positioned tooltip
bubble (UXP's CSS engine can't be trusted with content: attr()).

All existing main.js element IDs and the JS wiring contract are
preserved; the dropped advanced section was already guarded.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 17:57:26 -04:00
13 changed files with 1180 additions and 640 deletions

View file

@ -9,12 +9,10 @@
<div id="root"> <div id="root">
<!-- ── Connect Pane ─────────────────────────────────────────────── --> <!-- ── Connect Pane ─────────────────────────────────────────────── -->
<section id="connect-pane" class="pane"> <section id="connect-pane" class="pane pane-connect">
<div class="brand"> <div class="brand">
<div class="brand-icon"> <div class="brand-icon">
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"> <svg width="28" height="28" viewBox="0 0 24 24"><path fill="currentColor" d="M17 10.5V7c0-.55-.45-1-1-1H4c-.55 0-1 .45-1 1v10c0 .55.45 1 1 1h12c.55 0 1-.45 1-1v-3.5l4 4v-11l-4 4z"/></svg>
<path d="M23 7l-7 5 7 5V7z"/><rect x="1" y="5" width="15" height="14" rx="2"/>
</svg>
</div> </div>
<div class="brand-title">Dragonflight</div> <div class="brand-title">Dragonflight</div>
<div class="brand-tag">Wild Dragon Broadcast</div> <div class="brand-tag">Wild Dragon Broadcast</div>
@ -28,113 +26,151 @@
<div id="connect-status" class="status-msg muted"></div> <div id="connect-status" class="status-msg muted"></div>
</section> </section>
<!-- ── Library Pane ─────────────────────────────────────────────── --> <!-- ── Library Pane (app-shell: statusbar · rail · main · dock) ──── -->
<section id="library-pane" class="pane hidden"> <section id="library-pane" class="pane hidden">
<div class="app">
<!-- Connection status strip (v2.1.8): dot + identity + ⋯ menu. <!-- Connection collapsed to a status line: dot + host + version + ⋯ -->
Disconnect lives inside the menu so it's not always visible. --> <header class="statusbar">
<header class="status-strip"> <span class="signal-dot"></span>
<span class="signal-dot"></span> <span id="connected-host" class="connected-host"></span>
<span id="connected-host" class="connected-host"></span> <span id="panel-version" class="panel-version" title="Plugin version"></span>
<span id="panel-version" class="panel-version" title="Plugin version"></span> <div id="menu-btn" role="button" tabindex="0" class="iconbtn iconbtn--sm" data-tip="More" data-tip-pos="down-left" aria-label="More">
<button id="menu-btn" class="btn-ghost" title="More" aria-label="More"></button> <svg width="15" height="15" viewBox="0 0 24 24"><path fill="currentColor" d="M6 10c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2zm12 0c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2zm-6 0c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2z"/></svg>
<div id="status-menu" class="menu hidden" role="menu">
<button id="disconnect-btn" class="menu-item" role="menuitem">Disconnect</button>
</div>
</header>
<!-- Tabs -->
<div class="tab-nav">
<button id="tab-library" class="tab-btn active">
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/>
</svg>
Library
</button>
<button id="tab-growing" class="tab-btn">
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M23 7l-7 5 7 5V7z"/><rect x="1" y="5" width="15" height="14" rx="2" ry="2"/>
</svg>
Growing
<span id="growing-count" class="badge" style="display:none">0</span>
</button>
</div>
<!-- Search + Project filter -->
<div class="search-row">
<input id="search-input" type="search" placeholder="Search assets…" />
<select id="project-filter" title="Filter by project">
<option value="all">All Projects</option>
</select>
<button id="refresh-btn" class="btn btn-icon" title="Refresh"></button>
</div>
<!-- Active sequence info bar -->
<div id="seq-info-bar" class="seq-info-bar hidden">
<span class="chip chip-ok">SEQ</span>
<span id="seq-info-name" class="seq-info-name"></span>
</div>
<!-- Asset grid (library tab) -->
<div id="library-container" class="grid-container">
<div id="asset-grid" class="asset-grid">
<div class="empty muted">Loading…</div>
</div>
</div>
<!-- Growing grid -->
<div id="growing-container" class="grid-container hidden">
<div id="growing-grid" class="asset-grid">
<div class="empty muted">No growing files</div>
</div>
</div>
<!-- (v2.2.0) Asset details panel dropped — card meta already carries
name / codec / duration. If we need richer detail later, surface
it on the card hover state rather than reserving permanent space. -->
<div id="details-panel" class="hidden"></div>
<!-- Action buttons -->
<footer class="actions">
<div class="action-row">
<button id="import-proxy-btn" class="btn btn-primary" disabled>Import Proxy</button>
<button id="import-hires-btn" class="btn btn-secondary" disabled>Hi-Res</button>
</div>
<div class="action-row">
<button id="mount-live-btn" class="btn btn-secondary" disabled title="Open live file from SMB share">Mount Live</button>
<button id="relink-btn" class="btn btn-secondary" disabled title="Relink proxy → hi-res original">Relink Hi-Res</button>
</div>
<div class="action-row">
<button id="import-all-btn" class="btn btn-secondary" disabled>Import All</button>
<button id="export-timeline-btn" class="btn btn-secondary" disabled>Export Timeline ↑</button>
</div>
<!-- Progress -->
<div id="progress-row" class="progress-row hidden">
<div class="progress-bar"><div id="progress-fill"></div></div>
<div id="progress-label" class="progress-label"></div>
</div>
<!-- Toast -->
<div id="toast" class="toast hidden"></div>
</footer>
<!-- Advanced section — collapsed by default; click the row to expand. -->
<div class="advanced-section">
<button id="advanced-toggle" class="advanced-toggle" type="button" aria-expanded="false">
<span class="advanced-caret"></span>
<span class="advanced-title">Advanced</span>
</button>
<div id="advanced-body" class="advanced-body hidden">
<div class="action-row">
<button id="export-conform-btn" class="btn btn-secondary" disabled>Export &amp; Conform</button>
<button id="fetch-relink-btn" class="btn btn-secondary" disabled>Fetch &amp; Relink All</button>
</div> </div>
</div> <div id="status-menu" class="menu hidden" role="menu">
</div> <button id="disconnect-btn" class="menu-item" role="menuitem">Disconnect</button>
</div>
</header>
<div class="workspace">
<!-- Vertical icon rail: views on top, global actions below -->
<nav class="rail">
<div id="tab-library" role="button" tabindex="0" class="rail-btn active" data-tip="Library" data-tip-pos="right">
<svg width="18" height="18" viewBox="0 0 24 24"><rect x="3" y="3" width="7.5" height="7.5" rx="1.5" fill="currentColor"/><rect x="13.5" y="3" width="7.5" height="7.5" rx="1.5" fill="currentColor"/><rect x="3" y="13.5" width="7.5" height="7.5" rx="1.5" fill="currentColor"/><rect x="13.5" y="13.5" width="7.5" height="7.5" rx="1.5" fill="currentColor"/></svg>
</div>
<div id="tab-growing" role="button" tabindex="0" class="rail-btn" data-tip="Growing" data-tip-pos="right">
<svg width="18" height="18" viewBox="0 0 24 24"><path fill="currentColor" d="M17 10.5V7c0-.55-.45-1-1-1H4c-.55 0-1 .45-1 1v10c0 .55.45 1 1 1h12c.55 0 1-.45 1-1v-3.5l4 4v-11l-4 4z"/></svg>
<span id="growing-count" class="rail-count" style="display:none">0</span>
</div>
<span class="rail-spacer"></span>
<div id="export-timeline-btn" role="button" tabindex="0" class="rail-btn rail-btn--accent" data-tip="Export" data-tip-pos="right">
<svg width="18" height="18" viewBox="0 0 24 24"><path fill="currentColor" d="M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z"/></svg>
</div>
<div id="refresh-btn" role="button" tabindex="0" class="rail-btn" data-tip="Refresh" data-tip-pos="right">
<svg width="18" height="18" viewBox="0 0 24 24"><path fill="currentColor" d="M17.65 6.35A7.96 7.96 0 0 0 12 4c-4.42 0-7.99 3.58-7.99 8s3.57 8 7.99 8c3.73 0 6.84-2.55 7.73-6h-2.08A5.99 5.99 0 0 1 12 18c-3.31 0-6-2.69-6-6s2.69-6 6-6c1.66 0 3.14.69 4.22 1.78L13 11h7V4l-2.35 2.35z"/></svg>
</div>
</nav>
<!-- Main column -->
<div class="main">
<!-- Search + project filter -->
<div class="toolbar">
<label class="search">
<svg width="13" height="13" viewBox="0 0 24 24"><path fill="currentColor" d="M15.5 14h-.79l-.28-.27a6.5 6.5 0 0 0 1.48-5.34c-.47-2.78-2.79-5-5.59-5.34a6.5 6.5 0 0 0-7.27 7.27c.34 2.8 2.56 5.12 5.34 5.59a6.5 6.5 0 0 0 5.34-1.48l.27.28v.79l4.25 4.25c.41.41 1.08.41 1.49 0 .41-.41.41-1.08 0-1.49L15.5 14zm-6 0C7.01 14 5 11.99 5 9.5S7.01 5 9.5 5 14 7.01 14 9.5 11.99 14 9.5 14z"/></svg>
<input id="search-input" type="search" placeholder="Search assets" />
</label>
<select id="project-filter" class="filter-select" title="Filter by project">
<option value="all">All Projects</option>
</select>
<div id="view-toggle-btn" role="button" tabindex="0" class="iconbtn iconbtn--sm" data-tip="Grid view" data-tip-pos="down-left" aria-label="Toggle layout">
<svg width="15" height="15" viewBox="0 0 24 24"><path fill="currentColor" d="M4 11h5V5H4v6zm0 7h5v-6H4v6zm6 0h5v-6h-5v6zm6 0h5v-6h-5v6zm-6-7h5V5h-5v6zm6-6v6h5V5h-5z"/></svg>
</div>
</div>
<!-- Active sequence info bar -->
<div id="seq-info-bar" class="seq-info-bar hidden">
<span class="chip chip-ok">SEQ</span>
<span id="seq-info-name" class="seq-info-name"></span>
</div>
<!-- Asset grid (library tab) -->
<div id="library-container" class="grid-container">
<div id="asset-grid" class="asset-grid">
<div class="empty muted">Loading…</div>
</div>
</div>
<!-- Growing grid -->
<div id="growing-container" class="grid-container hidden">
<div id="growing-grid" class="asset-grid">
<div class="empty muted">No growing files</div>
</div>
</div>
<!-- Details panel retired (v2.2.0) — card meta carries name/codec/duration. -->
<div id="details-panel" class="hidden"></div>
<!-- Progress + toast sit just above the action dock -->
<div id="progress-row" class="progress-row hidden">
<div class="progress-bar"><div id="progress-fill"></div></div>
<div id="progress-label" class="progress-label"></div>
</div>
<div id="toast" class="toast hidden"></div>
<!-- Contextual action dock: text buttons replaced by icon buttons
with hover labels. Per-asset actions left, batch actions right. -->
<footer class="dock">
<div id="import-proxy-btn" role="button" tabindex="0" class="iconbtn iconbtn--primary" data-tip="Import Proxy" data-tip-pos="up" disabled>
<svg width="16" height="16" viewBox="0 0 24 24"><path fill="currentColor" d="M19 9h-4V3H9v6H5l7 7 7-7zM5 18v2h14v-2H5z"/></svg>
</div>
<div id="import-hires-btn" role="button" tabindex="0" class="iconbtn" data-tip="Import Hi-Res" data-tip-pos="up" disabled>
<svg width="16" height="16" viewBox="0 0 24 24"><path fill="currentColor" d="M11.99 18.54l-7.37-5.73L3 14.07l9 7 9-7-1.63-1.27-7.38 5.74zM12 16l7.36-5.73L21 9l-9-7-9 7 1.63 1.27L12 16z"/></svg>
</div>
<div id="mount-live-btn" role="button" tabindex="0" class="iconbtn" data-tip="Mount Live" data-tip-pos="up" disabled>
<svg width="16" height="16" viewBox="0 0 24 24"><path fill="currentColor" d="M14 12c0 1.11-.89 2-2 2s-2-.89-2-2 .89-2 2-2 2 .89 2 2zm-2-6c-3.31 0-6 2.69-6 6 0 2.22 1.21 4.15 3 5.19l1-1.74A3.98 3.98 0 0 1 8 12c0-2.21 1.79-4 4-4s4 1.79 4 4c0 1.48-.81 2.75-2 3.45l1 1.74c1.79-1.04 3-2.97 3-5.19 0-3.31-2.69-6-6-6zm0-4C7.58 2 4 5.58 4 10c0 2.96 1.61 5.53 4 6.92l1-1.73C7.21 14.07 6 12.18 6 10c0-3.31 2.69-6 6-6s6 2.69 6 6c0 2.18-1.21 4.07-3 5.19l1 1.73c2.39-1.39 4-3.96 4-6.92 0-4.42-3.58-8-8-8z"/></svg>
</div>
<div id="relink-btn" role="button" tabindex="0" class="iconbtn" data-tip="Relink Hi-Res" data-tip-pos="up" disabled>
<svg width="16" height="16" viewBox="0 0 24 24"><path fill="currentColor" d="M3.9 12c0-1.71 1.39-3.1 3.1-3.1h4V7H7c-2.76 0-5 2.24-5 5s2.24 5 5 5h4v-1.9H7c-1.71 0-3.1-1.39-3.1-3.1zM8 13h8v-2H8v2zm9-6h-4v1.9h4c1.71 0 3.1 1.39 3.1 3.1s-1.39 3.1-3.1 3.1h-4V17h4c2.76 0 5-2.24 5-5s-2.24-5-5-5z"/></svg>
</div>
<span class="dock-sep"></span>
<div id="upload-mam-btn" role="button" tabindex="0" class="iconbtn" data-tip="Upload to MAM" data-tip-pos="up-left">
<svg width="16" height="16" viewBox="0 0 24 24"><path fill="currentColor" d="M19.35 10.04A7.49 7.49 0 0 0 12 4C9.11 4 6.6 5.64 5.35 8.04A5.994 5.994 0 0 0 0 14c0 3.31 2.69 6 6 6h13c2.76 0 5-2.24 5-5 0-2.64-2.05-4.78-4.65-4.96zM14 13v4h-4v-4H7l5-5 5 5h-3z"/></svg>
</div>
</footer>
</div><!-- /main -->
</div><!-- /workspace -->
</div><!-- /app -->
</section> </section>
<!-- ── Export Timeline Slide Panel ──────────────────────────────── --> <!-- Full-panel Export screen -->
<div id="export-screen" class="screen hidden">
<div class="screen-header">
<span class="screen-title">Export</span>
<button id="export-screen-close" class="btn btn-icon"></button>
</div>
<div class="screen-body">
<div id="opt-conform" role="button" tabindex="0" class="export-option">
<div class="eo-icon">
<svg width="22" height="22" viewBox="0 0 24 24"><path fill="currentColor" d="M3 17v2h6v-2H3zM3 5v2h10V5H3zm10 16v-2h8v-2h-8v-2h-2v6h2zM7 9v2H3v2h4v2h2V9H7zm14 4v-2H11v2h10zm-6-4h2V7h4V5h-4V3h-2v6z"/></svg>
</div>
<div class="eo-text">
<div class="eo-title">Conform Timeline → MAM</div>
<div class="eo-desc">Render the sequence to a chosen codec and push it to the MAM</div>
</div>
<span class="eo-arrow"></span>
</div>
<div id="opt-local-export" role="button" tabindex="0" class="export-option">
<div class="eo-icon">
<svg width="22" height="22" viewBox="0 0 24 24"><path fill="currentColor" d="M19 9h-4V3H9v6H5l7 7 7-7zM5 18v2h14v-2H5z"/></svg>
</div>
<div class="eo-text">
<div class="eo-title">Local Export</div>
<div class="eo-desc">Trim the hi-res sources on the MAM, download and relink them in Premiere</div>
</div>
<span class="eo-arrow"></span>
</div>
</div>
</div>
<!-- ── (retired) Push Timeline Slide Panel — kept hidden/unused ──── -->
<div id="export-overlay" class="slide-overlay hidden"></div> <div id="export-overlay" class="slide-overlay hidden"></div>
<div id="export-panel" class="slide-panel hidden"> <div id="export-panel" class="slide-panel hidden">
<div class="slide-header"> <div class="slide-header">
@ -164,40 +200,43 @@
<div class="slide-body"> <div class="slide-body">
<label class="field-label">Target project</label> <label class="field-label">Target project</label>
<select id="conform-proj-select"><option value="">— Select project —</option></select> <select id="conform-proj-select"><option value="">— Select project —</option></select>
<label class="field-label">Preset</label> <label class="field-label">Format</label>
<div id="preset-cards" class="preset-grid"> <div id="preset-cards" class="preset-list">
<div class="preset-card selected" data-preset="broadcast"><div class="pc-title">Broadcast</div><div class="pc-desc">ProRes 422 HQ · 1080p · 48kHz</div></div> <div class="preset-card selected" data-preset="broadcast"><span class="pc-title">Broadcast</span><span class="pc-desc">ProRes 422 HQ · 1080p</span></div>
<div class="preset-card" data-preset="web"><div class="pc-title">Web</div><div class="pc-desc">H.264 · 1080p · AAC 320k</div></div> <div class="preset-card" data-preset="web"><span class="pc-title">Web</span><span class="pc-desc">H.264 · 1080p · AAC</span></div>
<div class="preset-card" data-preset="archive"><div class="pc-title">Archive</div><div class="pc-desc">ProRes 4444 · UHD · 48kHz</div></div> <div class="preset-card" data-preset="archive"><span class="pc-title">Archive</span><span class="pc-desc">ProRes 4444 · UHD</span></div>
<div class="preset-card" data-preset="custom"><div class="pc-title">Custom</div><div class="pc-desc">Manual settings</div></div> <div class="preset-card" data-preset="custom"><span class="pc-title">Custom</span><span class="pc-desc">Manual settings</span></div>
</div>
<div id="conform-custom" class="custom-fields hidden">
<label class="field-label">Codec</label>
<select id="conform-codec">
<option value="prores_hq">ProRes 422 HQ</option>
<option value="prores_4444">ProRes 4444</option>
<option value="h264">H.264</option>
<option value="h265">H.265 / HEVC</option>
<option value="dnxhr_hq">DNxHR HQ</option>
</select>
<label class="field-label">Quality</label>
<select id="conform-quality">
<option value="high">High</option>
<option value="medium" selected>Medium</option>
<option value="low">Low</option>
</select>
<label class="field-label">Resolution</label>
<select id="conform-resolution">
<option value="uhd">UHD (3840×2160)</option>
<option value="1080p" selected>Full HD (1920×1080)</option>
<option value="720p">HD (1280×720)</option>
<option value="source">Source</option>
</select>
<label class="field-label">Audio</label>
<select id="conform-audio">
<option value="broadcast">Broadcast (48kHz PCM)</option>
<option value="web">Web (AAC 320k)</option>
<option value="archive">Archive (96kHz PCM)</option>
</select>
</div> </div>
<label class="field-label">Codec</label>
<select id="conform-codec">
<option value="prores_hq">ProRes 422 HQ</option>
<option value="prores_4444">ProRes 4444</option>
<option value="h264">H.264</option>
<option value="h265">H.265 / HEVC</option>
<option value="dnxhr_hq">DNxHR HQ</option>
</select>
<label class="field-label">Quality</label>
<select id="conform-quality">
<option value="high">High</option>
<option value="medium" selected>Medium</option>
<option value="low">Low</option>
</select>
<label class="field-label">Resolution</label>
<select id="conform-resolution">
<option value="uhd">UHD (3840×2160)</option>
<option value="1080p" selected>Full HD (1920×1080)</option>
<option value="720p">HD (1280×720)</option>
<option value="source">Source</option>
</select>
<label class="field-label">Audio</label>
<select id="conform-audio">
<option value="broadcast">Broadcast (48kHz PCM)</option>
<option value="web">Web (AAC 320k)</option>
<option value="archive">Archive (96kHz PCM)</option>
</select>
<div id="conform-clip-info" class="clip-info"></div> <div id="conform-clip-info" class="clip-info"></div>
</div> </div>
<div class="slide-footer"> <div class="slide-footer">
@ -231,6 +270,7 @@
<script src="src/library.js"></script> <script src="src/library.js"></script>
<script src="src/import-flow.js"></script> <script src="src/import-flow.js"></script>
<script src="src/timeline.js"></script> <script src="src/timeline.js"></script>
<script src="src/tooltip.js"></script>
<script src="src/main.js"></script> <script src="src/main.js"></script>
</body> </body>
</html> </html>

View file

@ -167,5 +167,57 @@
}); });
}; };
// Local Export trim job polling + segment retrieval.
API.getTrimStatus = function (jobId) {
return API.json('/api/v1/assets/trim-status/' + jobId);
};
API.getTempSegmentUrl = function (clipInstanceId) {
return API.json('/api/v1/assets/temp-segment-url/' + clipInstanceId);
};
// ── Upload (ingest editor media into the MAM) ────────────────────
// Single-shot multipart form upload (server caps simple at <50 MB).
API.uploadSimple = async function (blob, meta) {
const fd = new FormData();
fd.append('file', blob, meta.filename);
fd.append('filename', meta.filename);
fd.append('projectId', meta.projectId);
if (meta.binId) fd.append('binId', meta.binId);
if (meta.contentType) fd.append('contentType', meta.contentType);
const r = await API.request('/api/v1/upload/simple', { method: 'POST', body: fd });
if (!r.ok) throw new Error('Upload HTTP ' + r.status + ' — ' + (await r.text().catch(() => '')).slice(0, 160));
return r.json();
};
// Chunked multipart for large originals.
API.uploadInit = function (meta) {
return API.json('/api/v1/upload/init', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(meta),
});
};
API.uploadPart = async function (blob, meta) {
const fd = new FormData();
fd.append('file', blob, 'part-' + meta.partNumber);
fd.append('uploadId', meta.uploadId);
fd.append('key', meta.key);
fd.append('partNumber', String(meta.partNumber));
const r = await API.request('/api/v1/upload/part', { method: 'POST', body: fd });
if (!r.ok) throw new Error('Upload part HTTP ' + r.status);
return r.json();
};
API.uploadComplete = function (meta) {
return API.json('/api/v1/upload/complete', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(meta),
});
};
API.uploadAbort = function (meta) {
return API.json('/api/v1/upload/abort', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(meta),
}).catch(() => {});
};
window.API = API; window.API = API;
})(); })();

View file

@ -154,5 +154,111 @@
return destPath; return destPath;
}; };
// ── Upload (ingest editor media into the MAM) ────────────────────
const SIMPLE_MAX = 45 * 1024 * 1024; // server caps /simple at <50 MB
const PART_SIZE = 16 * 1024 * 1024; // chunk size for multipart
function _contentType(name) {
const ext = String(name).split('.').pop().toLowerCase();
const map = {
mp4:'video/mp4', m4v:'video/mp4', mov:'video/quicktime', mxf:'application/mxf',
mkv:'video/x-matroska', avi:'video/x-msvideo', mpg:'video/mpeg', mpeg:'video/mpeg',
mts:'video/mp2t', m2ts:'video/mp2t', wav:'audio/wav', aif:'audio/aiff', aiff:'audio/aiff',
mp3:'audio/mpeg', png:'image/png', jpg:'image/jpeg', jpeg:'image/jpeg',
};
return map[ext] || 'application/octet-stream';
}
// Read a local file and push it to the MAM. Returns the created asset row.
// NOTE: reads the whole file into memory once (fine for typical clips;
// very large multi-GB originals may strain memory — revisit with a
// positional-read stream if that becomes a problem).
Import.uploadFile = async function (nativePath, meta) {
meta = meta || {};
if (!meta.projectId) throw new Error('No target project for upload');
const filename = meta.filename || path.basename(nativePath);
const contentType = _contentType(filename);
const buf = await fs.readFile(nativePath);
const size = buf.byteLength != null ? buf.byteLength : buf.length;
if (size <= SIMPLE_MAX) {
const blob = new Blob([buf], { type: contentType });
return API.uploadSimple(blob, { filename, projectId: meta.projectId, binId: meta.binId, contentType });
}
// Chunked multipart for large files.
const init = await API.uploadInit({ filename, fileSize: size, contentType, projectId: meta.projectId, binId: meta.binId });
const parts = [];
try {
let partNumber = 1;
for (let off = 0; off < size; off += PART_SIZE, partNumber++) {
const chunk = buf.slice(off, Math.min(off + PART_SIZE, size));
const blob = new Blob([chunk], { type: contentType });
if (meta.onProgress) meta.onProgress(off, size);
const res = await API.uploadPart(blob, { uploadId: init.uploadId, key: init.key, partNumber });
parts.push({ PartNumber: partNumber, ETag: res.etag });
}
return API.uploadComplete({ uploadId: init.uploadId, key: init.key, assetId: init.assetId, parts });
} catch (e) {
await API.uploadAbort({ uploadId: init.uploadId, key: init.key, assetId: init.assetId });
throw e;
}
};
// ── Bin selection (best-effort) + file-picker fallback ───────────
// Tries to read the highlighted project-panel item(s). The UXP premierepro
// selection surface varies by version, so every access is guarded; on any
// miss this returns [] and callers fall back to a native file picker.
Import.getSelectedBinPaths = async function () {
const paths = [];
try {
const P = _ppro();
const project = await P.Project.getActiveProject();
if (!project) return paths;
let sel = null;
try { if (project.getSelection) sel = await project.getSelection(); } catch (_) {}
let items = [];
if (sel) {
if (typeof sel.getItems === 'function') items = await sel.getItems();
else if (Array.isArray(sel)) items = sel;
else if (Array.isArray(sel.items)) items = sel.items;
}
for (const it of (items || [])) {
try {
const ci = await P.ClipProjectItem.cast(it);
const mp = await ci.getMediaFilePath();
if (mp) paths.push(mp);
} catch (_) {}
}
} catch (_) {}
return paths;
};
// Native file picker — returns array of native paths (may be empty).
Import.pickFiles = async function () {
if (!uxpFs || !uxpFs.getFileForOpening) throw new Error('File picker unavailable in this host');
const sel = await uxpFs.getFileForOpening({ allowMultiple: true });
if (!sel) return [];
const arr = Array.isArray(sel) ? sel : [sel];
return arr.map(f => f && f.nativePath).filter(Boolean);
};
// Upload any timeline clips not yet in the MAM, recording the path→asset
// mapping so resolveClipsToAssets picks them up on the next pass.
Import.ensureClipsInMam = async function (clips, projectId, onProgress) {
const missing = clips.filter(c => !c.asset_id && c.filePath);
for (let i = 0; i < missing.length; i++) {
const c = missing[i];
if (onProgress) onProgress(c.fileName || path.basename(c.filePath), i + 1, missing.length);
const asset = await Import.uploadFile(c.filePath, { projectId, filename: path.basename(c.filePath) });
if (asset && asset.id) {
Library.recordImport(c.filePath, { assetId: asset.id });
if (c.fileName) Library.recordImport('name:' + c.fileName, { assetId: asset.id });
}
}
return { uploaded: missing.length };
};
window.Import = Import; window.Import = Import;
})(); })();

View file

@ -220,10 +220,7 @@
_btn('import-hires-btn').disabled = !sel || live || !sel.original_s3_key; _btn('import-hires-btn').disabled = !sel || live || !sel.original_s3_key;
_btn('mount-live-btn').disabled = !sel || !live; _btn('mount-live-btn').disabled = !sel || !live;
_btn('relink-btn').disabled = !(ready && hasLiveImport); _btn('relink-btn').disabled = !(ready && hasLiveImport);
_btn('import-all-btn').disabled = Library.state.currentTab !== 'library'; // export-timeline-btn (Export menu) and upload-mam-btn are always available.
_btn('export-timeline-btn').disabled = false; // available once connected
_btn('export-conform-btn').disabled = false;
_btn('fetch-relink-btn').disabled = false;
}; };
function _btn(id) { return document.getElementById(id) || { disabled: false }; } function _btn(id) { return document.getElementById(id) || { disabled: false }; }

View file

@ -6,6 +6,50 @@
const $ = id => document.getElementById(id); const $ = id => document.getElementById(id);
// UXP renders native <button> chrome that ignores CSS `background` and does
// not draw <svg>-only button content, so the rail/dock icon controls are
// <div role="button"> (divs render custom backgrounds + SVG children fine).
// Divs have no native `disabled`, so reflect the `.disabled` property the
// rest of the code sets onto a [disabled] attribute the stylesheet keys off.
const ICON_CONTROLS = [
'menu-btn', 'tab-library', 'tab-growing', 'export-timeline-btn', 'refresh-btn',
'import-proxy-btn', 'import-hires-btn', 'mount-live-btn', 'relink-btn', 'upload-mam-btn'
];
function enableDivDisabled() {
ICON_CONTROLS.forEach(id => {
const el = document.getElementById(id);
if (!el || Object.getOwnPropertyDescriptor(el, 'disabled')) return;
Object.defineProperty(el, 'disabled', {
configurable: true,
get() { return this.hasAttribute('disabled'); },
set(v) { if (v) this.setAttribute('disabled', ''); else this.removeAttribute('disabled'); }
});
});
}
// Asset layout toggle: compact list (default) vs thumbnail grid. Persisted
// in localStorage when available (UXP host permitting), else session-only.
const GRID_ICON = '<svg width="15" height="15" viewBox="0 0 24 24"><path fill="currentColor" d="M4 11h5V5H4v6zm0 7h5v-6H4v6zm6 0h5v-6h-5v6zm6 0h5v-6h-5v6zm-6-7h5V5h-5v6zm6-6v6h5V5h-5z"/></svg>';
const LIST_ICON = '<svg width="15" height="15" viewBox="0 0 24 24"><path fill="currentColor" d="M4 10.5c-.83 0-1.5.67-1.5 1.5s.67 1.5 1.5 1.5 1.5-.67 1.5-1.5-.67-1.5-1.5-1.5zm0-6c-.83 0-1.5.67-1.5 1.5S3.17 7.5 4 7.5 5.5 6.83 5.5 6 4.83 4.5 4 4.5zm0 12c-.83 0-1.5.68-1.5 1.5s.68 1.5 1.5 1.5 1.5-.68 1.5-1.5-.67-1.5-1.5-1.5zM7 19h14v-2H7v2zm0-6h14v-2H7v2zm0-8v2h14V5H7z"/></svg>';
let _viewMode = null;
function getViewMode() {
if (_viewMode) return _viewMode;
try { _viewMode = localStorage.getItem('df_view_mode'); } catch (e) {}
return _viewMode || 'list';
}
function applyViewMode(mode) {
_viewMode = mode === 'grid' ? 'grid' : 'list';
try { localStorage.setItem('df_view_mode', _viewMode); } catch (e) {}
const isList = _viewMode === 'list';
document.querySelectorAll('.asset-grid').forEach(g => g.classList.toggle('list-view', isList));
const btn = $('view-toggle-btn');
if (btn) {
// Show the icon for the layout a click switches TO.
btn.innerHTML = isList ? GRID_ICON : LIST_ICON;
btn.setAttribute('data-tip', isList ? 'Grid view' : 'List view');
}
}
function syncConnectBtn() { function syncConnectBtn() {
$('connect-btn').disabled = !$('server-url').value.trim() || !$('api-token').value.trim(); $('connect-btn').disabled = !$('server-url').value.trim() || !$('api-token').value.trim();
} }
@ -74,6 +118,11 @@
$('tab-library').addEventListener('click', () => Library.switchTab('library')); $('tab-library').addEventListener('click', () => Library.switchTab('library'));
$('tab-growing').addEventListener('click', () => Library.switchTab('growing')); $('tab-growing').addEventListener('click', () => Library.switchTab('growing'));
const vt = $('view-toggle-btn');
if (vt) vt.addEventListener('click', () => {
applyViewMode(getViewMode() === 'list' ? 'grid' : 'list');
});
let searchTimer; let searchTimer;
$('search-input').addEventListener('input', e => { $('search-input').addEventListener('input', e => {
clearTimeout(searchTimer); clearTimeout(searchTimer);
@ -114,24 +163,8 @@
finally { _disableImportBtns(false); Library._syncActions(); } finally { _disableImportBtns(false); Library._syncActions(); }
}); });
$('import-all-btn').addEventListener('click', async () => { // ── Upload highlighted bin file(s) to the MAM ──
const assets = Library.state.assets; $('upload-mam-btn').addEventListener('click', uploadToMam);
if (!assets.length) { UI.toast('No assets', 'error'); return; }
_disableImportBtns(true);
let ok = 0, fail = 0;
for (const a of assets) {
try {
const { localPath, safeName } = await Import.proxy(a);
Library.recordImport(localPath, { assetId: a.id, displayName: a.display_name || a.filename });
Library.recordImport('name:' + safeName, { assetId: a.id, displayName: a.display_name || a.filename });
ok++;
} catch (_) { fail++; }
}
_disableImportBtns(false);
UI.hideProgress();
UI.toast('Import all: ' + ok + ' ok' + (fail ? ', ' + fail + ' failed' : ''), fail ? 'error' : 'ok');
Library._syncActions();
});
$('mount-live-btn').addEventListener('click', async () => { $('mount-live-btn').addEventListener('click', async () => {
const a = Library.selectedAsset(); if (!a) return; const a = Library.selectedAsset(); if (!a) return;
@ -181,12 +214,8 @@
finally { Library._syncActions(); } finally { Library._syncActions(); }
}); });
// v2.2.1: Export Timeline is now a single-click pipeline — // Single Export entry → popup menu (Conform Timeline / Local Export).
// push to MAM → start conform → poll → new asset lands in Library. wireExportMenu();
// The Conform slide panel is still wired for Advanced → Export & Conform.
$('export-timeline-btn').addEventListener('click', oneClickExport);
$('export-conform-btn').addEventListener('click', openConformPanel);
$('fetch-relink-btn').addEventListener('click', openRelinkPanel);
// Advanced collapsible toggle (v2.2.0). // Advanced collapsible toggle (v2.2.0).
const advToggle = $('advanced-toggle'); const advToggle = $('advanced-toggle');
@ -203,6 +232,83 @@
['import-proxy-btn','import-hires-btn'].forEach(id => { $(id).disabled = dis; }); ['import-proxy-btn','import-hires-btn'].forEach(id => { $(id).disabled = dis; });
} }
function _basename(p) { return String(p).split(/[\\/]/).pop(); }
// Target project for uploads/auto-upload: the project filter when specific,
// else the only project if there's exactly one, else null (caller prompts).
function getTargetProjectId() {
const sel = Library.state.selectedProject;
if (sel && sel !== 'all') return sel;
const projs = Library.state.projects || [];
return projs.length === 1 ? projs[0].id : null;
}
// ── Export screen (full-panel chooser → Conform / Local Export) ──
function wireExportMenu() {
const btn = $('export-timeline-btn');
if (!btn) return;
const close = () => UI.setHidden('#export-screen', true);
btn.addEventListener('click', () => UI.setHidden('#export-screen', false));
const closeBtn = $('export-screen-close');
if (closeBtn) closeBtn.addEventListener('click', close);
const optConform = $('opt-conform');
if (optConform) optConform.addEventListener('click', () => { close(); openConformPanel(); });
const optLocal = $('opt-local-export');
if (optLocal) optLocal.addEventListener('click', () => { close(); runLocalExport(); });
}
// ── Upload highlighted bin file(s) (or file-picker fallback) ─────
async function uploadToMam() {
const projectId = getTargetProjectId();
if (!projectId) { UI.toast('Pick a target project (project filter) before uploading', 'error'); return; }
let paths = [];
try { paths = await Import.getSelectedBinPaths(); } catch (_) {}
if (!paths.length) {
UI.toast('No bin selection — choose file(s) to upload', 'muted');
try { paths = await Import.pickFiles(); }
catch (e) { UI.toast('File picker unavailable: ' + e.message, 'error'); return; }
}
if (!paths.length) return;
let ok = 0, fail = 0;
for (let i = 0; i < paths.length; i++) {
const name = _basename(paths[i]);
UI.showProgress('Uploading ' + name + ' (' + (i + 1) + '/' + paths.length + ')…', 10 + (i / paths.length) * 80);
try { await Import.uploadFile(paths[i], { projectId }); ok++; }
catch (e) { fail++; console.warn('[df] upload failed', paths[i], e.message); }
}
UI.hideProgress();
UI.toast('Uploaded ' + ok + (fail ? ', ' + fail + ' failed' : '') + ' to MAM', fail ? 'error' : 'ok');
if (ok) Library.refresh(Library.state.searchQuery);
}
// ── Local Export (server FFMPEG-trims hi-res → download → relink) ─
async function runLocalExport() {
const projectId = getTargetProjectId();
UI.showProgress('Reading Premiere sequence…', 8);
let td;
try { td = await Timeline.readActiveSequence(); }
catch (e) { UI.hideProgress(); UI.toast('Timeline read failed: ' + e.message, 'error'); return; }
if (!td.clips.length) { UI.hideProgress(); UI.toast('No clips in active sequence', 'error'); return; }
let resolved = Library.resolveClipsToAssets(td.clips);
const missing = resolved.filter(c => !c.asset_id && c.filePath);
if (missing.length) {
if (!projectId) { UI.hideProgress(); UI.toast(missing.length + ' clip(s) not in MAM — pick a target project so they can be uploaded', 'error'); return; }
try {
await Import.ensureClipsInMam(resolved, projectId, (name, n, total) =>
UI.showProgress('Uploading missing source ' + name + ' (' + n + '/' + total + ')…', 8 + (n / total) * 20));
resolved = Library.resolveClipsToAssets(td.clips);
} catch (e) { UI.hideProgress(); UI.toast('Auto-upload failed: ' + e.message, 'error'); return; }
}
try {
const res = await Timeline.localExport(resolved, (label, pct) => UI.showProgress(label, pct));
UI.hideProgress();
if (res.failed) UI.toast('Local Export: ' + res.succeeded + ' ok, ' + res.failed + ' failed', 'error');
else UI.toast('Local Export complete — ' + res.succeeded + ' clip(s) relinked', 'ok');
} catch (e) { UI.hideProgress(); UI.toast('Local Export failed: ' + e.message, 'error'); }
}
let _seqCache = null; let _seqCache = null;
// ── One-click Export Timeline ──────────────────────────────────── // ── One-click Export Timeline ────────────────────────────────────
@ -331,9 +437,13 @@
if (!_seqCache.clips.length) { UI.hideProgress(); UI.toast('No clips in active sequence', 'error'); return; } if (!_seqCache.clips.length) { UI.hideProgress(); UI.toast('No clips in active sequence', 'error'); return; }
UI.hideProgress(); UI.hideProgress();
const resolved = Library.resolveClipsToAssets(_seqCache.clips); const resolved = Library.resolveClipsToAssets(_seqCache.clips);
const total = _seqCache.clips.length;
const matched = resolved.filter(c => c.asset_id).length; const matched = resolved.filter(c => c.asset_id).length;
$('conform-clip-info').textContent = matched + ' of ' + _seqCache.clips.length + ' clip(s) matched'; const missing = total - matched;
$('conform-start-btn').disabled = matched === 0; $('conform-clip-info').textContent = missing
? matched + ' of ' + total + ' clip(s) in MAM — ' + missing + ' will be uploaded first'
: matched + ' of ' + total + ' clip(s) in MAM';
$('conform-start-btn').disabled = total === 0;
const conformProj = $('conform-proj-select'); const conformProj = $('conform-proj-select');
if (conformProj) { if (conformProj) {
conformProj.innerHTML = '<option value="">— Select project —</option>'; conformProj.innerHTML = '<option value="">— Select project —</option>';
@ -360,6 +470,8 @@
}; };
const p = presets[card.dataset.preset]; const p = presets[card.dataset.preset];
if (p) { $('conform-codec').value=p.codec; $('conform-quality').value=p.quality; $('conform-resolution').value=p.resolution; $('conform-audio').value=p.audio; } if (p) { $('conform-codec').value=p.codec; $('conform-quality').value=p.quality; $('conform-resolution').value=p.resolution; $('conform-audio').value=p.audio; }
// Manual codec/quality/res/audio fields appear only for Custom.
UI.setHidden('#conform-custom', card.dataset.preset !== 'custom');
}); });
$('conform-start-btn').addEventListener('click', async () => { $('conform-start-btn').addEventListener('click', async () => {
if (!_seqCache) return; if (!_seqCache) return;
@ -367,6 +479,15 @@
const projectId = conformProj ? conformProj.value : ''; const projectId = conformProj ? conformProj.value : '';
if (!projectId) { UI.toast('Select a target project', 'error'); return; } if (!projectId) { UI.toast('Select a target project', 'error'); return; }
UI.closeSlide('conform-overlay', 'conform-panel'); UI.closeSlide('conform-overlay', 'conform-panel');
// Auto-upload any timeline sources not yet in the MAM, then conform.
try {
const resolved = Library.resolveClipsToAssets(_seqCache.clips);
const missing = resolved.filter(c => !c.asset_id && c.filePath);
if (missing.length) {
await Import.ensureClipsInMam(resolved, projectId, (name, n, tot) =>
UI.showProgress('Uploading missing source ' + name + ' (' + n + '/' + tot + ')…', 5 + (n / tot) * 10));
}
} catch (e) { UI.hideProgress(); UI.toast('Auto-upload failed: ' + e.message, 'error'); return; }
UI.showProgress('Starting conform job…', 15); UI.showProgress('Starting conform job…', 15);
try { try {
const jobId = await Timeline.startConform(projectId, _seqCache.sequenceName, _seqCache, { const jobId = await Timeline.startConform(projectId, _seqCache.sequenceName, _seqCache, {
@ -465,6 +586,8 @@
} }
function init() { function init() {
enableDivDisabled();
applyViewMode(getViewMode());
wireConnectPane(); wireLibraryPane(); wireConnectPane(); wireLibraryPane();
wireExportPanel(); wireConformPanel(); wireRelinkPanel(); wireExportPanel(); wireConformPanel(); wireRelinkPanel();
showVersion(); showVersion();

View file

@ -304,5 +304,76 @@
return count; return count;
}; };
// ── Local Export ─────────────────────────────────────────────────
// Server trims each timeline clip's hi-res via FFMPEG, then we download
// the trimmed segments and relink the project items to them.
// CAVEAT: relink keys on the source media path, so a source used by
// multiple timeline clips with different in/out points will relink to a
// single segment (last one wins). Common single-use case is exact.
Timeline.localExport = async function (resolvedClips, onProgress) {
const P = ppro();
const project = await P.Project.getActiveProject();
if (!project) throw new Error('No active Premiere project');
const matched = (resolvedClips || []).filter(c => c.asset_id);
if (!matched.length) throw new Error('No clips matched MAM assets to export');
const payload = matched.map(c => ({
assetId: c.asset_id,
filename: c.fileName || (c.filePath ? path.basename(c.filePath) : 'clip'),
sourceInFrames: c.sourceInFrames, sourceOutFrames: c.sourceOutFrames,
timelineInFrames: c.timelineInFrames, timelineOutFrames: c.timelineOutFrames,
trackIndex: c.trackIndex,
}));
onProgress && onProgress('Requesting trim of ' + matched.length + ' clip(s)…', 10);
const job = await API.batchTrim(payload);
const jobId = job.jobId;
const clipByInstance = {};
(job.clips || []).forEach((cr, i) => { if (cr.clipInstanceId) clipByInstance[cr.clipInstanceId] = matched[i]; });
// Poll until every segment is ready (s3Key set) or the job fails.
const ready = {};
await new Promise((resolve, reject) => {
const t = setInterval(async () => {
try {
const st = await API.getTrimStatus(jobId);
const clips = st.clips || [];
const completed = clips.filter(c => c.status === 'completed' && c.s3Key);
onProgress && onProgress('Trimming on server… (' + completed.length + '/' + clips.length + ')',
15 + (completed.length / Math.max(1, clips.length)) * 45);
if (st.status === 'failed') { clearInterval(t); reject(new Error('Server trim job failed')); return; }
if (clips.length && completed.length === clips.length) {
clearInterval(t); completed.forEach(c => { ready[c.clipInstanceId] = c; }); resolve();
}
} catch (_) { /* transient — keep polling */ }
}, 2000);
});
// Download each segment and relink the source media path to it.
const results = { succeeded: 0, failed: 0, errors: [] };
const ids = Object.keys(ready);
for (let i = 0; i < ids.length; i++) {
const cid = ids[i];
const clip = clipByInstance[cid];
if (!clip) continue;
try {
onProgress && onProgress('Downloading segment ' + (i + 1) + '/' + ids.length + '…', 60 + (i / ids.length) * 35);
const seg = await API.getTempSegmentUrl(cid);
const ext = (seg.s3Key && seg.s3Key.split('.').pop()) || 'mov';
const base = UI.sanitizeFilename((clip.fileName || 'clip') + '-trim-' + cid.slice(0, 8) + '.' + ext);
const dest = await Import._tempPath(base);
const r = await API.requestExternal(seg.url);
if (!r.ok) throw new Error('Segment download HTTP ' + r.status);
await Import._writeBuffer(dest, await r.arrayBuffer());
if (clip.filePath) await Timeline._relinkInProject(project, clip.filePath, dest);
results.succeeded++;
} catch (e) {
results.failed++;
results.errors.push((clip && clip.fileName || 'clip') + ': ' + e.message);
}
}
return results;
};
window.Timeline = Timeline; window.Timeline = Timeline;
})(); })();

View file

@ -0,0 +1,78 @@
// Hover tooltips — v1
// Icon-first UI: every actionable control carries a [data-tip] label that
// surfaces on hover. UXP's CSS engine can't be trusted with
// `content: attr(data-tip)` on ::after, so we position a single floating
// bubble with plain DOM + getBoundingClientRect (both well supported).
(function () {
let bubble = null;
let timer = null;
function ensure() {
if (bubble) return bubble;
bubble = document.createElement('div');
bubble.className = 'tip-bubble';
document.body.appendChild(bubble);
return bubble;
}
function show(el) {
const text = el.getAttribute('data-tip');
if (!text) return;
const tip = ensure();
tip.textContent = text;
tip.style.display = 'block';
tip.style.opacity = '0';
const r = el.getBoundingClientRect();
const t = tip.getBoundingClientRect();
// position:absolute on a body-level node is offset from the document
// origin; add scroll offset (0 in practice, body doesn't scroll) for safety.
const sx = window.pageXOffset || document.documentElement.scrollLeft || 0;
const sy = window.pageYOffset || document.documentElement.scrollTop || 0;
const gap = 7;
const pos = el.getAttribute('data-tip-pos') || 'down';
let x, y;
if (pos === 'right') {
x = r.right + gap; y = r.top + (r.height - t.height) / 2;
} else if (pos === 'up') {
x = r.left + (r.width - t.width) / 2; y = r.top - t.height - gap;
} else if (pos === 'up-left') {
x = r.right - t.width; y = r.top - t.height - gap;
} else if (pos === 'down-left') {
x = r.right - t.width; y = r.bottom + gap;
} else {
x = r.left + (r.width - t.width) / 2; y = r.bottom + gap;
}
const vw = window.innerWidth || document.documentElement.clientWidth || 99999;
const vh = window.innerHeight || document.documentElement.clientHeight || 99999;
x = Math.max(4, Math.min(x, vw - t.width - 4));
y = Math.max(4, Math.min(y, vh - t.height - 4));
tip.style.left = (x + sx) + 'px';
tip.style.top = (y + sy) + 'px';
tip.style.opacity = '1';
}
function hide() {
clearTimeout(timer);
if (bubble) { bubble.style.opacity = '0'; bubble.style.display = 'none'; }
}
function bind(el) {
el.addEventListener('mouseenter', () => {
clearTimeout(timer);
timer = setTimeout(() => show(el), 240);
});
el.addEventListener('mouseleave', hide);
el.addEventListener('click', hide);
}
function init() {
document.querySelectorAll('[data-tip]').forEach(bind);
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();

File diff suppressed because it is too large Load diff

View file

@ -25,12 +25,20 @@ window.ZAMPP_API_PREFIX = API; // single source of truth (#115)
// here is the single source of truth both the Editor download buttons and // here is the single source of truth both the Editor download buttons and
// the Settings Capture SDKs page read from this list (#125). // the Settings Capture SDKs page read from this list (#125).
window.PREMIERE_RELEASES = [ window.PREMIERE_RELEASES = [
{
version: '2.2.2',
ccx: '/downloads/dragonflight-mam-2.2.2.ccx',
zxp: null,
installer: null,
notes: 'UXP panel — redesigned icon-rail UI, compact list view, single Export menu (Conform / Local Export), Upload to MAM',
latest: true,
},
{ {
version: '1.2.0', version: '1.2.0',
zxp: '/downloads/dragonflight-premiere-panel-1.2.0.zxp', zxp: '/downloads/dragonflight-premiere-panel-1.2.0.zxp',
installer: null, installer: null,
notes: 'Latest — design system refresh, aligned panel UI with web-ui tokens', notes: 'Legacy CEP panel — design system refresh',
latest: true, latest: false,
}, },
{ {
version: '1.0.1', version: '1.0.1',

View file

@ -1997,9 +1997,8 @@ function SdkSettingsCard() {
Premiere Pro Panel Premiere Pro Panel
</div> </div>
<div style={{ fontSize: 12, color: 'var(--text-3)', lineHeight: 1.55, marginBottom: 10 }}> <div style={{ fontSize: 12, color: 'var(--text-3)', lineHeight: 1.55, marginBottom: 10 }}>
The Dragonflight CEP panel enables growing-file editing, batch trim, and one-click hi-res relink directly inside Premiere Pro. The Dragonflight panel enables growing-file editing, conform, local export, and one-click hi-res relink directly inside Premiere Pro.
Install the <strong style={{ color: 'var(--text-2)' }}>.zxp</strong> via <a href="https://zxpsign.com/zxp-installer" target="_blank" rel="noreferrer" style={{ color: 'var(--accent)' }}>ZXP Installer</a> (Mac/Win), Download the <strong style={{ color: 'var(--text-2)' }}>.ccx</strong> and double-click to install via Creative Cloud (Mac/Win), then restart Premiere.
or run the <strong style={{ color: 'var(--text-2)' }}>Windows Setup</strong> which bundles the installer automatically.
</div> </div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}> <div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{PREMIERE_RELEASES.map(r => ( {PREMIERE_RELEASES.map(r => (
@ -2011,12 +2010,16 @@ function SdkSettingsCard() {
</div> </div>
<div style={{ fontSize: 11.5, color: 'var(--text-3)', marginTop: 2 }}>{r.notes}</div> <div style={{ fontSize: 11.5, color: 'var(--text-3)', marginTop: 2 }}>{r.notes}</div>
</div> </div>
<a href={r.zxp} download style={{ textDecoration: 'none' }}> {r.ccx ? (
<button className="btn ghost sm">ZXP</button> <a href={r.ccx} download style={{ textDecoration: 'none' }}>
</a> <button className="btn ghost sm">.ccx</button>
<a href={r.installer} download style={{ textDecoration: 'none' }}> </a>
<button className="btn ghost sm">Win Installer</button> ) : (
</a> <>
{r.zxp && <a href={r.zxp} download style={{ textDecoration: 'none' }}><button className="btn ghost sm">ZXP</button></a>}
{r.installer && <a href={r.installer} download style={{ textDecoration: 'none' }}><button className="btn ghost sm">Win Installer</button></a>}
</>
)}
</div> </div>
))} ))}
</div> </div>

View file

@ -408,11 +408,8 @@ function Editor() {
</div> </div>
</div> </div>
<div style={{ display: 'flex', gap: 10, marginTop: 4 }}> <div style={{ display: 'flex', gap: 10, marginTop: 4 }}>
<a href={(window.PREMIERE_LATEST || {}).zxp || '#'} download style={{ textDecoration: 'none' }}> <a href={(window.PREMIERE_LATEST || {}).ccx || (window.PREMIERE_LATEST || {}).zxp || '#'} download style={{ textDecoration: 'none' }}>
<button className="btn primary">Download ZXP</button> <button className="btn primary">Download Panel (.ccx)</button>
</a>
<a href={(window.PREMIERE_LATEST || {}).installer || '#'} download style={{ textDecoration: 'none' }}>
<button className="btn ghost">Windows Installer</button>
</a> </a>
</div> </div>
<div style={{ fontSize: 11.5, color: 'var(--text-3)', marginTop: -4 }}> <div style={{ fontSize: 11.5, color: 'var(--text-3)', marginTop: -4 }}>