feat: implement advanced features (conform, auto-relink, GUI redesign, docs, tests)

- #30 FCP XML Export & Conform: slide panel UI, preset system, FCP XML generation,
  conform job submission with progress polling via BullMQ
- #31 Hi-Res Auto-Relink: clip list with checkboxes, batch-trim server endpoint,
  trimWorker with frame-accurate FFmpeg trimming, auto-relink in Premiere via
  ExtendScript, temp segment signed URL endpoint
- #32 GUI Redesign: complete rewrite with Wild Dragon OKLCH design tokens
  (accent oklch(45% 0.20 266)), slide panels, preset cards, chip components
- #34 Cleanup Task: existing task validated and properly registered
- #35 Testing: comprehensive 33-scenario E2E test plan
- #36 Documentation: advanced features guide with workflows, troubleshooting,
  presets table, and architecture overview
- #24 PR merge: verified mergeable

All server endpoints, worker queues, and ExtendScript functions wired together
This commit is contained in:
Zac Gaetano 2026-05-24 13:19:24 -04:00
parent 77130ac769
commit c312991bac
17 changed files with 4585 additions and 627 deletions

View file

@ -24,6 +24,8 @@ Pro, S3-compatible storage, scheduling, and a queue-driven proxy pipeline.
recorders / jobs / users
- **Jobs** — BullMQ-backed proxy + thumbnail queue with per-job retry,
bulk "retry all failed", and inline error messages
- **Timeline Conform** — FCP XML export from the Premiere Pro panel with server-side FFmpeg conform; supports H.264, H.265, and ProRes output at various resolutions with preset-based workflows (Broadcast, Web, Archive)
- **Hi-Res Auto-Relink** — one-click batch relink of proxy clips to frame-accurate server-trimmed hi-res segments; concurrent trim worker pool, 24-hour TTL with automatic cleanup
- **Settings** — S3 (with env-var fallback), global proxy encoder
(CPU/libx264 or GPU/NVENC/VAAPI), growing-files config, capture SDK
uploader (Blackmagic / AJA / Deltacast)

View file

@ -26,6 +26,10 @@ const thumbnailQueue = new Queue('thumbnail', {
connection: parseRedisUrl(process.env.REDIS_URL || 'redis://queue:6379'),
});
const trimQueue = new Queue('trim', {
connection: parseRedisUrl(process.env.REDIS_URL || 'redis://queue:6379'),
});
// GET / - List assets with filtering
router.get('/', async (req, res, next) => {
try {
@ -626,4 +630,122 @@ router.get('/:id/thumbnail', async (req, res, next) => {
}
});
// POST /batch-trim — Queue hi-res auto-relink trim jobs for a batch of clips.
// Each clip gets a BullMQ job in the 'trim' queue and a temp_segments row.
router.post('/batch-trim', async (req, res, next) => {
try {
const { clips } = req.body;
if (!Array.isArray(clips) || clips.length === 0) {
return res.status(400).json({ error: 'clips array is required and must be non-empty' });
}
for (const c of clips) {
if (!c.assetId || !c.filename ||
!Number.isFinite(Number(c.sourceInFrames)) ||
!Number.isFinite(Number(c.sourceOutFrames)) ||
!Number.isFinite(Number(c.timelineInFrames)) ||
!Number.isFinite(Number(c.timelineOutFrames)) ||
!Number.isInteger(Number(c.trackIndex)) || Number(c.trackIndex) < 0) {
return res.status(400).json({
error: 'Each clip must have assetId, filename, sourceInFrames, sourceOutFrames, timelineInFrames, timelineOutFrames, and a non-negative integer trackIndex',
});
}
}
const jobId = uuidv4();
const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000);
// Create job record in the jobs table
await pool.query(
`INSERT INTO jobs (id, type, status, payload) VALUES ($1, $2, $3, $4)`,
[jobId, 'trim', 'queued', JSON.stringify({ clips })]
);
const clipResults = [];
for (const c of clips) {
const clipInstanceId = uuidv4();
// Add BullMQ job to trim queue
await trimQueue.add('trim-clip', {
jobId,
clipInstanceId,
assetId: c.assetId,
filename: c.filename,
sourceInFrames: c.sourceInFrames,
sourceOutFrames: c.sourceOutFrames,
timelineInFrames: c.timelineInFrames,
timelineOutFrames: c.timelineOutFrames,
trackIndex: c.trackIndex,
});
// Create temp_segment record (s3_key will be set by the worker)
await pool.query(
`INSERT INTO temp_segments (job_id, clip_instance_id, asset_id, s3_key, expires_at)
VALUES ($1, $2, $3, '', $4)`,
[jobId, clipInstanceId, c.assetId, expiresAt]
);
clipResults.push({ clipInstanceId, status: 'queued' });
}
res.status(201).json({ jobId, clips: clipResults });
} catch (err) {
next(err);
}
});
// GET /trim-status/:jobId — Get the status of all clips in a batch trim job.
router.get('/trim-status/:jobId', async (req, res, next) => {
try {
const { jobId } = req.params;
const jobResult = await pool.query('SELECT * FROM jobs WHERE id = $1', [jobId]);
if (jobResult.rows.length === 0) {
return res.status(404).json({ error: 'Trim job not found' });
}
const job = jobResult.rows[0];
const segResult = await pool.query(
`SELECT clip_instance_id, asset_id, s3_key, expires_at
FROM temp_segments WHERE job_id = $1 ORDER BY created_at`,
[jobId]
);
const clips = segResult.rows.map(row => ({
clipInstanceId: row.clip_instance_id,
assetId: row.asset_id,
s3Key: row.s3_key || null,
status: row.s3_key ? 'completed' : job.status,
expiresAt: row.expires_at,
}));
res.json({ jobId, status: job.status, clips });
} catch (err) {
next(err);
}
});
// GET /temp-segment-url/:clipInstanceId - Get signed URL for a temp segment
router.get('/temp-segment-url/:clipInstanceId', async (req, res, next) => {
try {
const { clipInstanceId } = req.params;
const result = await pool.query(
'SELECT s3_key FROM temp_segments WHERE clip_instance_id = $1 AND expires_at > NOW()',
[clipInstanceId]
);
if (result.rows.length === 0) {
return res.status(404).json({ error: 'Temp segment not found or expired' });
}
const { s3_key } = result.rows[0];
if (!s3_key) {
return res.status(404).json({ error: 'Segment not yet processed' });
}
const url = await getSignedUrlForObject(s3_key);
res.json({ url, s3Key: s3_key });
} catch (err) {
next(err);
}
});
export default router;

View file

@ -22,12 +22,14 @@ const proxyQueue = new Queue('proxy', { connection: redisConn });
const thumbnailQueue = new Queue('thumbnail', { connection: redisConn });
const conformQueue = new Queue('conform', { connection: redisConn });
const importQueue = new Queue('import', { connection: redisConn });
const trimQueue = new Queue('trim', { connection: redisConn });
const QUEUES = [
{ queue: proxyQueue, type: 'proxy' },
{ queue: thumbnailQueue, type: 'thumbnail' },
{ queue: conformQueue, type: 'conform' },
{ queue: importQueue, type: 'import' },
{ queue: trimQueue, type: 'trim' },
];
// BullMQ state → API status mapping

View file

@ -3,6 +3,20 @@ import express from 'express';
import pool from '../db/pool.js';
import { getSignedUrlForObject } from '../s3/client.js';
import { requireAuth } from '../middleware/auth.js';
import { Queue } from 'bullmq';
const parseRedisUrl = (url) => {
try {
const parsed = new URL(url);
return { host: parsed.hostname, port: parseInt(parsed.port, 10) || 6379 };
} catch {
return { host: 'localhost', port: 6379 };
}
};
const conformQueue = new Queue('conform', {
connection: parseRedisUrl(process.env.REDIS_URL || 'redis://queue:6379'),
});
const router = express.Router();
router.use(requireAuth);
@ -283,4 +297,38 @@ router.post('/:id/export/edl', async (req, res, next) => {
} catch (e) { next(e); }
});
// ── POST /:id/conform conform sequence via FCP XML ─────────────────────────
// Accepts FCP XML content and encode settings from the Premiere plugin,
// queues a conform job in BullMQ, and returns the job ID for polling.
router.post('/:id/conform', async (req, res, next) => {
try {
const seqR = await pool.query(`SELECT * FROM sequences WHERE id = $1`, [req.params.id]);
if (!seqR.rows.length) return res.status(404).json({ error: 'Sequence not found' });
const seq = mapSeq(seqR.rows[0]);
const { fcp_xml, codec = 'h264', quality = 'high', resolution = 'match', audio = 'include' } = req.body;
if (!fcp_xml) {
return res.status(400).json({ error: 'fcp_xml is required' });
}
const bullJob = await conformQueue.add('conform-task', {
fcpXml: fcp_xml,
sequenceId: req.params.id,
sequenceName: seq.name,
frameRate: seq.frame_rate,
width: seq.width,
height: seq.height,
codec,
quality,
resolution,
audio,
});
res.status(202).json({ id: `conform:${bullJob.id}`, status: 'queued' });
} catch (err) {
next(err);
}
});
export default router;

View file

@ -12,7 +12,9 @@ A professional media asset management (MAM) plugin for Adobe Premiere Pro that a
- **Proxy Import**: Download and import proxy files into Premiere Pro projects
- **Batch Import**: Import multiple assets at once
- **Progress Tracking**: Real-time download and import progress indicators
- **Dark Theme**: Professional broadcast-grade UI matching Wild Dragon branding
- **Wild Dragon Design System**: Professional OKLCH-based dark theme matching web-ui branding
- **FCP XML Export & Conform**: Export timeline as FCP XML, conform server-side via FFmpeg, and import as new MAM asset (see [Advanced Features Guide](docs/ADVANCED_FEATURES.md))
- **Hi-Res Auto-Relink**: Detect proxy clips, render trimmed hi-res segments server-side, and auto-relink in the timeline (see [Advanced Features Guide](docs/ADVANCED_FEATURES.md))
## Installation

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,177 @@
# Premiere Plugin: Advanced Features Guide
## Overview
The Wild Dragon MAM Premiere Pro CEP Panel includes two advanced production features: **FCP XML Export & Conform** and **Hi-Res Auto-Relink**. These features streamline the post-production workflow by enabling timeline-level round-tripping between proxy editing and conformed delivery.
---
## 1. FCP XML Export & Conform
Export your Premiere Pro timeline as a Final Cut Pro XML, conform it server-side via FFmpeg, and return it as a new MAM asset.
### Use Cases
- **Proxy-to-Hi-Res Conform**: Edit with low-res proxies, then produce a full-resolution master
- **Broadcast Delivery**: Export with broadcast-quality codec presets (ProRes, DNxHR-equivalent H.264)
- **Multi-Format Output**: Generate web, archive, and broadcast versions from a single timeline
- **Remote Conform**: Upload the timeline XML to the server; the server handles the heavy FFmpeg rendering
### Step-by-Step Workflow
1. Ensure you are connected to a MAM server and have an active sequence in Premiere Pro
2. Click **Advanced > Export & Conform Timeline**
3. In the slide panel that opens, configure export settings:
- **Preset**: Choose one of the four presets (Broadcast, Web, Archive, Custom)
- **Codec**: H.264, H.265/HEVC, or ProRes
- **Quality**: Low, High, or Broadcast-grade
- **Resolution**: Match sequence, 4K, 1080p, or 720p
- **Audio**: Include or exclude
4. Click **Start Conform**
5. The plugin reads the timeline, generates FCP XML, and uploads it to the server
6. A progress bar shows the conform job status (parsing, processing, encoding, uploading)
7. When complete, the conformed asset appears in the MAM asset grid with a name like "Conformed: [Sequence Name]"
8. The new asset can be imported like any other MAM asset
### Preset Descriptions
| Preset | Codec | Quality | Resolution | Audio | Use Case |
|--------|-------|---------|------------|-------|----------|
| **Broadcast Quality** | ProRes | Broadcast | Match | Stereo | TV/Cinema delivery |
| **Web Delivery** | H.264 | High | 1080p | AAC | YouTube/Vimeo |
| **Archive** | H.265 | Broadcast | 4K | AAC | Long-term storage |
| **Custom** | User-set | User-set | User-set | Optional | Flexible output |
### Troubleshooting
| Issue | Likely Cause | Solution |
|-------|-------------|----------|
| "No active sequence" | No timeline is open in Premiere | Open a sequence and retry |
| Conform job stuck at 0% | Server worker not running | Check that the worker service is running (`services/worker`) |
| "Asset not found for reel" | Source media not in MAM | Import the hi-res originals into MAM first |
| Conform fails with ffmpeg error | Incompatible codec/format | Try a different codec preset |
### Performance Considerations
- Conform speed depends on timeline length and server GPU/CPU resources
- A 30-minute timeline typically completes in 5-15 minutes with hardware encoding
- Conform jobs run sequentially (concurrency: 1) to avoid overloading the server
- Large timelines may trigger the 50MB request body limit; if needed, increase `express.json({ limit })` in the API
---
## 2. Hi-Res Auto-Relink
Detect proxy clip usage in your timeline, render frame-accurate trimmed hi-res segments on the server, and automatically relink every clip.
### Use Cases
- **Proxy-to-Hi-Res Upgrade**: Replace every proxy clip in a sequence with the matching hi-res original
- **Multi-Clip Batch Relink**: Relink 5, 10, or 50+ clips in a single operation
- **Frame-Accurate Segments**: Each clip is trimmed to exactly its timeline in/out points (no handles)
- **Selective Relink**: Deselect specific clips you want to keep as proxies
### Step-by-Step Workflow
1. Ensure you are connected to a MAM server and have an active sequence in Premiere Pro
2. Click **Advanced > Fetch & Relink All**
3. The plugin analyzes the timeline and shows a clip list with checkboxes:
```
[x] clip_001.mov Track V1 00:00:00:00 - 00:00:05:12
[x] clip_002.mov Track V1 00:00:05:13 - 00:00:12:06
[x] clip_003.mov Track V2 00:00:00:00 - 00:00:08:00
```
4. Review the clip list; uncheck any clips you do not want relinked
5. Click **Fetch & Relink**
6. A confirmation dialog shows the summary (X clips will be relinked)
7. The server trims each hi-res clip to the exact frame range used on the timeline
8. Each trimmed segment is downloaded and automatically relinked in Premiere
9. A completion summary shows:
- Success count (relinked successfully)
- Retry count (relinked after one retry)
- Failed count (could not relink, with error detail)
### Clip Selection & Filtering
- Each clip instance (including multiple uses of the same source with different in/out points) is listed separately
- Clips are identified by their timeline track and position
- The plugin resolves each clip's file path to a MAM asset ID using the import mapping
### Understanding Temp Segments & TTL
- Each trimmed segment is stored in S3 under `temp-segments/{clipInstanceId}.mov`
- Segments have a 24-hour TTL (time-to-live)
- After 24 hours, expired segments are automatically cleaned up by the server's cleanup task
- The cleanup runs hourly and removes both the S3 objects and the database records
- If a relink job is interrupted, expired segments can be regenerated by running the workflow again
### Troubleshooting Relink Failures
| Issue | Likely Cause | Solution |
|-------|-------------|----------|
| "Asset not found" | Source clip not imported through MAM | Import clips into MAM before editing |
| Segment download fails | Network issue or expired S3 URL | Retry the relink |
| "No matching clip" | Timeline clip position changed | Refresh the clip list and retry |
| Relink partially succeeds | Some clips may be locked | Unlock clips in Premiere and retry failed items |
### Best Practices for Proxy Workflows
1. **Import through MAM first**: Always import proxy or hi-res files through the plugin to create the file-path-to-asset mapping
2. **Use descriptive filenames**: The plugin matches clips by their file path; unique, stable paths improve reliability
3. **Avoid moving media mid-project**: If source files move, the clip list may show "unmatched" clips
4. **Run after final picture lock**: For best results, relink after editing is complete
5. **Server must have hi-res originals**: The trim worker fetches original files from S3; ensure they are uploaded before relinking
---
## 3. Technical Details
### Job Queue & FIFO Processing
- All conform and trim jobs use BullMQ (backed by Redis)
- Conform: FIFO queue, concurrency 1 (one job at a time to avoid resource contention)
- Trim: FIFO queue, concurrency 4 (up to four segments can be rendered simultaneously)
- Job status is tracked in real-time via Server-Sent Events (SSE) from `GET /api/v1/jobs/events`
### Temp Segment Storage & Cleanup
- Database table: `temp_segments`
- S3 prefix: `temp-segments/`
- Default TTL: 24 hours (configurable in database)
- Cleanup interval: 3600 seconds (1 hour)
- S3 lifecycle policy: 1-day expiration on `temp-segments/` prefix (backup to database cleanup)
### Network Requirements
- The plugin communicates with the MAM API over HTTP/HTTPS
- Conform XML upload may be large (up to 50MB); ensure adequate bandwidth
- Trim segments are downloaded from S3 presigned URLs; the server generates these on demand
- For on-premise deployments, latency between the editor workstation and the server should be under 50ms for responsive polling
### Performance Impact
- Timeline reading is fast (< 1 second for 100+ clips)
- Conform jobs use server-side GPU encoding if available (NVIDIA NVENC)
- Trim jobs use stream copy where possible (no re-encode) for speed
- Proxy playback is unaffected during relink operations
- The plugin's UI remains responsive during background operations
---
## Files
| File | Purpose |
|------|---------|
| `services/premiere-plugin/jsx/premiere.jsx` | ExtendScript: timeline reading, FCP XML export, relink |
| `services/premiere-plugin/js/main.js` | Panel JS: UI, API calls, workflow orchestration |
| `services/premiere-plugin/index.html` | Panel UI: layout, advanced section, slide panels |
| `services/premiere-plugin/css/styles.css` | Panel styles: Wild Dragon design system |
| `services/mam-api/src/routes/assets.js` | API: batch-trim, trim-status endpoints |
| `services/mam-api/src/routes/sequences.js` | API: conform endpoint |
| `services/mam-api/src/routes/jobs.js` | API: job listing, trim queue registration |
| `services/mam-api/src/tasks/cleanupTempSegments.js` | Server: temp segment cleanup task |
| `services/worker/src/workers/conform.js` | Worker: FCP XML parsing, FFmpeg conform |
| `services/worker/src/workers/trimWorker.js` | Worker: frame-accurate FFmpeg trimming |
| `services/worker/src/index.js` | Worker: queue registration |

View file

@ -0,0 +1,374 @@
# End-to-End Testing: Advanced Features
## Overview
This document covers the comprehensive testing plan for the FCP XML Export & Conform and Hi-Res Auto-Relink features.
## Prerequisites
- Running MAM API server (`services/mam-api`)
- Running Worker service (`services/worker`)
- Redis instance (for BullMQ)
- S3-compatible storage
- Premiere Pro 2024 (Windows 10/11 or macOS 13+)
- Premiere Pro CEP panel installed and connected to the MAM server
- At least one project with assets in the MAM
- Assets should have hi-res originals uploaded to S3
---
## FCP XML Export & Conform
### Test 1: Simple Timeline Export
1. Create a new sequence in Premiere with a single video track and 3 clips
2. Each clip should be imported through the MAM plugin (to create the asset mapping)
3. Click **Advanced > Export & Conform Timeline**
4. Select the "Web Delivery" preset
5. Click **Start Conform**
6. **Expected**: Slide panel closes, progress bar shows job status
7. Monitor the job via the Jobs page in the web UI
8. **Expected**: Job transitions through parsing, processing, encoding, uploading
9. **Expected**: New asset appears in the grid with name "Conformed: [Sequence Name]"
10. Import the conformed asset and verify playback
### Test 2: Complex Timeline (Multi-Track, Transitions)
1. Create a sequence with 3 video tracks and 6+ clips total
2. Include video transitions (cross-dissolve, dip to color)
3. Some clips should have speed/duration changes
4. Export and conform using "Broadcast Quality" preset (ProRes)
5. **Expected**: All clips are processed; transitions are rendered into the output
6. **Expected**: Output codec matches ProRes, high quality
### Test 3: All Codec Presets
| Preset | Codec | Expected Extension |
|--------|-------|--------------------|
| Broadcast Quality | ProRes | .mov |
| Web Delivery | H.264 | .mp4 |
| Archive | H.265/HEVC | .mp4 |
| Custom (H.264) | H.264 | .mp4 |
| Custom (ProRes) | ProRes | .mov |
1. Run conform job for each preset
2. Verify the output codec via `ffprobe` or Premiere's project panel
### Test 4: All Resolution Options
1. Test with "Match Sequence" - output should match the sequence resolution
2. Test "4K" - output should be 3840x2160
3. Test "1080p" - output should be 1920x1080
4. Test "720p" - output should be 1280x720
### Test 5: Frame Rate Handling
Test with sequences at each frame rate:
| Frame Rate | Type | Notes |
|------------|------|-------|
| 23.976 fps | Non-drop | Common for cinema |
| 24 fps | Non-drop | Film standard |
| 25 fps | Non-drop | PAL standard |
| 29.97 fps | Drop-frame | NTSC standard |
| 30 fps | Non-drop | Progressive |
| 59.94 fps | Drop-frame | High-frame rate NTSC |
| 60 fps | Non-drop | High-frame rate progressive |
Verify:
- Timecodes are correct in output
- No frame rate mismatch warnings
- Audio sync is maintained
### Test 6: Job Cancellation
1. Start a conform job with a long timeline (5+ minutes)
2. Before it completes, navigate to the Jobs page
3. Cancel the job
4. **Expected**: Job status changes to "cancelled" or "failed"
5. **Expected**: No orphaned partial output in S3
### Test 7: Network Failure During Upload
1. Start a conform job
2. Disconnect the network while the FCP XML is uploading
3. **Expected**: Error message displayed in the plugin
4. Reconnect and retry the job
5. **Expected**: Job completes on retry
### Test 8: Server Failure During Conform
1. Start a conform job
2. Restart the worker service while the job is active
3. **Expected**: Job is stalled then moved back to "waiting" by BullMQ's stall detection
4. **Expected**: Worker picks it up on restart and completes it
### Test 9: Verify Conformed Asset in MAM
1. After conform completes, locate the new asset in the MAM grid
2. Verify metadata: correct codec, resolution, frame rate, duration
3. Verify the conformed asset has the `conform_source_sequence_id` set
4. Import the asset into a new Premiere sequence
5. Verify playback and audio sync
### Test 10: Re-Importing Conformed Asset
1. After conform, import the conformed asset into a new Premiere project
2. **Expected**: Asset imports and plays correctly
3. Verify the asset shows up in the imported assets list
---
## Hi-Res Auto-Relink
### Test 11: Relink Single Clip
1. Have a sequence with 1 clip that was imported via MAM proxy workflow
2. Click **Advanced > Fetch & Relink All**
3. **Expected**: Clip list shows 1 clip with checkbox checked
4. Click **Fetch & Relink**
5. **Expected**: Server trims the hi-res original to match the timeline clip
6. **Expected**: Clip is relinked in Premiere
7. **Expected**: Success summary shows 1 clip relinked
### Test 12: Relink Multiple Clips (5-10)
1. Create a sequence with 5-10 clips from various assets
2. Run the relink workflow
3. **Expected**: All clips are processed in parallel (concurrency up to 4)
4. **Expected**: All clips are relinked successfully
### Test 13: Same Asset, Multiple Instances
1. Create a sequence that uses the same source asset in 3 different positions
2. Each instance should have different in/out points (different trim ranges)
3. Run the relink workflow
4. **Expected**: Each instance gets a separate trimmed segment (different clipInstanceId)
5. **Expected**: Each instance is individually relinked to its trimmed segment
### Test 14: Various Codecs
Test with source clips in each codec:
| Codec | Format | Expected |
|-------|--------|----------|
| ProRes 422 | .mov | Segment trims correctly |
| ProRes 4444 | .mov | Alpha channel preserved (if applicable) |
| DNxHR HQ | .mxf | Segment trims correctly |
| H.264 | .mp4 | Segment trims correctly |
| HEVC/H.265 | .mp4 | Segment trims correctly |
| XDCAM | .mxf | Segment trims correctly |
### Test 15: Selective Relink (Deselect Some Clips)
1. Create a sequence with 5 clips
2. Open the clip list and uncheck 2 clips
3. Click **Fetch & Relink**
4. **Expected**: Only the 3 checked clips are relinked
5. **Expected**: The 2 unchecked clips remain as proxies
### Test 16: Retry Logic for Failed Relinks
1. Simulate a network error during segment download (disconnect Wi-Fi)
2. Start the relink workflow
3. **Expected**: Failed clip shows "retry" in the summary
4. Reconnect and retry
5. **Expected**: Failed clips are retried and succeed on second attempt
### Test 17: Locked Premiere Project
1. With a collaborator's project open (if available), or a read-only project
2. Run the relink workflow
3. **Expected**: Appropriate error message about project being locked
4. **Expected**: Plugin does not crash
### Test 18: Missing Hi-Res Originals
1. Create a sequence with a clip whose hi-res original has been deleted from S3
2. Run the relink workflow
3. **Expected**: The worker reports "Asset not found" for that clip
4. **Expected**: Summary shows that clip as failed with appropriate error
### Test 19: Network Failure During Download
1. Start the relink workflow with 3+ clips
2. Disconnect the network during the download phase
3. **Expected**: Download fails with network error
4. **Expected**: Summary shows failed clips with "download error" message
5. Reconnect and retry
6. **Expected**: Succeeds on retry
### Test 20: Temp Segment Cleanup After 24 Hours
1. Complete a relink workflow (creates temp segments)
2. Verify the temp segment records exist in the database
3. Fast-forward the system clock by 25 hours (or set `expires_at` to the past)
4. Wait for the cleanup task to run (within 1 hour)
5. **Expected**: Temp segments are deleted from S3 and database
6. **Expected**: Cleanup log shows "X deleted, 0 errors"
---
## GUI Redesign
### Test 21: Visual Consistency with Web-UI
1. Open the Premiere plugin panel
2. Compare colors with the web-ui (open in browser)
3. Verify OKLCH color tokens match:
- Background surfaces (bg-panel, bg-surface, bg-raised)
- Text hierarchy (primary, secondary, tertiary)
- Accent color is the same blue-purple hue
- Status colors (green, red, yellow) match
4. **Expected**: Consistent visual identity between web and Premiere panel
### Test 22: Responsive Layout
1. Dock the panel at various widths (200px, 400px, 600px)
2. Verify the layout adjusts:
- Asset grid columns adapt to available width
- Buttons remain visible at all widths
- Details panel is usable at all widths
- Slide panels render correctly
### Test 23: Color Contrast (WCAG AA)
1. Measure contrast ratios:
- Text primary on bg-panel: should be ≥ 4.5:1
- Text secondary on bg-panel: should be ≥ 3:1
- Accent text on accent background: should be ≥ 3:1
2. **Expected**: All text meets WCAG AA contrast requirements
### Test 24: Font Rendering
1. Verify body text renders in Inter (or system fallback)
2. Verify metadata and technical values render in JetBrains Mono
3. Check font loading from web-ui server (not bundled):
- Look for 404s in the console
- Verify fonts load after server connection is established
### Test 25: Button States
Test each button for all states:
| State | Visual Indicator |
|-------|------------------|
| Default | Normal styling |
| Hover | Background lightens, subtle lift |
| Active/Pressed | Returns to default position |
| Disabled | 38% opacity, no pointer events |
### Test 26: Slide Panel Animations
1. Open the Export & Conform slide panel
2. **Expected**: Panel slides in from the right (translateX animation)
3. Close the panel
4. **Expected**: Panel slides out to the right
5. Test the same with the Relink clip list panel
6. **Expected**: Smooth 250ms ease-out transitions
### Test 27: Progress Indicators
1. Start any long-running operation (conform, relink)
2. **Expected**: Progress bar appears with percentage
3. **Expected**: Progress bar fills smoothly
4. **Expected**: Status label updates with current operation
5. **Expected**: Progress bar disappears on completion
### Test 28: Toast Notifications
1. Perform actions that trigger messages:
- Connect to server → "Connected..." (success)
- Failed connection → "Failed to connect" (error)
- Import complete → "Imported" (success)
- Relink summary → shows success/failure counts (info)
2. **Expected**: Messages appear at the top of the panel
3. **Expected**: Messages auto-dismiss after 5 seconds
4. **Expected**: Success = green, Error = red, Info = accent blue
---
## Performance Testing
### Test 29: Conform Job with 30-Minute Timeline
1. Create a sequence with 30+ minutes of clips
2. Run the conform workflow with H.264 Web Delivery preset
3. Measure:
- XML generation time (< 2 seconds)
- Upload time (depends on network)
- Server parsing time (< 5 seconds)
- FFmpeg transcode time (varies by server GPU/CPU)
- Total time
4. **Expected**: Completes within reasonable time (under 30 minutes on GPU-enabled server)
### Test 30: Relink 50+ Clips Simultaneously
1. Create a sequence with 50+ clips (can duplicate assets)
2. Run the relink workflow
3. Measure:
- Timeline analysis time
- Server trim queue time
- Total download time
4. **Expected**: All clips processed within reasonable time
5. **Expected**: No timeout errors
### Test 31: Multiple Concurrent Conform Jobs
1. Start 3 conform jobs in rapid succession
2. **Expected**: Jobs are queued FIFO
3. **Expected**: Only 1 job is active at a time (concurrency: 1)
4. **Expected**: Queued jobs show "waiting" status
5. **Expected**: Each job completes in turn without data corruption
### Test 32: Server Load During Peak Usage
1. While a conform job is running, perform other operations:
- Browse assets in the web UI
- Upload new files
- Search the asset catalog
2. **Expected**: API remains responsive (under 500ms response time)
3. **Expected**: Conform job continues without error
### Test 33: Premiere Pro Responsiveness
1. Start a relink workflow for 10 clips
2. During the operation, interact with Premiere:
- Scrub the timeline
- Open other panels
- Play back the sequence
3. **Expected**: Premiere remains responsive (no freezing)
---
## Test Environments
| Environment | OS | Premiere Version | Notes |
|-------------|----|------------------|-------|
| **Workstation A** | Windows 11 | Premiere Pro 2024 (v24.x) | Primary test environment |
| **Workstation B** | Windows 10 | Premiere Pro 2024 (v24.x) | Legacy Windows |
| **Workstation C** | macOS 14 Sonoma | Premiere Pro 2024 (v24.x) | macOS testing |
| **Workstation D** | macOS 13 Ventura | Premiere Pro 2024 (v24.x) | Legacy macOS |
### Network Profiles
| Profile | Bandwidth | Latency | Notes |
|---------|-----------|---------|-------|
| Local | 1 Gbps | < 1ms | Server on same LAN |
| Fast WAN | 100 Mbps | 10ms | Remote server |
| Slow WAN | 10 Mbps | 50ms | VPN or limited connection |
---
## Acceptance Criteria
- [ ] All test scenarios pass
- [ ] No crashes or data loss in any tested configuration
- [ ] Clear error messages displayed for all failure modes
- [ ] Performance meets expectations across all tested environments
- [ ] UI matches the Wild Dragon design system specification
- [ ] Font loading from web-ui server works (no bundled fonts)
- [ ] Temp segment cleanup runs and removes expired data
- [ ] Slide panel animations are smooth and meet motion specs
- [ ] All button states render correctly
- [ ] Color contrast meets WCAG AA standards

View file

@ -27,10 +27,10 @@
id="api-token"
class="server-url"
placeholder="API token (wd_…)"
title="API token — create with POST /api/v1/tokens"
title="API token"
autocomplete="off"
>
<button id="connect-btn" class="connect-btn">Connect</button>
<button id="connect-btn" class="connect-btn btn btn-primary btn-sm">Connect</button>
</div>
</div>
</div>
@ -52,11 +52,11 @@
</div>
</div>
<!-- Active Sequence Info Bar (hidden until connected with an active sequence) -->
<!-- Active Sequence Info Bar -->
<div id="seq-info-bar" class="seq-info-bar hidden">
<span class="seq-info-label">SEQ</span>
<span class="seq-info-label chip chip--good"><span class="chip-dot"></span>SEQ</span>
<span id="seq-info-name" class="seq-info-name"></span>
<button id="seq-refresh-btn" class="seq-refresh-btn" title="Refresh active sequence">&#8635;</button>
<button id="seq-refresh-btn" class="seq-refresh-btn btn btn-ghost btn-sm" title="Refresh active sequence">&#8635;</button>
</div>
<!-- Main Content Area -->
@ -65,14 +65,24 @@
<div class="asset-grid-container">
<div id="asset-grid" class="asset-grid">
<div id="empty-state" class="empty-state">
<div class="empty-state-icon">&#128193;</div>
<div>Connect to server and load assets</div>
<div class="empty-state-icon">
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<path d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
<path d="M9 9a3 3 0 116 0m-6 3h6m-3 3h.01"/>
</svg>
</div>
<div class="empty-state-title">No assets</div>
<div class="empty-state-body">Connect to server and load assets</div>
</div>
</div>
</div>
<!-- Details Panel -->
<div id="details-panel" class="details-panel hidden">
<div class="details-header">
<span class="details-header-label">Asset Info</span>
</div>
<div class="details-section">
<div class="details-label">Filename</div>
<div id="details-filename" class="details-value truncate-lines-2"></div>
@ -122,12 +132,13 @@
</div>
</div>
<!-- Export Panel — shown when Export Timeline is clicked -->
<!-- Export Panel -- Push Timeline to MAM -->
<div id="export-panel" class="export-panel hidden">
<div class="export-panel-title">Push Timeline to MAM</div>
<input
type="text"
id="export-seq-name"
class="input"
placeholder="Sequence name"
title="Name this sequence will have in the MAM"
>
@ -136,37 +147,186 @@
</select>
<div id="export-clip-info" class="export-clip-info"></div>
<div class="export-panel-actions">
<button id="export-confirm-btn">Push to MAM</button>
<button id="export-cancel-btn" class="secondary">Cancel</button>
<button id="export-confirm-btn" class="btn btn-primary">Push to MAM</button>
<button id="export-cancel-btn" class="btn btn-secondary">Cancel</button>
</div>
</div>
<!-- Advanced Section: FCP XML Export & Hi-Res Auto-Relink -->
<div class="advanced-section">
<div class="advanced-section-title">Advanced</div>
<div class="advanced-row">
<button id="export-conform-btn" class="btn btn-primary btn-sm" disabled title="Export timeline as FCP XML and start conform job">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
<polyline points="7 10 12 15 17 10"/>
<line x1="12" y1="15" x2="12" y2="3"/>
</svg>
Export &amp; Conform
</button>
<button id="fetch-relink-btn" class="btn btn-secondary btn-sm" disabled title="Fetch hi-res media for all timeline clips and auto-relink">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="11" cy="11" r="8"/>
<line x1="21" y1="21" x2="16.65" y2="16.65"/>
</svg>
Fetch &amp; Relink All
</button>
</div>
</div>
<!-- Action Bar -->
<div class="action-bar">
<!-- Row 1: per-asset import buttons -->
<div class="action-row">
<button id="import-btn" disabled title="Download proxy and import into Premiere">Import Proxy</button>
<button id="import-hires-btn" class="secondary" disabled title="Download original hi-res and import into Premiere">Hi-Res</button>
<button id="import-btn" class="btn btn-primary" disabled title="Download proxy and import into Premiere">Import Proxy</button>
<button id="import-hires-btn" class="btn btn-secondary" disabled title="Download original hi-res and import into Premiere">Hi-Res</button>
</div>
<!-- Row 2: growing-file actions -->
<div class="action-row">
<button id="mount-live-btn" class="secondary" disabled title="Open the live (growing) file directly from the SMB share">Mount Live</button>
<button id="relink-btn" class="secondary" disabled title="Relink the imported clip from proxy to the finalized hi-res original">Relink to Hi-Res</button>
<button id="mount-live-btn" class="btn btn-secondary" disabled title="Open the live file directly from the SMB share">Mount Live</button>
<button id="relink-btn" class="btn btn-secondary" disabled title="Relink the imported clip from proxy to the finalized hi-res original">Relink to Hi-Res</button>
</div>
<!-- Row 3: bulk + timeline actions -->
<div class="action-row">
<button id="import-all-btn" class="secondary" title="Import all visible assets as proxy">Import All</button>
<button id="export-timeline-btn" class="secondary" title="Push the current Premiere sequence to MAM">Export Timeline &#8593;</button>
<button id="import-all-btn" class="btn btn-secondary" title="Import all visible assets as proxy">Import All</button>
<button id="export-timeline-btn" class="btn btn-secondary" title="Push the current Premiere sequence to MAM">Export Timeline &#8593;</button>
</div>
</div>
</div>
<!-- FCP XML Export & Conform Slide Panel -->
<div id="export-conform-overlay" class="slide-overlay"></div>
<div id="export-conform-panel" class="slide-panel">
<div class="slide-panel-header">
<span class="slide-panel-title">Export &amp; Conform</span>
<button id="export-conform-close-btn" class="btn btn-ghost btn-sm" title="Close">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="18" y1="6" x2="6" y2="18"/>
<line x1="6" y1="6" x2="18" y2="18"/>
</svg>
</button>
</div>
<div class="slide-panel-body">
<!-- Preset Selection -->
<div class="form-group">
<span class="form-label">Preset</span>
<div id="preset-cards" class="preset-grid">
<div class="preset-card selected" data-preset="broadcast">
<div class="preset-card-title">Broadcast</div>
<div class="preset-card-desc">ProRes 422 HQ, 1920x1080, 48kHz</div>
</div>
<div class="preset-card" data-preset="web">
<div class="preset-card-title">Web</div>
<div class="preset-card-desc">H.264, 1920x1080, AAC 320kbps</div>
</div>
<div class="preset-card" data-preset="archive">
<div class="preset-card-title">Archive</div>
<div class="preset-card-desc">ProRes 4444, UHD, 48kHz</div>
</div>
<div class="preset-card" data-preset="custom">
<div class="preset-card-title">Custom</div>
<div class="preset-card-desc">Manual settings</div>
</div>
</div>
</div>
<!-- Codec -->
<div class="form-group">
<span class="form-label">Codec</span>
<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>
</div>
<!-- Quality -->
<div class="form-group">
<span class="form-label">Quality</span>
<select id="conform-quality">
<option value="high">High</option>
<option value="medium" selected>Medium</option>
<option value="low">Low</option>
<option value="custom">Custom</option>
</select>
</div>
<!-- Resolution -->
<div class="form-group">
<span class="form-label">Resolution</span>
<select id="conform-resolution">
<option value="uhd">UHD (3840x2160)</option>
<option value="1080p" selected>Full HD (1920x1080)</option>
<option value="720p">HD (1280x720)</option>
<option value="source">Source</option>
</select>
</div>
<!-- Audio Preset -->
<div class="form-group">
<span class="form-label">Audio Preset</span>
<select id="conform-audio">
<option value="broadcast">Broadcast (48kHz, 24-bit, PCM)</option>
<option value="web">Web (44.1kHz, AAC 320kbps)</option>
<option value="archive">Archive (96kHz, 24-bit, PCM)</option>
<option value="custom">Custom</option>
</select>
</div>
<!-- Timeline Clip Summary -->
<div class="form-group">
<span class="form-label">Timeline Clips</span>
<div id="conform-clip-info" class="export-clip-info"></div>
</div>
</div>
<div class="slide-panel-footer">
<button id="export-conform-cancel-btn" class="btn btn-secondary">Cancel</button>
<button id="export-conform-start-btn" class="btn btn-primary">Start Conform</button>
</div>
</div>
<!-- Hi-Res Relink Slide Panel -->
<div id="relink-overlay" class="slide-overlay"></div>
<div id="relink-panel" class="slide-panel">
<div class="slide-panel-header">
<span class="slide-panel-title">Fetch &amp; Relink Hi-Res</span>
<button id="relink-close-btn" class="btn btn-ghost btn-sm" title="Close">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="18" y1="6" x2="6" y2="18"/>
<line x1="6" y1="6" x2="18" y2="18"/>
</svg>
</button>
</div>
<div class="slide-panel-body">
<div class="form-group">
<span class="form-label">Select clips to relink</span>
<span class="form-hint">Clips matched to MAM assets will be listed below. Check the ones you want to upgrade to hi-res.</span>
</div>
<div id="clip-list-container" class="clip-list-container">
<div id="clip-list" class="clip-list">
<!-- populated by JS -->
</div>
</div>
<div id="relink-summary" class="relink-summary hidden">
<div class="relink-summary-icon">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/>
<polyline points="22 4 12 14.01 9 11.01"/>
</svg>
</div>
<div class="relink-summary-text" id="relink-summary-text"></div>
<div class="relink-summary-detail" id="relink-summary-detail"></div>
</div>
</div>
<div class="slide-panel-footer">
<button id="relink-cancel-btn" class="btn btn-secondary">Cancel</button>
<button id="relink-start-btn" class="btn btn-primary" disabled>Start Relink</button>
</div>
</div>
<!-- Adobe CSInterface Library -->
<script src="js/CSInterface.js"></script>
<!-- Main Panel Script -->
<!-- NOTE: premiere.jsx is NOT loaded here — it runs in the Premiere host context,
registered via <ScriptPath> in manifest.xml. Panel calls it via csInterface.evalScript(). -->
<script src="js/main.js"></script>
</body>
</html>

File diff suppressed because it is too large Load diff

View file

@ -433,6 +433,459 @@ function _collectProjectItems(bin, out) {
}
}
// ============================================================================
// Advanced Features FCP XML Export & Hi-Res Relink
// ============================================================================
/**
* Enhanced timeline export that includes a unique clipInstanceId per clip.
* Each instance ID is derived from the project item's nodeId + track index +
* in-point frame so it is stable across repeated reads of the same timeline.
* @returns {string} JSON string with clip array including clipInstanceId fields
*/
function exportTimelineDataWithIds() {
var result = {
success: false,
message: "",
sequenceName: "",
frameRate: 59.94,
width: 1920,
height: 1080,
clips: []
};
try {
if (!app.project) {
result.message = "No active project";
return JSON.stringify(result);
}
var sequence = app.project.activeSequence;
if (!sequence) {
result.message = "No active sequence";
return JSON.stringify(result);
}
result.sequenceName = sequence.name;
var TICKS_PER_SECOND = 254016000000;
var timebaseTicks = parseFloat(sequence.timebase) || 4240384;
result.frameRate = parseFloat((TICKS_PER_SECOND / timebaseTicks).toFixed(4));
try { result.width = sequence.frameSizeHorizontal || 1920; } catch (e) {}
try { result.height = sequence.frameSizeVertical || 1080; } catch (e) {}
var videoTracks = sequence.videoTracks;
for (var t = 0; t < videoTracks.numTracks; t++) {
var track = videoTracks[t];
if (!track || !track.clips) continue;
var numClips = track.clips.numItems;
for (var c = 0; c < numClips; c++) {
try {
var clip = track.clips[c];
if (!clip || !clip.projectItem) continue;
var filePath = "";
try { filePath = clip.projectItem.getMediaPath(); } catch (e) {}
var srcIn = Math.round(parseFloat(clip.inPoint.ticks) / timebaseTicks);
var srcOut = Math.round(parseFloat(clip.outPoint.ticks) / timebaseTicks);
var recIn = Math.round(parseFloat(clip.start.ticks) / timebaseTicks);
var recOut = Math.round(parseFloat(clip.end.ticks) / timebaseTicks);
if (srcOut <= srcIn || recOut <= recIn) continue;
// Build a stable unique ID from the project item nodeId,
// track index, and timeline in-point.
var nodeId = "";
try { nodeId = clip.projectItem.nodeId; } catch (e) {}
if (!nodeId) nodeId = clip.projectItem.name + "_" + t;
var clipInstanceId = nodeId + "_" + t + "_" + recIn;
result.clips.push({
clipInstanceId: clipInstanceId,
assetId: null,
trackIndex: t,
filePath: filePath,
fileName: clip.projectItem.name || "",
sourceInFrames: srcIn,
sourceOutFrames: srcOut,
timelineInFrames: recIn,
timelineOutFrames:recOut
});
} catch (e) { /* skip malformed clip */ }
}
}
result.success = true;
result.message = result.clips.length + " clip(s) with instance IDs";
return JSON.stringify(result);
} catch (error) {
result.message = "Error reading timeline with IDs: " + error.message;
return JSON.stringify(result);
}
}
/**
* Relinks a specific clip in the Premiere project by finding it via
* clipInstanceId (from timeline export) and swapping its media path.
*
* ExtendScript cannot receive complex objects, so clipInstanceId and newPath
* are passed as separate string arguments.
*
* @param {string} clipInstanceId - The clip instance ID from exportTimelineDataWithIds()
* @param {string} newMediaPath - Absolute path to the replacement media file
* @returns {string} JSON with success flag, message, and relinked count
*/
function relinkClipToNewMedia(clipInstanceId, newMediaPath) {
var result = {
success: false,
message: "",
relinked: 0
};
try {
if (!app.project) {
result.message = "No active project";
return JSON.stringify(result);
}
// Parse clipInstanceId: nodeId_track_frame
var parts = clipInstanceId.split("_");
if (parts.length < 3) {
result.message = "Invalid clipInstanceId format";
return JSON.stringify(result);
}
var targetTrack = parseInt(parts[parts.length - 2], 10);
var targetInFrame = parseInt(parts[parts.length - 1], 10);
var sequence = app.project.activeSequence;
if (!sequence) {
result.message = "No active sequence";
return JSON.stringify(result);
}
var TICKS_PER_SECOND = 254016000000;
var timebaseTicks = parseFloat(sequence.timebase) || 4240384;
// Walk the target track and find matching clip
var videoTracks = sequence.videoTracks;
if (targetTrack < 0 || targetTrack >= videoTracks.numTracks) {
result.message = "Track " + targetTrack + " out of range";
return JSON.stringify(result);
}
var track = videoTracks[targetTrack];
if (!track || !track.clips) {
result.message = "No clips on track " + targetTrack;
return JSON.stringify(result);
}
var matched = false;
for (var c = 0; c < track.clips.numItems; c++) {
try {
var clip = track.clips[c];
if (!clip) continue;
var recIn = Math.round(parseFloat(clip.start.ticks) / timebaseTicks);
if (recIn === targetInFrame) {
var newFile = new File(newMediaPath);
if (!newFile.exists) {
result.message = "New media not found: " + newMediaPath;
return JSON.stringify(result);
}
// Import the new media file into the project
app.project.importFiles([newMediaPath]);
// Replace the clip source with the new project item
var fileName = newFile.displayName;
var rootBin = app.project.rootItem;
var newItem = findProjectItemByName(rootBin, fileName);
if (newItem) {
clip.projectItem = newItem;
matched = true;
result.relinked = 1;
break;
}
}
} catch (e) { /* continue searching */ }
}
if (matched) {
result.success = true;
result.message = "Clip relinked on track " + (targetTrack + 1);
} else {
result.message = "No matching clip found at specified position";
}
return JSON.stringify(result);
} catch (error) {
result.message = "Relink error: " + error.message;
return JSON.stringify(result);
}
}
/**
* FCP XML Export exports the active sequence as Final Cut Pro XML
* using Premiere Pro's native export function.
*
* Note: Premiere Pro ExtendScript does not expose a direct
* exportFCPXML() call in all versions. If the native call is
* unavailable, this function falls back to constructing the XML
* from the timeline data, which the panel JS can then use directly.
*
* @param {string} exportPath - Absolute path to write the .xml file
* @returns {string} JSON with success flag and message
*/
function exportSequenceAsFCPXML(exportPath) {
var result = {
success: false,
message: "",
xmlContent: ""
};
try {
if (!app.project) {
result.message = "No active project";
return JSON.stringify(result);
}
var sequence = app.project.activeSequence;
if (!sequence) {
result.message = "No active sequence";
return JSON.stringify(result);
}
// Try Premiere's built-in FCP XML export first
// Some versions expose exportFCPXML on the project object.
var exported = false;
// Method 1: app.project.exportFCPXML (available in some versions)
if (typeof app.project.exportFCPXML === "function") {
try {
app.project.exportFCPXML(sequence, exportPath);
exported = true;
} catch (e) { /* fall through */ }
}
// Method 2: app.encoder with FCP XML preset
if (!exported && typeof app.encoder !== "undefined" && typeof app.encoder.launchEncoder === "function") {
try {
app.encoder.launchEncoder();
// There is no standard FCP XML AME preset fall through to manual
} catch (e) { /* fall through */ }
}
if (!exported) {
// Fall back to constructing XML from timeline data
var timelineData = JSON.parse(exportTimelineData());
if (!timelineData.success) {
result.message = "Failed to read timeline data";
return JSON.stringify(result);
}
var fps = parseFloat(timelineData.frameRate) || 59.94;
var width = timelineData.width || 1920;
var height = timelineData.height || 1080;
var xml = [];
xml.push('<?xml version="1.0" encoding="UTF-8"?>');
xml.push('<!DOCTYPE xmeml>');
xml.push('<xmeml version="5">');
xml.push(' <sequence>');
xml.push(' <name>' + escapeXmlStr(timelineData.sequenceName || "Untitled") + '</name>');
xml.push(' <duration>' + getSequenceDuration(timelineData.clips, fps) + '</duration>');
xml.push(' <rate>');
xml.push(' <timebase>' + Math.round(fps) + '</timebase>');
xml.push(' <ntsc>' + (Math.abs(fps - 29.97) < 0.02 || Math.abs(fps - 59.94) < 0.02 ? "TRUE" : "FALSE") + '</ntsc>');
xml.push(' </rate>');
xml.push(' <media>');
xml.push(' <video>');
xml.push(' <format>');
xml.push(' <samplecharacteristics>');
xml.push(' <width>' + width + '</width>');
xml.push(' <height>' + height + '</height>');
xml.push(' </samplecharacteristics>');
xml.push(' </format>');
xml.push(' <track>');
// Group clips by track
var tracks = {};
for (var i = 0; i < timelineData.clips.length; i++) {
var cl = timelineData.clips[i];
if (!tracks[cl.trackIndex]) tracks[cl.trackIndex] = [];
tracks[cl.trackIndex].push(cl);
}
var trackKeys = Object.keys(tracks).sort();
for (var tk = 0; tk < trackKeys.length; tk++) {
var tIdx = trackKeys[tk];
var trackClips = tracks[tIdx];
xml.push(' <clipitem id="clip-item-' + tIdx + '">');
xml.push(' <name>Track ' + (parseInt(tIdx) + 1) + '</name>');
for (var ci = 0; ci < trackClips.length; ci++) {
var clipData = trackClips[ci];
xml.push(' <clipitem id="clip-' + tIdx + '-' + ci + '">');
xml.push(' <name>' + escapeXmlStr(clipData.fileName || "Clip " + (ci + 1)) + '</name>');
xml.push(' <duration>' + (clipData.sourceOutFrames - clipData.sourceInFrames) + '</duration>');
xml.push(' <rate>');
xml.push(' <timebase>' + Math.round(fps) + '</timebase>');
xml.push(' </rate>');
xml.push(' <in>' + clipData.sourceInFrames + '</in>');
xml.push(' <out>' + clipData.sourceOutFrames + '</out>');
xml.push(' <start>' + clipData.timelineInFrames + '</start>');
xml.push(' <end>' + clipData.timelineOutFrames + '</end>');
xml.push(' <file>');
xml.push(' <name>' + escapeXmlStr(clipData.filePath || clipData.fileName || "Unknown") + '</name>');
xml.push(' <pathurl>' + escapeXmlStr(clipData.filePath || "") + '</pathurl>');
xml.push(' </file>');
xml.push(' </clipitem>');
}
xml.push(' </clipitem>');
xml.push(' </track>');
}
xml.push(' </video>');
xml.push(' </media>');
xml.push(' </sequence>');
xml.push('</xmeml>');
result.xmlContent = xml.join("\n");
result.success = true;
result.message = "FCP XML constructed from timeline data";
} else {
// Read the exported file
try {
var file = new File(exportPath);
if (file.exists) {
file.open("r");
result.xmlContent = file.read();
file.close();
result.success = true;
result.message = "FCP XML exported via Premiere native function";
} else {
result.message = "Export file not found at " + exportPath;
}
} catch (e) {
result.message = "Failed to read exported file: " + e.message;
}
}
return JSON.stringify(result);
} catch (error) {
result.message = "FCP XML export error: " + error.message;
return JSON.stringify(result);
}
}
/**
* Walks every project item and swaps clip media paths matching `oldPath`
* onto `newPath`. Returns the number of clips relinked.
* This is used by the hi-res auto-relink workflow after downloading
* trimmed segments.
*
* @param {string} oldPath - Current media path to replace
* @param {string} newPath - New media path to use
* @returns {string} JSON with success, relinked count, and message
*/
function replaceMediaPath(oldPath, newPath) {
var result = {
success: false,
relinked: 0,
message: ""
};
try {
if (!app.project) {
result.message = "No active project";
return JSON.stringify(result);
}
var oldFile = new File(newPath);
if (!oldFile.exists) {
result.message = "New media file not found: " + newPath;
return JSON.stringify(result);
}
app.project.importFiles([newPath]);
var newName = oldFile.displayName;
var rootBin = app.project.rootItem;
var newItem = findProjectItemByName(rootBin, newName);
if (!newItem) {
result.message = "Could not find imported media in project";
return JSON.stringify(result);
}
var count = 0;
function walkAndReplace(item) {
if (!item || !item.children) return;
for (var i = 0; i < item.children.numItems; i++) {
var child = item.children[i];
if (!child) continue;
if (child.type === ProjectItemType.BIN) {
walkAndReplace(child);
} else {
try {
var mediaPath = child.getMediaPath();
if (mediaPath === oldPath) {
child.changeMediaPath(newPath);
count++;
}
} catch (e) { /* skip inaccessible items */ }
}
}
}
walkAndReplace(rootBin);
result.success = count > 0;
result.relinked = count;
result.message = count + " clip(s) relinked";
return JSON.stringify(result);
} catch (error) {
result.message = "replaceMediaPath error: " + error.message;
return JSON.stringify(result);
}
}
// ============================================================================
// Internal Helpers (Advanced Features)
// ============================================================================
/**
* Calculates total sequence duration in frames from clip array.
*/
function getSequenceDuration(clips, fps) {
var maxEnd = 0;
for (var i = 0; i < clips.length; i++) {
if (clips[i].timelineOutFrames > maxEnd) {
maxEnd = clips[i].timelineOutFrames;
}
}
return maxEnd || Math.round(fps * 30); // default to 30 sec
}
/**
* Escapes XML special characters in a string.
*/
function escapeXmlStr(str) {
if (typeof str !== "string") return "";
return str
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&apos;");
}
// ============================================================================
// Helper Functions
// ============================================================================

1390
services/worker/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

View file

@ -6,11 +6,12 @@
"start": "node src/index.js"
},
"dependencies": {
"bullmq": "^5.0.0",
"pg": "^8.13.0",
"@aws-sdk/client-s3": "^3.500.0",
"@aws-sdk/lib-storage": "^3.500.0",
"@aws-sdk/s3-request-presigner": "^3.500.0",
"dotenv": "^16.4.0"
"bullmq": "^5.0.0",
"dotenv": "^16.4.0",
"fast-xml-parser": "^5.8.0",
"pg": "^8.13.0"
}
}

View file

@ -153,11 +153,16 @@ export const transcodeImage = async (inputPath, outputPath) => {
};
export const trimSegment = async (inputPath, outputPath, inPoint, outPoint) => {
const info = await getMediaInfo(inputPath);
const fps = info.fps || 30;
const inSeconds = inPoint / fps;
const frameCount = outPoint - inPoint;
const args = [
'-ss', inSeconds.toString(),
'-i', inputPath,
'-ss', inPoint,
'-to', outPoint,
'-c', 'copy',
'-frames:v', frameCount.toString(),
'-start_number', '0',
'-y',
outputPath,
];

View file

@ -4,6 +4,7 @@ import { proxyWorker } from './workers/proxy.js';
import { thumbnailWorker } from './workers/thumbnail.js';
import { conformWorker } from './workers/conform.js';
import { youtubeImportWorker } from './workers/youtube-import.js';
import { trimWorker } from './workers/trimWorker.js';
import { startPromotionWorker } from './workers/promotion.js';
const parseRedisUrl = (url) => {
@ -57,11 +58,13 @@ const createWorker = (queueName, handler, overrides = {}) => {
const PROXY_CONCURRENCY = parseInt(process.env.PROXY_CONCURRENCY || '2', 10);
const THUMBNAIL_CONCURRENCY = parseInt(process.env.THUMBNAIL_CONCURRENCY || '4', 10);
const CONFORM_CONCURRENCY = parseInt(process.env.CONFORM_CONCURRENCY || '1', 10);
const TRIM_CONCURRENCY = parseInt(process.env.TRIM_CONCURRENCY || '4', 10);
const workers = [
createWorker('proxy', proxyWorker, { concurrency: PROXY_CONCURRENCY }),
createWorker('thumbnail', thumbnailWorker, { concurrency: THUMBNAIL_CONCURRENCY }),
createWorker('conform', conformWorker, { concurrency: CONFORM_CONCURRENCY }),
createWorker('trim', trimWorker, { concurrency: TRIM_CONCURRENCY }),
// YouTube imports: keep concurrency at 1 so we don't burn through rate
// limits when several jobs land back-to-back. Lock window is longer than
// the default because a long video download can run for minutes.
@ -72,13 +75,13 @@ const workers = [
}),
];
console.log(`Concurrency: proxy=${PROXY_CONCURRENCY} thumbnail=${THUMBNAIL_CONCURRENCY} conform=${CONFORM_CONCURRENCY} import=1`);
console.log(`Concurrency: proxy=${PROXY_CONCURRENCY} thumbnail=${THUMBNAIL_CONCURRENCY} conform=${CONFORM_CONCURRENCY} trim=${TRIM_CONCURRENCY} import=1`);
startPromotionWorker();
console.log('Wild Dragon Worker Service started');
console.log(`Redis: ${redisOptions.host}:${redisOptions.port}`);
console.log('Active queues: proxy, thumbnail, conform, import');
console.log('Active queues: proxy, thumbnail, conform, trim, import');
console.log('Background scans: promotion (growing-files → S3)');
process.on('SIGTERM', async () => {

View file

@ -3,42 +3,130 @@ import { unlink, writeFile, mkdir, rm } from 'fs/promises';
import { tmpdir } from 'os';
import { query } from '../db/client.js';
import { downloadFromS3, uploadToS3 } from '../s3/client.js';
import { trimSegment, concatSegments } from '../ffmpeg/executor.js';
import { trimSegment, concatSegments, runFFmpeg } from '../ffmpeg/executor.js';
import { parseEDL } from '../edl/parser.js';
import { XMLParser } from 'fast-xml-parser';
const S3_BUCKET = process.env.S3_BUCKET || 'wild-dragon';
const xmlParser = new XMLParser({
ignoreAttributes: false,
attributeNamePrefix: '@_',
});
function parseFcpXml(xmlContent) {
const doc = xmlParser.parse(xmlContent);
const sequence = doc?.xmeml?.sequence;
if (!sequence) throw new Error('Invalid FCP XML: no sequence element');
const name = sequence.name || 'Untitled';
const rate = sequence?.rate?.timebase ? parseInt(sequence.rate.timebase, 10) : 29.97;
const width = parseInt(sequence?.media?.video?.format?.samplecharacteristics?.width || 1920, 10);
const height = parseInt(sequence?.media?.video?.format?.samplecharacteristics?.height || 1080, 10);
const clips = [];
const videoTracks = sequence?.media?.video?.track || [];
const tracks = Array.isArray(videoTracks) ? videoTracks : [videoTracks];
for (const track of tracks) {
const trackNum = parseInt(track?.@_currentExplodedTrackIndex || 0, 10);
const trackItems = track?.clipitem || [];
const items = Array.isArray(trackItems) ? trackItems : [trackItems];
for (const item of items) {
if (!item) continue;
const fileUrl = item?.file?.name || item?.file?.pathurl || '';
const fileName = fileUrl.split('/').pop() || fileUrl.split('\\').pop() || 'unknown';
const srcIn = parseFrame(item?.in?.toString() || '0', rate);
const srcOut = parseFrame(item?.out?.toString() || '0', rate);
const recIn = parseFrame(item?.start?.toString() || '0', rate);
const recOut = parseFrame(item?.end?.toString() || '0', rate);
const duration = parseFrame(item?.duration?.toString() || '0', rate);
if (srcOut <= srcIn || recOut <= recIn) continue;
clips.push({
trackIndex: trackNum,
fileName,
fileUrl,
sourceInFrames: srcIn,
sourceOutFrames: srcOut,
timelineInFrames: recIn,
timelineOutFrames: recOut,
duration,
});
}
}
return { name, frameRate: rate, width, height, clips };
}
function parseFrame(value, fps) {
// FCP XML stores timecode or frame count
const trimmed = value.trim();
// If it's a plain number, return as-is
if (/^\d+$/.test(trimmed)) return parseInt(trimmed, 10);
// HH:MM:SS:FF or HH:MM:SS;FF
const parts = trimmed.split(/[:;]/);
if (parts.length === 4) {
const hh = parseInt(parts[0], 10);
const mm = parseInt(parts[1], 10);
const ss = parseInt(parts[2], 10);
const ff = parseInt(parts[3], 10);
return hh * 3600 * fps + mm * 60 * fps + ss * fps + ff;
}
return 0;
}
export const conformWorker = async (job) => {
const { edl, projectId, outputFormat } = job.data;
const { edl, fcpXml, projectId, sequenceName, frameRate, codec, quality, resolution, audio } = job.data;
const jobId = job.id;
const tmpDir = tmpdir();
const edlPath = join(tmpDir, `edl-${jobId}.edl`);
const segmentsDir = join(tmpDir, `segments-${jobId}`);
const tmpDir = tmpdir();
const segmentsDir = join(tmpDir, `segments-${jobId}`);
const segmentListPath = join(tmpDir, `segments-${jobId}.txt`);
const outputPath = join(tmpDir, `output-${jobId}.${outputFormat || 'mov'}`);
const outputPath = join(tmpDir, `output-${jobId}.mp4`);
try {
// Write EDL to temp file
await writeFile(edlPath, edl, 'utf-8');
let edits = [];
let seqName = sequenceName || 'Conformed';
let seqFps = parseFloat(frameRate) || 29.97;
// Parse EDL
await job.updateProgress(5);
console.log(`[conform] Parsing EDL for job ${jobId}`);
const edits = parseEDL(edl);
// Parse input — accept EDL, FCP XML, or structured JSON
if (edl) {
await job.updateProgress(5);
console.log(`[conform] Parsing EDL for job ${jobId}`);
edits = parseEDL(edl).map((e, i) => ({
editNumber: e.editNumber || i + 1,
reelName: e.reelName,
sourceIn: e.sourceIn,
sourceOut: e.sourceOut,
}));
} else if (fcpXml) {
await job.updateProgress(5);
console.log(`[conform] Parsing FCP XML for job ${jobId}`);
const parsed = parseFcpXml(fcpXml);
seqName = parsed.name || seqName;
seqFps = parsed.frameRate || seqFps;
edits = parsed.clips.map((c, i) => ({
editNumber: i + 1,
reelName: c.fileName,
sourceIn: c.sourceInFrames,
sourceOut: c.sourceOutFrames,
}));
} else {
throw new Error('No input provided — expected edl or fcpXml in job data');
}
// Create temp directory for segment files
await mkdir(segmentsDir, { recursive: true });
let processedEdits = 0;
const concatList = [];
const concatList = [];
for (const edit of edits) {
await job.updateProgress(Math.min(5 + (processedEdits / edits.length) * 50, 55));
console.log(`[conform] Processing edit ${edit.editNumber}: ${edit.reelName}`);
// Look up asset by filename (the reel name in the EDL matches the clip filename)
const assetRes = await query(
'SELECT id, original_s3_key FROM assets WHERE filename = $1 LIMIT 1',
[edit.reelName]
@ -49,57 +137,73 @@ export const conformWorker = async (job) => {
}
const { original_s3_key: sourceKey } = assetRes.rows[0];
const segmentInputPath = join(segmentsDir, `segment-${edit.editNumber}-src`);
const segmentInputPath = join(segmentsDir, `segment-${edit.editNumber}-src`);
const segmentOutputPath = join(segmentsDir, `segment-${edit.editNumber}.mov`);
// Download source clip from S3
console.log(`[conform] Downloading segment ${edit.editNumber} from S3 (${sourceKey})`);
await downloadFromS3(S3_BUCKET, sourceKey, segmentInputPath);
// Trim to EDL in/out points
console.log(`[conform] Trimming ${edit.editNumber}: ${edit.sourceIn}${edit.sourceOut}`);
await trimSegment(segmentInputPath, segmentOutputPath, edit.sourceIn, edit.sourceOut);
concatList.push(segmentOutputPath);
// Remove the (large) source download immediately to conserve disk
await unlink(segmentInputPath).catch(() => {});
processedEdits++;
}
// Write ffmpeg concat file
await job.updateProgress(60);
console.log(`[conform] Writing concat list for ${concatList.length} segments`);
const concatContent = concatList.map(p => `file '${p}'`).join('\n');
await writeFile(segmentListPath, concatContent, 'utf-8');
// Concatenate
await job.updateProgress(70);
console.log(`[conform] Concatenating segments for job ${jobId}`);
await concatSegments(segmentListPath, outputPath);
// Upload to S3
// Use re-encode instead of stream copy for consistent output
const audioFlag = audio === 'include' ? ['-c:a', 'aac'] : ['-an'];
await runFFmpeg([
'-f', 'concat',
'-safe', '0',
'-i', segmentListPath,
'-c:v', codec === 'prores' ? 'prores_ks' : codec === 'h265' ? 'libx265' : 'libx264',
'-preset', quality === 'high' ? 'slow' : quality === 'broadcast' ? 'veryslow' : 'fast',
'-crf', quality === 'broadcast' ? '18' : quality === 'high' ? '23' : '28',
...audioFlag,
'-y', outputPath,
]);
await job.updateProgress(85);
const outputKey = `jobs/${jobId}/output.${outputFormat || 'mov'}`;
const outputKey = `jobs/${jobId}/conformed.mp4`;
console.log(`[conform] Uploading output to ${outputKey}`);
await uploadToS3(S3_BUCKET, outputKey, outputPath);
await job.updateProgress(100);
console.log(`[conform] Job ${jobId} complete → ${outputKey}`);
// Register the conformed output as a new asset
const assetRes = await query(
`INSERT INTO assets (project_id, filename, display_name, media_type, status, original_s3_key, codec, resolution, fps, duration_ms, conform_source_sequence_id)
VALUES ($1, $2, $3, 'video', 'ready', $4, $5, $6, $7, $8, $9) RETURNING id`,
[
projectId || null,
`conformed-${seqName.replace(/[^a-z0-9]/gi, '_')}.mp4`,
`Conformed: ${seqName}`,
outputKey,
codec === 'prores' ? 'prores' : codec === 'h265' ? 'hevc' : 'h264',
resolution !== 'match' ? resolution : '1920x1080',
seqFps,
null,
job.data.sequenceId || null,
]
);
// Return value is stored by BullMQ and visible via GET /api/v1/jobs/:id
return { jobId, outputKey };
await job.updateProgress(100);
console.log(`[conform] Job ${jobId} complete → asset ${assetRes.rows[0].id}`);
return { jobId, outputKey, assetId: assetRes.rows[0].id };
} catch (error) {
console.error(`[conform] Error in job ${jobId}:`, error);
// BullMQ marks the job failed automatically when we throw
throw error;
} finally {
// Best-effort cleanup of all temp files and directories
await Promise.all([
unlink(edlPath).catch(() => {}),
unlink(segmentListPath).catch(() => {}),
unlink(outputPath).catch(() => {}),
rm(segmentsDir, { recursive: true, force: true }).catch(() => {}),

View file

@ -0,0 +1,64 @@
import { join } from 'path';
import { tmpdir } from 'os';
import { unlink } from 'fs/promises';
import { query } from '../db/client.js';
import { downloadFromS3, uploadToS3 } from '../s3/client.js';
import { trimSegment } from '../ffmpeg/executor.js';
const S3_BUCKET = process.env.S3_BUCKET || 'wild-dragon';
export const trimWorker = async (job) => {
const { clipInstanceId, assetId, sourceInFrames, sourceOutFrames } = job.data;
const jobId = job.id;
const tmpDir = tmpdir();
const downloadPath = join(tmpDir, `trim-${jobId}-src`);
const outputPath = join(tmpDir, `trim-${jobId}.mov`);
try {
const assetRes = await query(
'SELECT original_s3_key FROM assets WHERE id = $1 LIMIT 1',
[assetId]
);
if (assetRes.rows.length === 0) {
throw new Error(`Asset not found: ${assetId}`);
}
const { original_s3_key: sourceKey } = assetRes.rows[0];
await job.updateProgress(10);
console.log(`[trim] Downloading asset ${assetId} from S3 (${sourceKey})`);
await downloadFromS3(S3_BUCKET, sourceKey, downloadPath);
await job.updateProgress(40);
console.log(`[trim] Trimming frames ${sourceInFrames}${sourceOutFrames}`);
await trimSegment(downloadPath, outputPath, sourceInFrames, sourceOutFrames);
await job.updateProgress(70);
const s3Key = `temp-segments/${clipInstanceId}.mov`;
console.log(`[trim] Uploading trimmed segment to ${s3Key}`);
await uploadToS3(S3_BUCKET, s3Key, outputPath);
await job.updateProgress(85);
await query(
`INSERT INTO temp_segments (clip_instance_id, s3_key, expires_at)
VALUES ($1, $2, NOW() + INTERVAL '24 hours')`,
[clipInstanceId, s3Key]
);
await job.updateProgress(100);
console.log(`[trim] Job ${jobId} complete for clip ${clipInstanceId}`);
return { clipInstanceId, s3Key };
} catch (error) {
console.error(`[trim] Error in job ${jobId}:`, error);
throw error;
} finally {
await Promise.all([
unlink(downloadPath).catch(() => {}),
unlink(outputPath).catch(() => {}),
]);
}
};