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:
parent
77130ac769
commit
c312991bac
17 changed files with 4585 additions and 627 deletions
|
|
@ -24,6 +24,8 @@ Pro, S3-compatible storage, scheduling, and a queue-driven proxy pipeline.
|
||||||
recorders / jobs / users
|
recorders / jobs / users
|
||||||
- **Jobs** — BullMQ-backed proxy + thumbnail queue with per-job retry,
|
- **Jobs** — BullMQ-backed proxy + thumbnail queue with per-job retry,
|
||||||
bulk "retry all failed", and inline error messages
|
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
|
- **Settings** — S3 (with env-var fallback), global proxy encoder
|
||||||
(CPU/libx264 or GPU/NVENC/VAAPI), growing-files config, capture SDK
|
(CPU/libx264 or GPU/NVENC/VAAPI), growing-files config, capture SDK
|
||||||
uploader (Blackmagic / AJA / Deltacast)
|
uploader (Blackmagic / AJA / Deltacast)
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,10 @@ const thumbnailQueue = new Queue('thumbnail', {
|
||||||
connection: parseRedisUrl(process.env.REDIS_URL || 'redis://queue:6379'),
|
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
|
// GET / - List assets with filtering
|
||||||
router.get('/', async (req, res, next) => {
|
router.get('/', async (req, res, next) => {
|
||||||
try {
|
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;
|
export default router;
|
||||||
|
|
|
||||||
|
|
@ -22,12 +22,14 @@ const proxyQueue = new Queue('proxy', { connection: redisConn });
|
||||||
const thumbnailQueue = new Queue('thumbnail', { connection: redisConn });
|
const thumbnailQueue = new Queue('thumbnail', { connection: redisConn });
|
||||||
const conformQueue = new Queue('conform', { connection: redisConn });
|
const conformQueue = new Queue('conform', { connection: redisConn });
|
||||||
const importQueue = new Queue('import', { connection: redisConn });
|
const importQueue = new Queue('import', { connection: redisConn });
|
||||||
|
const trimQueue = new Queue('trim', { connection: redisConn });
|
||||||
|
|
||||||
const QUEUES = [
|
const QUEUES = [
|
||||||
{ queue: proxyQueue, type: 'proxy' },
|
{ queue: proxyQueue, type: 'proxy' },
|
||||||
{ queue: thumbnailQueue, type: 'thumbnail' },
|
{ queue: thumbnailQueue, type: 'thumbnail' },
|
||||||
{ queue: conformQueue, type: 'conform' },
|
{ queue: conformQueue, type: 'conform' },
|
||||||
{ queue: importQueue, type: 'import' },
|
{ queue: importQueue, type: 'import' },
|
||||||
|
{ queue: trimQueue, type: 'trim' },
|
||||||
];
|
];
|
||||||
|
|
||||||
// BullMQ state → API status mapping
|
// BullMQ state → API status mapping
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,20 @@ import express from 'express';
|
||||||
import pool from '../db/pool.js';
|
import pool from '../db/pool.js';
|
||||||
import { getSignedUrlForObject } from '../s3/client.js';
|
import { getSignedUrlForObject } from '../s3/client.js';
|
||||||
import { requireAuth } from '../middleware/auth.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();
|
const router = express.Router();
|
||||||
router.use(requireAuth);
|
router.use(requireAuth);
|
||||||
|
|
@ -283,4 +297,38 @@ router.post('/:id/export/edl', async (req, res, next) => {
|
||||||
} catch (e) { next(e); }
|
} 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;
|
export default router;
|
||||||
|
|
|
||||||
|
|
@ -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
|
- **Proxy Import**: Download and import proxy files into Premiere Pro projects
|
||||||
- **Batch Import**: Import multiple assets at once
|
- **Batch Import**: Import multiple assets at once
|
||||||
- **Progress Tracking**: Real-time download and import progress indicators
|
- **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
|
## Installation
|
||||||
|
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
177
services/premiere-plugin/docs/ADVANCED_FEATURES.md
Normal file
177
services/premiere-plugin/docs/ADVANCED_FEATURES.md
Normal 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 |
|
||||||
374
services/premiere-plugin/docs/TESTING_ADVANCED_FEATURES.md
Normal file
374
services/premiere-plugin/docs/TESTING_ADVANCED_FEATURES.md
Normal 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
|
||||||
|
|
@ -27,10 +27,10 @@
|
||||||
id="api-token"
|
id="api-token"
|
||||||
class="server-url"
|
class="server-url"
|
||||||
placeholder="API token (wd_…)"
|
placeholder="API token (wd_…)"
|
||||||
title="API token — create with POST /api/v1/tokens"
|
title="API token"
|
||||||
autocomplete="off"
|
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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -52,11 +52,11 @@
|
||||||
</div>
|
</div>
|
||||||
</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">
|
<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>
|
<span id="seq-info-name" class="seq-info-name"></span>
|
||||||
<button id="seq-refresh-btn" class="seq-refresh-btn" title="Refresh active sequence">↻</button>
|
<button id="seq-refresh-btn" class="seq-refresh-btn btn btn-ghost btn-sm" title="Refresh active sequence">↻</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Main Content Area -->
|
<!-- Main Content Area -->
|
||||||
|
|
@ -65,14 +65,24 @@
|
||||||
<div class="asset-grid-container">
|
<div class="asset-grid-container">
|
||||||
<div id="asset-grid" class="asset-grid">
|
<div id="asset-grid" class="asset-grid">
|
||||||
<div id="empty-state" class="empty-state">
|
<div id="empty-state" class="empty-state">
|
||||||
<div class="empty-state-icon">📁</div>
|
<div class="empty-state-icon">
|
||||||
<div>Connect to server and load assets</div>
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Details Panel -->
|
<!-- Details Panel -->
|
||||||
<div id="details-panel" class="details-panel hidden">
|
<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-section">
|
||||||
<div class="details-label">Filename</div>
|
<div class="details-label">Filename</div>
|
||||||
<div id="details-filename" class="details-value truncate-lines-2"></div>
|
<div id="details-filename" class="details-value truncate-lines-2"></div>
|
||||||
|
|
@ -122,12 +132,13 @@
|
||||||
</div>
|
</div>
|
||||||
</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 id="export-panel" class="export-panel hidden">
|
||||||
<div class="export-panel-title">Push Timeline to MAM</div>
|
<div class="export-panel-title">Push Timeline to MAM</div>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
id="export-seq-name"
|
id="export-seq-name"
|
||||||
|
class="input"
|
||||||
placeholder="Sequence name"
|
placeholder="Sequence name"
|
||||||
title="Name this sequence will have in the MAM"
|
title="Name this sequence will have in the MAM"
|
||||||
>
|
>
|
||||||
|
|
@ -136,37 +147,186 @@
|
||||||
</select>
|
</select>
|
||||||
<div id="export-clip-info" class="export-clip-info"></div>
|
<div id="export-clip-info" class="export-clip-info"></div>
|
||||||
<div class="export-panel-actions">
|
<div class="export-panel-actions">
|
||||||
<button id="export-confirm-btn">Push to MAM</button>
|
<button id="export-confirm-btn" class="btn btn-primary">Push to MAM</button>
|
||||||
<button id="export-cancel-btn" class="secondary">Cancel</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 & 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 & Relink All
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Action Bar -->
|
<!-- Action Bar -->
|
||||||
<div class="action-bar">
|
<div class="action-bar">
|
||||||
<!-- Row 1: per-asset import buttons -->
|
|
||||||
<div class="action-row">
|
<div class="action-row">
|
||||||
<button id="import-btn" disabled title="Download proxy and import into Premiere">Import Proxy</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="secondary" disabled title="Download original hi-res and import into Premiere">Hi-Res</button>
|
<button id="import-hires-btn" class="btn btn-secondary" disabled title="Download original hi-res and import into Premiere">Hi-Res</button>
|
||||||
</div>
|
</div>
|
||||||
<!-- Row 2: growing-file actions -->
|
|
||||||
<div class="action-row">
|
<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="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="secondary" disabled title="Relink the imported clip from proxy to the finalized hi-res original">Relink to Hi-Res</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>
|
</div>
|
||||||
<!-- Row 3: bulk + timeline actions -->
|
|
||||||
<div class="action-row">
|
<div class="action-row">
|
||||||
<button id="import-all-btn" class="secondary" title="Import all visible assets as proxy">Import All</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="secondary" title="Push the current Premiere sequence to MAM">Export Timeline ↑</button>
|
<button id="export-timeline-btn" class="btn btn-secondary" title="Push the current Premiere sequence to MAM">Export Timeline ↑</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</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 & 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 & 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 -->
|
<!-- Adobe CSInterface Library -->
|
||||||
<script src="js/CSInterface.js"></script>
|
<script src="js/CSInterface.js"></script>
|
||||||
|
|
||||||
<!-- Main Panel 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>
|
<script src="js/main.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -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, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, """)
|
||||||
|
.replace(/'/g, "'");
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Helper Functions
|
// Helper Functions
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
|
||||||
1390
services/worker/package-lock.json
generated
Normal file
1390
services/worker/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -6,11 +6,12 @@
|
||||||
"start": "node src/index.js"
|
"start": "node src/index.js"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"bullmq": "^5.0.0",
|
|
||||||
"pg": "^8.13.0",
|
|
||||||
"@aws-sdk/client-s3": "^3.500.0",
|
"@aws-sdk/client-s3": "^3.500.0",
|
||||||
"@aws-sdk/lib-storage": "^3.500.0",
|
"@aws-sdk/lib-storage": "^3.500.0",
|
||||||
"@aws-sdk/s3-request-presigner": "^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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -153,11 +153,16 @@ export const transcodeImage = async (inputPath, outputPath) => {
|
||||||
};
|
};
|
||||||
|
|
||||||
export const trimSegment = async (inputPath, outputPath, inPoint, outPoint) => {
|
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 = [
|
const args = [
|
||||||
|
'-ss', inSeconds.toString(),
|
||||||
'-i', inputPath,
|
'-i', inputPath,
|
||||||
'-ss', inPoint,
|
'-frames:v', frameCount.toString(),
|
||||||
'-to', outPoint,
|
'-start_number', '0',
|
||||||
'-c', 'copy',
|
|
||||||
'-y',
|
'-y',
|
||||||
outputPath,
|
outputPath,
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import { proxyWorker } from './workers/proxy.js';
|
||||||
import { thumbnailWorker } from './workers/thumbnail.js';
|
import { thumbnailWorker } from './workers/thumbnail.js';
|
||||||
import { conformWorker } from './workers/conform.js';
|
import { conformWorker } from './workers/conform.js';
|
||||||
import { youtubeImportWorker } from './workers/youtube-import.js';
|
import { youtubeImportWorker } from './workers/youtube-import.js';
|
||||||
|
import { trimWorker } from './workers/trimWorker.js';
|
||||||
import { startPromotionWorker } from './workers/promotion.js';
|
import { startPromotionWorker } from './workers/promotion.js';
|
||||||
|
|
||||||
const parseRedisUrl = (url) => {
|
const parseRedisUrl = (url) => {
|
||||||
|
|
@ -57,11 +58,13 @@ const createWorker = (queueName, handler, overrides = {}) => {
|
||||||
const PROXY_CONCURRENCY = parseInt(process.env.PROXY_CONCURRENCY || '2', 10);
|
const PROXY_CONCURRENCY = parseInt(process.env.PROXY_CONCURRENCY || '2', 10);
|
||||||
const THUMBNAIL_CONCURRENCY = parseInt(process.env.THUMBNAIL_CONCURRENCY || '4', 10);
|
const THUMBNAIL_CONCURRENCY = parseInt(process.env.THUMBNAIL_CONCURRENCY || '4', 10);
|
||||||
const CONFORM_CONCURRENCY = parseInt(process.env.CONFORM_CONCURRENCY || '1', 10);
|
const CONFORM_CONCURRENCY = parseInt(process.env.CONFORM_CONCURRENCY || '1', 10);
|
||||||
|
const TRIM_CONCURRENCY = parseInt(process.env.TRIM_CONCURRENCY || '4', 10);
|
||||||
|
|
||||||
const workers = [
|
const workers = [
|
||||||
createWorker('proxy', proxyWorker, { concurrency: PROXY_CONCURRENCY }),
|
createWorker('proxy', proxyWorker, { concurrency: PROXY_CONCURRENCY }),
|
||||||
createWorker('thumbnail', thumbnailWorker, { concurrency: THUMBNAIL_CONCURRENCY }),
|
createWorker('thumbnail', thumbnailWorker, { concurrency: THUMBNAIL_CONCURRENCY }),
|
||||||
createWorker('conform', conformWorker, { concurrency: CONFORM_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
|
// 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
|
// limits when several jobs land back-to-back. Lock window is longer than
|
||||||
// the default because a long video download can run for minutes.
|
// 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();
|
startPromotionWorker();
|
||||||
|
|
||||||
console.log('Wild Dragon Worker Service started');
|
console.log('Wild Dragon Worker Service started');
|
||||||
console.log(`Redis: ${redisOptions.host}:${redisOptions.port}`);
|
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)');
|
console.log('Background scans: promotion (growing-files → S3)');
|
||||||
|
|
||||||
process.on('SIGTERM', async () => {
|
process.on('SIGTERM', async () => {
|
||||||
|
|
|
||||||
|
|
@ -3,42 +3,130 @@ import { unlink, writeFile, mkdir, rm } from 'fs/promises';
|
||||||
import { tmpdir } from 'os';
|
import { tmpdir } from 'os';
|
||||||
import { query } from '../db/client.js';
|
import { query } from '../db/client.js';
|
||||||
import { downloadFromS3, uploadToS3 } from '../s3/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 { parseEDL } from '../edl/parser.js';
|
||||||
|
import { XMLParser } from 'fast-xml-parser';
|
||||||
|
|
||||||
const S3_BUCKET = process.env.S3_BUCKET || 'wild-dragon';
|
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) => {
|
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 jobId = job.id;
|
||||||
|
|
||||||
const tmpDir = tmpdir();
|
const tmpDir = tmpdir();
|
||||||
const edlPath = join(tmpDir, `edl-${jobId}.edl`);
|
const segmentsDir = join(tmpDir, `segments-${jobId}`);
|
||||||
const segmentsDir = join(tmpDir, `segments-${jobId}`);
|
|
||||||
const segmentListPath = join(tmpDir, `segments-${jobId}.txt`);
|
const segmentListPath = join(tmpDir, `segments-${jobId}.txt`);
|
||||||
const outputPath = join(tmpDir, `output-${jobId}.${outputFormat || 'mov'}`);
|
const outputPath = join(tmpDir, `output-${jobId}.mp4`);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Write EDL to temp file
|
let edits = [];
|
||||||
await writeFile(edlPath, edl, 'utf-8');
|
let seqName = sequenceName || 'Conformed';
|
||||||
|
let seqFps = parseFloat(frameRate) || 29.97;
|
||||||
|
|
||||||
// Parse EDL
|
// Parse input — accept EDL, FCP XML, or structured JSON
|
||||||
await job.updateProgress(5);
|
if (edl) {
|
||||||
console.log(`[conform] Parsing EDL for job ${jobId}`);
|
await job.updateProgress(5);
|
||||||
const edits = parseEDL(edl);
|
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 });
|
await mkdir(segmentsDir, { recursive: true });
|
||||||
|
|
||||||
let processedEdits = 0;
|
let processedEdits = 0;
|
||||||
const concatList = [];
|
const concatList = [];
|
||||||
|
|
||||||
for (const edit of edits) {
|
for (const edit of edits) {
|
||||||
await job.updateProgress(Math.min(5 + (processedEdits / edits.length) * 50, 55));
|
await job.updateProgress(Math.min(5 + (processedEdits / edits.length) * 50, 55));
|
||||||
|
|
||||||
console.log(`[conform] Processing edit ${edit.editNumber}: ${edit.reelName}`);
|
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(
|
const assetRes = await query(
|
||||||
'SELECT id, original_s3_key FROM assets WHERE filename = $1 LIMIT 1',
|
'SELECT id, original_s3_key FROM assets WHERE filename = $1 LIMIT 1',
|
||||||
[edit.reelName]
|
[edit.reelName]
|
||||||
|
|
@ -49,57 +137,73 @@ export const conformWorker = async (job) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
const { original_s3_key: sourceKey } = assetRes.rows[0];
|
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`);
|
const segmentOutputPath = join(segmentsDir, `segment-${edit.editNumber}.mov`);
|
||||||
|
|
||||||
// Download source clip from S3
|
|
||||||
console.log(`[conform] Downloading segment ${edit.editNumber} from S3 (${sourceKey})`);
|
console.log(`[conform] Downloading segment ${edit.editNumber} from S3 (${sourceKey})`);
|
||||||
await downloadFromS3(S3_BUCKET, sourceKey, segmentInputPath);
|
await downloadFromS3(S3_BUCKET, sourceKey, segmentInputPath);
|
||||||
|
|
||||||
// Trim to EDL in/out points
|
|
||||||
console.log(`[conform] Trimming ${edit.editNumber}: ${edit.sourceIn} → ${edit.sourceOut}`);
|
console.log(`[conform] Trimming ${edit.editNumber}: ${edit.sourceIn} → ${edit.sourceOut}`);
|
||||||
await trimSegment(segmentInputPath, segmentOutputPath, edit.sourceIn, edit.sourceOut);
|
await trimSegment(segmentInputPath, segmentOutputPath, edit.sourceIn, edit.sourceOut);
|
||||||
|
|
||||||
concatList.push(segmentOutputPath);
|
concatList.push(segmentOutputPath);
|
||||||
|
|
||||||
// Remove the (large) source download immediately to conserve disk
|
|
||||||
await unlink(segmentInputPath).catch(() => {});
|
await unlink(segmentInputPath).catch(() => {});
|
||||||
|
|
||||||
processedEdits++;
|
processedEdits++;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Write ffmpeg concat file
|
|
||||||
await job.updateProgress(60);
|
await job.updateProgress(60);
|
||||||
console.log(`[conform] Writing concat list for ${concatList.length} segments`);
|
console.log(`[conform] Writing concat list for ${concatList.length} segments`);
|
||||||
const concatContent = concatList.map(p => `file '${p}'`).join('\n');
|
const concatContent = concatList.map(p => `file '${p}'`).join('\n');
|
||||||
await writeFile(segmentListPath, concatContent, 'utf-8');
|
await writeFile(segmentListPath, concatContent, 'utf-8');
|
||||||
|
|
||||||
// Concatenate
|
|
||||||
await job.updateProgress(70);
|
await job.updateProgress(70);
|
||||||
console.log(`[conform] Concatenating segments for job ${jobId}`);
|
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);
|
await job.updateProgress(85);
|
||||||
const outputKey = `jobs/${jobId}/output.${outputFormat || 'mov'}`;
|
const outputKey = `jobs/${jobId}/conformed.mp4`;
|
||||||
console.log(`[conform] Uploading output to ${outputKey}`);
|
console.log(`[conform] Uploading output to ${outputKey}`);
|
||||||
await uploadToS3(S3_BUCKET, outputKey, outputPath);
|
await uploadToS3(S3_BUCKET, outputKey, outputPath);
|
||||||
|
|
||||||
await job.updateProgress(100);
|
// Register the conformed output as a new asset
|
||||||
console.log(`[conform] Job ${jobId} complete → ${outputKey}`);
|
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
|
await job.updateProgress(100);
|
||||||
return { jobId, outputKey };
|
console.log(`[conform] Job ${jobId} complete → asset ${assetRes.rows[0].id}`);
|
||||||
|
|
||||||
|
return { jobId, outputKey, assetId: assetRes.rows[0].id };
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`[conform] Error in job ${jobId}:`, error);
|
console.error(`[conform] Error in job ${jobId}:`, error);
|
||||||
// BullMQ marks the job failed automatically when we throw
|
|
||||||
throw error;
|
throw error;
|
||||||
|
|
||||||
} finally {
|
} finally {
|
||||||
// Best-effort cleanup of all temp files and directories
|
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
unlink(edlPath).catch(() => {}),
|
|
||||||
unlink(segmentListPath).catch(() => {}),
|
unlink(segmentListPath).catch(() => {}),
|
||||||
unlink(outputPath).catch(() => {}),
|
unlink(outputPath).catch(() => {}),
|
||||||
rm(segmentsDir, { recursive: true, force: true }).catch(() => {}),
|
rm(segmentsDir, { recursive: true, force: true }).catch(() => {}),
|
||||||
|
|
|
||||||
64
services/worker/src/workers/trimWorker.js
Normal file
64
services/worker/src/workers/trimWorker.js
Normal 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(() => {}),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
};
|
||||||
Loading…
Reference in a new issue