1	import express from 'express';
2	import pool from '../db/pool.js';
3	import { requireAuth } from '../middleware/auth.js';
4	import { getAmppConfig } from '../ampp/client.js';
5	import { rebuildS3Client, buildTestClient, testS3Connection, getS3Bucket } from '../s3/client.js';
6
7	const router = express.Router();
8	router.use(requireAuth);
9
10	// ── S3 / Object Storage ───────────────────────────────────────────────────────
11
12	router.get('/s3', async (req, res, next) => {
13	  try {
14	    const result = await pool.query(
15	      `SELECT key, value FROM settings
16	       WHERE key IN ('s3_endpoint','s3_bucket','s3_access_key','s3_secret_key','s3_region')`
17	    );
18	    const out = {
19	      s3_endpoint:   '',
20	      s3_bucket:     getS3Bucket(),
21	      s3_access_key: '',
22	      s3_secret_key_exists: false,
23	      s3_region:     'us-east-1',
24	    };
25	    for (const { key, value } of result.rows) {
26	      if (key === 's3_secret_key') {
27	        out.s3_secret_key_exists = !!value;
28	      } else {
29	        out[key] = value || '';
30	      }
31	    }
32	    res.json(out);
33	  } catch (err) {
34	    next(err);
35	  }
36	});
37
38	router.put('/s3', async (req, res, next) => {
39	  try {
40	    const { s3_endpoint, s3_bucket, s3_access_key, s3_secret_key, s3_region } = req.body;
41
42	    if (!s3_endpoint) return res.status(400).json({ error: 's3_endpoint is required' });
43	    if (!s3_bucket)   return res.status(400).json({ error: 's3_bucket is required' });
44
45	    const keys = { s3_endpoint, s3_bucket, s3_region: s3_region || 'us-east-1' };
46	    if (s3_access_key) keys.s3_access_key = s3_access_key;
47	    if (s3_secret_key) keys.s3_secret_key = s3_secret_key;
48
49	    for (const [key, value] of Object.entries(keys)) {
50	      await pool.query(
51	        `INSERT INTO settings (key, value, updated_at) VALUES ($1, $2, NOW())
52	         ON CONFLICT (key) DO UPDATE SET value = $2, updated_at = NOW()`,
53	        [key, value.trim()]
54	      );
55	    }
56
57	    // Fetch the full current config (including any previously saved secret)
58	    const saved = await pool.query(
59	      `SELECT key, value FROM settings
60	       WHERE key IN ('s3_endpoint','s3_bucket','s3_access_key','s3_secret_key','s3_region')
61	         AND value IS NOT NULL AND value <> ''`
62	    );
63	    const cfg = {};
64	    for (const { key, value } of saved.rows) {
65	      switch (key) {
66	        case 's3_endpoint':   cfg.endpoint  = value; break;
67	        case 's3_bucket':     cfg.bucket    = value; break;
68	        case 's3_access_key': cfg.accessKey = value; break;
69	        case 's3_secret_key': cfg.secretKey = value; break;
70	        case 's3_region':     cfg.region    = value; break;
71	      }
72	    }
73	    rebuildS3Client(cfg);
74
75	    res.json({ message: 'S3 settings saved and applied' });
76	  } catch (err) {
77	    next(err);
78	  }
79	});
80
81	// Test with the values the browser just typed (before saving), or with saved
82	// creds if the fields are left blank. Secret is sent only if user typed it.
83	router.post('/s3/test', async (req, res, next) => {
84	  try {
85	    const { s3_endpoint, s3_bucket, s3_access_key, s3_secret_key, s3_region } = req.body;
86
87	    // Merge submitted values with anything already saved in the DB
88	    const saved = await pool.query(
89	      `SELECT key, value FROM settings
90	       WHERE key IN ('s3_endpoint','s3_bucket','s3_access_key','s3_secret_key','s3_region')
91	         AND value IS NOT NULL AND value <> ''`
92	    );
93	    const fromDb = {};
94	    for (const { key, value } of saved.rows) fromDb[key] = value;
95
96	    const cfg = {
97	      endpoint:  (s3_endpoint  || fromDb.s3_endpoint  || '').trim(),
98	      bucket:    (s3_bucket    || fromDb.s3_bucket    || getS3Bucket()).trim(),
99	      accessKey: (s3_access_key || fromDb.s3_access_key || '').trim(),
100	      secretKey: (s3_secret_key || fromDb.s3_secret_key || '').trim(),
101	      region:    (s3_region    || fromDb.s3_region    || 'us-east-1').trim(),
102	    };
103
104	    if (!cfg.endpoint) return res.status(400).json({ error: 'S3 endpoint is required' });
105	    if (!cfg.bucket)   return res.status(400).json({ error: 'S3 bucket is required' });
106
107	    const client = buildTestClient(cfg);
108	    const result = await testS3Connection(client, cfg.bucket);
109	    res.json(result);
110	  } catch (err) {
111	    res.status(400).json({ ok: false, error: err.message });
112	  }
113	});
114
115	// ── AMPP integration ───────────────────────────────────────────────────────────
116
117	router.get('/ampp', async (req, res, next) => {
118	  try {
119	    const result = await pool.query(
120	      "SELECT key, value FROM settings WHERE key IN ('ampp_base_url', 'ampp_token')"
121	    );
122	    const out = {};
123	    for (const row of result.rows) {
124	      if (row.key === 'ampp_token') {
125	        out.ampp_token_exists = true;
126	      } else {
127	        out[row.key] = row.value;
128	      }
129	    }
130	    res.json(out);
131	  } catch (err) {
132	    next(err);
133	  }
134	});
135
136	router.put('/ampp', async (req, res, next) => {
137	  try {
138	    const { ampp_base_url, ampp_token } = req.body;
139	    if (!ampp_base_url) return res.status(400).json({ error: 'ampp_base_url is required' });
140	    const baseUrl = ampp_base_url.trim().replace(/\/$/, '');
141	    await pool.query(
142	      `INSERT INTO settings (key, value, updated_at) VALUES ('ampp_base_url', $1, NOW())
143	       ON CONFLICT (key) DO UPDATE SET value = $1, updated_at = NOW()`,
144	      [baseUrl]
145	    );
146	    if (ampp_token) {
147	      await pool.query(
148	        `INSERT INTO settings (key, value, updated_at) VALUES ('ampp_token', $1, NOW())
149	         ON CONFLICT (key) DO UPDATE SET value = $1, updated_at = NOW()`,
150	        [ampp_token.trim()]
151	      );
152	    }
153	    res.json({ message: 'AMPP settings saved' });
154	  } catch (err) {
155	    next(err);
156	  }
157	});
158
159	router.post('/ampp/test', async (req, res, next) => {
160	  try {
161	    const config = await getAmppConfig();
162	    if (!config) return res.status(400).json({ error: 'AMPP credentials not configured' });
163	    const testUrl = `${config.ampp_base_url}/api/v1/store/folder/folders?limit=1`;
164	    const testRes = await fetch(testUrl, {
165	      headers: { Authorization: `Bearer ${config.ampp_token}`, 'Content-Type': 'application/json' },
166	    });
167	    if (!testRes.ok) return res.status(400).json({ error: `AMPP returned HTTP ${testRes.status}` });
168	    res.json({ message: 'AMPP connection successful' });
169	  } catch (err) {
170	    res.status(400).json({ error: `Connection failed: ${err.message}` });
171	  }
172	});
173
174	// ── Hardware inventory ────────────────────────────────────────────────────────
175
176	router.get('/hardware', async (req, res, next) => {
177	  try {
178	    const result = await pool.query(
179	      `SELECT id, hostname, role, ip_address, capabilities,
180	              EXTRACT(EPOCH FROM (NOW() - last_seen))::int AS stale_seconds
181	       FROM cluster_nodes
182	       ORDER BY role, hostname`
183	    );
184	    const nodes = result.rows.map(n => ({
185	      id:           n.id,
186	      hostname:     n.hostname,
187	      role:         n.role,
188	      ip_address:   n.ip_address,
189	      online:       n.stale_seconds < 120,
190	      capabilities: n.capabilities || {},
191	    }));
192	    res.json({ nodes });
193	  } catch (err) {
194	    next(err);
195	  }
196	});
197
198	// ── GPU / Transcoding ─────────────────────────────────────────────────────────
199
200	router.get('/transcoding', async (req, res, next) => {
201	  try {
202	    const result = await pool.query(
203	      `SELECT key, value FROM settings
204	       WHERE key IN ('gpu_transcode_enabled','gpu_codec','gpu_preset','gpu_bitrate_mbps','gpu_node',
205	                     'gpu_extension','gpu_framerate','gpu_rc_mode','gpu_audio_codec','gpu_audio_bitrate_kbps')`
206	    );
207	    const out = {
208	      gpu_transcode_enabled:  'false',
209	      gpu_codec:              'h264_nvenc',
210	      gpu_preset:             'p4',
211	      gpu_bitrate_mbps:       '8',
212	      gpu_node:               '',
213	      gpu_extension:          'mp4',
214	      gpu_framerate:          'passthrough',
215	      gpu_rc_mode:            'cbr',
216	      gpu_audio_codec:        'aac',
217	      gpu_audio_bitrate_kbps: '192',
218	    };
219	    for (const { key, value } of result.rows) out[key] = value;
220	    res.json(out);
221	  } catch (err) {
222	    next(err);
223	  }
224	});
225
226	router.put('/transcoding', async (req, res, next) => {
227	  try {
228	    const allowed = [
229	      'gpu_transcode_enabled', 'gpu_codec', 'gpu_preset', 'gpu_bitrate_mbps', 'gpu_node',
230	      'gpu_extension', 'gpu_framerate', 'gpu_rc_mode', 'gpu_audio_codec', 'gpu_audio_bitrate_kbps',
231	    ];
232	    for (const key of allowed) {
233	      if (req.body[key] !== undefined) {
234	        await pool.query(
235	          `INSERT INTO settings (key, value, updated_at) VALUES ($1, $2, NOW())
236	           ON CONFLICT (key) DO UPDATE SET value = $2, updated_at = NOW()`,
237	          [key, String(req.body[key])]
238	        );
239	      }
240	    }
241	    res.json({ message: 'Transcoding settings saved' });
242	  } catch (err) {
243	    next(err);
244	  }
245	});
246
247	// ── Capture service routing ───────────────────────────────────────────────────
248
249	router.get('/capture-service', async (req, res, next) => {
250	  try {
251	    const result = await pool.query(
252	      "SELECT value FROM settings WHERE key = 'capture_service_url'"
253	    );
254	    res.json({ capture_service_url: result.rows[0]?.value || '' });
255	  } catch (err) {
256	    next(err);
257	  }
258	});
259
260	router.put('/capture-service', async (req, res, next) => {
261	  try {
262	    const url = (req.body.capture_service_url || '').trim().replace(/\/$/, '');
263	    await pool.query(
264	      `INSERT INTO settings (key, value, updated_at) VALUES ('capture_service_url', $1, NOW())
265	       ON CONFLICT (key) DO UPDATE SET value = $1, updated_at = NOW()`,
266	      [url]
267	    );
268	    res.json({ message: 'Capture service URL saved' });
269	  } catch (err) {
270	    next(err);
271	  }
272	});
273
274	export default router;
275

import express from 'express';
import pool from '../db/pool.js';
import { requireAuth } from '../middleware/auth.js';
import { getAmppConfig } from '../ampp/client.js';
import { rebuildS3Client, buildTestClient, testS3Connection, getS3Bucket } from '../s3/client.js';

const router = express.Router();
router.use(requireAuth);

// ── S3 / Object Storage ───────────────────────────────────────────────────────

router.get('/s3', async (req, res, next) => {
  try {
    const result = await pool.query(
      `SELECT key, value FROM settings
       WHERE key IN ('s3_endpoint','s3_bucket','s3_access_key','s3_secret_key','s3_region')`
    );
    const out = {
      s3_endpoint:   '',
      s3_bucket:     getS3Bucket(),
      s3_access_key: '',
      s3_secret_key_exists: false,
      s3_region:     'us-east-1',
    };
    for (const { key, value } of result.rows) {
      if (key === 's3_secret_key') {
        out.s3_secret_key_exists = !!value;
      } else {
        out[key] = value || '';
      }
    }
    res.json(out);
  } catch (err) {
    next(err);
  }
});

router.put('/s3', async (req, res, next) => {
  try {
    const { s3_endpoint, s3_bucket, s3_access_key, s3_secret_key, s3_region } = req.body;

    if (!s3_endpoint) return res.status(400).json({ error: 's3_endpoint is required' });
    if (!s3_bucket)   return res.status(400).json({ error: 's3_bucket is required' });

    const keys = { s3_endpoint, s3_bucket, s3_region: s3_region || 'us-east-1' };
    if (s3_access_key) keys.s3_access_key = s3_access_key;
    if (s3_secret_key) keys.s3_secret_key = s3_secret_key;

    for (const [key, value] of Object.entries(keys)) {
      await pool.query(
        `INSERT INTO settings (key, value, updated_at) VALUES ($1, $2, NOW())
         ON CONFLICT (key) DO UPDATE SET value = $2, updated_at = NOW()`,
        [key, value.trim()]
      );
    }

    // Fetch the full current config (including any previously saved secret)
    const saved = await pool.query(
      `SELECT key, value FROM settings
       WHERE key IN ('s3_endpoint','s3_bucket','s3_access_key','s3_secret_key','s3_region')
         AND value IS NOT NULL AND value <> ''`
    );
    const cfg = {};
    for (const { key, value } of saved.rows) {
      switch (key) {
        case 's3_endpoint':   cfg.endpoint  = value; break;
        case 's3_bucket':     cfg.bucket    = value; break;
        case 's3_access_key': cfg.accessKey = value; break;
        case 's3_secret_key': cfg.secretKey = value; break;
        case 's3_region':     cfg.region    = value; break;
      }
    }
    rebuildS3Client(cfg);

    res.json({ message: 'S3 settings saved and applied' });
  } catch (err) {
    next(err);
  }
});

// Test with the values the browser just typed (before saving), or with saved
// creds if the fields are left blank. Secret is sent only if user typed it.
router.post('/s3/test', async (req, res, next) => {
  try {
    const { s3_endpoint, s3_bucket, s3_access_key, s3_secret_key, s3_region } = req.body;

    // Merge submitted values with anything already saved in the DB
    const saved = await pool.query(
      `SELECT key, value FROM settings
       WHERE key IN ('s3_endpoint','s3_bucket','s3_access_key','s3_secret_key','s3_region')
         AND value IS NOT NULL AND value <> ''`
    );
    const fromDb = {};
    for (const { key, value } of saved.rows) fromDb[key] = value;

    const cfg = {
      endpoint:  (s3_endpoint  || fromDb.s3_endpoint  || '').trim(),
      bucket:    (s3_bucket    || fromDb.s3_bucket    || getS3Bucket()).trim(),
      accessKey: (s3_access_key || fromDb.s3_access_key || '').trim(),
      secretKey: (s3_secret_key || fromDb.s3_secret_key || '').trim(),
      region:    (s3_region    || fromDb.s3_region    || 'us-east-1').trim(),
    };

    if (!cfg.endpoint) return res.status(400).json({ error: 'S3 endpoint is required' });
    if (!cfg.bucket)   return res.status(400).json({ error: 'S3 bucket is required' });

    const client = buildTestClient(cfg);
    const result = await testS3Connection(client, cfg.bucket);
    res.json(result);
  } catch (err) {
    res.status(400).json({ ok: false, error: err.message });
  }
});

// ── AMPP integration ───────────────────────────────────────────────────────────

router.get('/ampp', async (req, res, next) => {
  try {
    const result = await pool.query(
      "SELECT key, value FROM settings WHERE key IN ('ampp_base_url', 'ampp_token')"
    );
    const out = {};
    for (const row of result.rows) {
      if (row.key === 'ampp_token') {
        out.ampp_token_exists = true;
      } else {
        out[row.key] = row.value;
      }
    }
    res.json(out);
  } catch (err) {
    next(err);
  }
});

router.put('/ampp', async (req, res, next) => {
  try {
    const { ampp_base_url, ampp_token } = req.body;
    if (!ampp_base_url) return res.status(400).json({ error: 'ampp_base_url is required' });
    const baseUrl = ampp_base_url.trim().replace(/\/$/, '');
    await pool.query(
      `INSERT INTO settings (key, value, updated_at) VALUES ('ampp_base_url', $1, NOW())
       ON CONFLICT (key) DO UPDATE SET value = $1, updated_at = NOW()`,
      [baseUrl]
    );
    if (ampp_token) {
      await pool.query(
        `INSERT INTO settings (key, value, updated_at) VALUES ('ampp_token', $1, NOW())
         ON CONFLICT (key) DO UPDATE SET value = $1, updated_at = NOW()`,
        [ampp_token.trim()]
      );
    }
    res.json({ message: 'AMPP settings saved' });
  } catch (err) {
    next(err);
  }
});

router.post('/ampp/test', async (req, res, next) => {
  try {
    const config = await getAmppConfig();
    if (!config) return res.status(400).json({ error: 'AMPP credentials not configured' });
    const testUrl = `${config.ampp_base_url}/api/v1/store/folder/folders?limit=1`;
    const testRes = await fetch(testUrl, {
      headers: { Authorization: `Bearer ${config.ampp_token}`, 'Content-Type': 'application/json' },
    });
    if (!testRes.ok) return res.status(400).json({ error: `AMPP returned HTTP ${testRes.status}` });
    res.json({ message: 'AMPP connection successful' });
  } catch (err) {
    res.status(400).json({ error: `Connection failed: ${err.message}` });
  }
});

// ── Hardware inventory ────────────────────────────────────────────────────────

router.get('/hardware', async (req, res, next) => {
  try {
    const result = await pool.query(
      `SELECT id, hostname, role, ip_address, capabilities,
              EXTRACT(EPOCH FROM (NOW() - last_seen))::int AS stale_seconds
       FROM cluster_nodes
       ORDER BY role, hostname`
    );
    const nodes = result.rows.map(n => ({
      id:           n.id,
      hostname:     n.hostname,
      role:         n.role,
      ip_address:   n.ip_address,
      online:       n.stale_seconds < 120,
      capabilities: n.capabilities || {},
    }));
    res.json({ nodes });
  } catch (err) {
    next(err);
  }
});

// ── GPU / Transcoding ─────────────────────────────────────────────────────────

router.get('/transcoding', async (req, res, next) => {
  try {
    const result = await pool.query(
      `SELECT key, value FROM settings
       WHERE key IN ('gpu_transcode_enabled','gpu_codec','gpu_preset','gpu_bitrate_mbps','gpu_node',
                     'gpu_extension','gpu_framerate','gpu_rc_mode','gpu_audio_codec','gpu_audio_bitrate_kbps')`
    );
    const out = {
      gpu_transcode_enabled:  'false',
      gpu_codec:              'h264_nvenc',
      gpu_preset:             'p4',
      gpu_bitrate_mbps:       '8',
      gpu_node:               '',
      gpu_extension:          'mp4',
      gpu_framerate:          'passthrough',
      gpu_rc_mode:            'cbr',
      gpu_audio_codec:        'aac',
      gpu_audio_bitrate_kbps: '192',
    };
    for (const { key, value } of result.rows) out[key] = value;
    res.json(out);
  } catch (err) {
    next(err);
  }
});

router.put('/transcoding', async (req, res, next) => {
  try {
    const allowed = [
      'gpu_transcode_enabled', 'gpu_codec', 'gpu_preset', 'gpu_bitrate_mbps', 'gpu_node',
      'gpu_extension', 'gpu_framerate', 'gpu_rc_mode', 'gpu_audio_codec', 'gpu_audio_bitrate_kbps',
    ];
    for (const key of allowed) {
      if (req.body[key] !== undefined) {
        await pool.query(
          `INSERT INTO settings (key, value, updated_at) VALUES ($1, $2, NOW())
           ON CONFLICT (key) DO UPDATE SET value = $2, updated_at = NOW()`,
          [key, String(req.body[key])]
        );
      }
    }
    res.json({ message: 'Transcoding settings saved' });
  } catch (err) {
    next(err);
  }
});

// ── Capture service routing ───────────────────────────────────────────────────

router.get('/capture-service', async (req, res, next) => {
  try {
    const result = await pool.query(
      "SELECT value FROM settings WHERE key = 'capture_service_url'"
    );
    res.json({ capture_service_url: result.rows[0]?.value || '' });
  } catch (err) {
    next(err);
  }
});

router.put('/capture-service', async (req, res, next) => {
  try {
    const url = (req.body.capture_service_url || '').trim().replace(/\/$/, '');
    await pool.query(
      `INSERT INTO settings (key, value, updated_at) VALUES ('capture_service_url', $1, NOW())
       ON CONFLICT (key) DO UPDATE SET value = $1, updated_at = NOW()`,
      [url]
    );
    res.json({ message: 'Capture service URL saved' });
  } catch (err) {
    next(err);
  }
});

export default router;


339:// GET /:id/stream
340:router.get('/:id/stream', async (req, res, next) => {
346:    if (a.status === 'live') return res.json({ url: `/live/${a.id}/index.m3u8`, type: 'hls', live: true });


320	      const asset = assetResult.rows[0];
321	      if (asset.proxy_s3_key)     await deleteObject(asset.proxy_s3_key);
322	      if (asset.thumbnail_s3_key) await deleteObject(asset.thumbnail_s3_key);
323	      if (asset.original_s3_key)  await deleteObject(asset.original_s3_key);
324	      await pool.query('DELETE FROM assets WHERE id = $1', [id]);
325	      res.json({ message: 'Asset deleted permanently' });
326	    } else {
327	      const result = await pool.query(
328	        `UPDATE assets SET status = 'archived', updated_at = NOW() WHERE id = $1 RETURNING *`,
329	        [id]
330	      );
331	      if (result.rows.length === 0) return res.status(404).json({ error: 'Asset not found' });
332	      res.json(result.rows[0]);
333	    }
334	  } catch (err) {
335	    next(err);
336	  }
337	});
338
339	// GET /:id/stream
340	router.get('/:id/stream', async (req, res, next) => {
341	  try {
342	    const { id } = req.params;
343	    const r = await pool.query('SELECT * FROM assets WHERE id = $1', [id]);
344	    if (r.rows.length === 0) return res.status(404).json({ error: 'Asset not found' });
345	    const a = r.rows[0];
346	    if (a.status === 'live') return res.json({ url: `/live/${a.id}/index.m3u8`, type: 'hls', live: true });
347	    if (a.proxy_s3_key) return res.json({ url: `/api/v1/assets/${id}/video`, type: 'mp4' });
348	    const orig = a.original_s3_key;
349	    if (orig && orig.toLowerCase().endsWith('.mp4')) return res.json({ url: `/api/v1/assets/${id}/video`, type: 'mp4' });
350	    return res.json({ url: null, type: null, reason: 'no_proxy' });
351	  } catch (err) {
352	    next(err);
353	  }
354	});
355
356	// GET /:id/video
357	router.get('/:id/video', async (req, res, next) => {
358	  try {
359	    const { id } = req.params;
360	    const r = await pool.query('SELECT proxy_s3_key, original_s3_key FROM assets WHERE id = $1', [id]);
361	    if (r.rows.length === 0) return res.status(404).json({ error: 'Asset not found' });
362	    const a = r.rows[0];
363	    const key = a.proxy_s3_key || (a.original_s3_key?.toLowerCase().endsWith('.mp4') ? a.original_s3_key : null);
364	    if (!key) return res.status(404).json({ error: 'No browser-playable source' });
365	    const params = { Bucket: getS3Bucket(), Key: key };
366	    const rangeHeader = req.headers.range;
367	    if (rangeHeader) params.Range = rangeHeader;
368	    const s3Res = await s3Client.send(new GetObjectCommand(params));
369	    const status = rangeHeader ? 206 : 200;
370	    const headers = { 'Content-Type': 'video/mp4', 'Accept-Ranges': 'bytes', 'Cache-Control': 'no-store' };
371	    if (s3Res.ContentLength) headers['Content-Length'] = String(s3Res.ContentLength);
372	    if (s3Res.ContentRange)  headers['Content-Range']  = s3Res.ContentRange;
373	    res.writeHead(status, headers);
374	    s3Res.Body.pipe(res);
375	  } catch (err) { next(err); }
376	});
377
378	// GET /:id/hires
379	router.get('/:id/hires', async (req, res, next) => {
380	  try {
381	    const { id } = req.params;
382	    const r = await pool.query(
383	      'SELECT original_s3_key, filename, display_name, file_size FROM assets WHERE id = $1',
384	      [id]
385	    );
386	    if (r.rows.length === 0) return res.status(404).json({ error: 'Asset not found' });
387	    const a = r.rows[0];
388	    if (!a.original_s3_key) return res.status(404).json({ error: 'No hi-res source available' });
389	    const url   = await getSignedUrlForObject(a.original_s3_key);
390	    const parts = a.original_s3_key.split('.');
391	    const ext   = (parts.length > 1 ? parts[parts.length - 1] : 'mxf').toLowerCase();
392	    const base  = (a.display_name || a.filename || id).replace(/[^\w.-]/g, '_').substring(0, 100);
393	    res.json({ url, filename: `${base}.${ext}`, ext, file_size: a.file_size || null, type: 'hires' });
394	  } catch (err) { next(err); }
395	});
396
397	// GET /:id/thumbnail
398	router.get('/:id/thumbnail', async (req, res, next) => {
399	  try {
400	    const { id } = req.params;
401	    const result = await pool.query('SELECT thumbnail_s3_key FROM assets WHERE id = $1', [id]);
402	    if (result.rows.length === 0) return res.status(404).json({ error: 'Asset not found' });
403	    const { thumbnail_s3_key } = result.rows[0];
404	    if (!thumbnail_s3_key) return res.status(404).json({ error: 'Thumbnail not yet available' });
405	    const url = await getSignedUrlForObject(thumbnail_s3_key);
406	    if (req.query.redirect === '1') return res.redirect(302, url);
407	    res.json({ url });
408	  } catch (err) {
409	    next(err);
410	  }
411	});
412
413	export default router;
414

      const asset = assetResult.rows[0];
      if (asset.proxy_s3_key)     await deleteObject(asset.proxy_s3_key);
      if (asset.thumbnail_s3_key) await deleteObject(asset.thumbnail_s3_key);
      if (asset.original_s3_key)  await deleteObject(asset.original_s3_key);
      await pool.query('DELETE FROM assets WHERE id = $1', [id]);
      res.json({ message: 'Asset deleted permanently' });
    } else {
      const result = await pool.query(
        `UPDATE assets SET status = 'archived', updated_at = NOW() WHERE id = $1 RETURNING *`,
        [id]
      );
      if (result.rows.length === 0) return res.status(404).json({ error: 'Asset not found' });
      res.json(result.rows[0]);
    }
  } catch (err) {
    next(err);
  }
});

// GET /:id/stream
router.get('/:id/stream', async (req, res, next) => {
  try {
    const { id } = req.params;
    const r = await pool.query('SELECT * FROM assets WHERE id = $1', [id]);
    if (r.rows.length === 0) return res.status(404).json({ error: 'Asset not found' });
    const a = r.rows[0];
    if (a.status === 'live') return res.json({ url: `/live/${a.id}/index.m3u8`, type: 'hls', live: true });
    if (a.proxy_s3_key) return res.json({ url: `/api/v1/assets/${id}/video`, type: 'mp4' });
    const orig = a.original_s3_key;
    if (orig && orig.toLowerCase().endsWith('.mp4')) return res.json({ url: `/api/v1/assets/${id}/video`, type: 'mp4' });
    return res.json({ url: null, type: null, reason: 'no_proxy' });
  } catch (err) {
    next(err);
  }
});

// GET /:id/video
router.get('/:id/video', async (req, res, next) => {
  try {
    const { id } = req.params;
    const r = await pool.query('SELECT proxy_s3_key, original_s3_key FROM assets WHERE id = $1', [id]);
    if (r.rows.length === 0) return res.status(404).json({ error: 'Asset not found' });
    const a = r.rows[0];
    const key = a.proxy_s3_key || (a.original_s3_key?.toLowerCase().endsWith('.mp4') ? a.original_s3_key : null);
    if (!key) return res.status(404).json({ error: 'No browser-playable source' });
    const params = { Bucket: getS3Bucket(), Key: key };
    const rangeHeader = req.headers.range;
    if (rangeHeader) params.Range = rangeHeader;
    const s3Res = await s3Client.send(new GetObjectCommand(params));
    const status = rangeHeader ? 206 : 200;
    const headers = { 'Content-Type': 'video/mp4', 'Accept-Ranges': 'bytes', 'Cache-Control': 'no-store' };
    if (s3Res.ContentLength) headers['Content-Length'] = String(s3Res.ContentLength);
    if (s3Res.ContentRange)  headers['Content-Range']  = s3Res.ContentRange;
    res.writeHead(status, headers);
    s3Res.Body.pipe(res);
  } catch (err) { next(err); }
});

// GET /:id/hires
router.get('/:id/hires', async (req, res, next) => {
  try {
    const { id } = req.params;
    const r = await pool.query(
      'SELECT original_s3_key, filename, display_name, file_size FROM assets WHERE id = $1',
      [id]
    );
    if (r.rows.length === 0) return res.status(404).json({ error: 'Asset not found' });
    const a = r.rows[0];
    if (!a.original_s3_key) return res.status(404).json({ error: 'No hi-res source available' });
    const url   = await getSignedUrlForObject(a.original_s3_key);
    const parts = a.original_s3_key.split('.');
    const ext   = (parts.length > 1 ? parts[parts.length - 1] : 'mxf').toLowerCase();
    const base  = (a.display_name || a.filename || id).replace(/[^\w.-]/g, '_').substring(0, 100);
    res.json({ url, filename: `${base}.${ext}`, ext, file_size: a.file_size || null, type: 'hires' });
  } catch (err) { next(err); }
});

// GET /:id/thumbnail
router.get('/:id/thumbnail', async (req, res, next) => {
  try {
    const { id } = req.params;
    const result = await pool.query('SELECT thumbnail_s3_key FROM assets WHERE id = $1', [id]);
    if (result.rows.length === 0) return res.status(404).json({ error: 'Asset not found' });
    const { thumbnail_s3_key } = result.rows[0];
    if (!thumbnail_s3_key) return res.status(404).json({ error: 'Thumbnail not yet available' });
    const url = await getSignedUrlForObject(thumbnail_s3_key);
    if (req.query.redirect === '1') return res.redirect(302, url);
    res.json({ url });
  } catch (err) {
    next(err);
  }
});

export default router;


1	import 'dotenv/config';
2	import express from 'express';
3	import cors from 'cors';
4	import session from 'express-session';
5	import ConnectPgSimple from 'connect-pg-simple';
6	import os from 'node:os';
7	import { exec } from 'node:child_process';
8	import pool from './db/pool.js';
9	import { errorHandler } from './middleware/errors.js';
10	import { loadS3ConfigFromDb } from './s3/client.js';
11
12	// Routes
13	import authRouter from './routes/auth.js';
14	import assetsRouter from './routes/assets.js';
15	import projectsRouter from './routes/projects.js';
16	import binsRouter from './routes/bins.js';
17	import jobsRouter from './routes/jobs.js';
18	import captureRouter from './routes/capture.js';
19	import uploadRouter from './routes/upload.js';
20	import recordersRouter from './routes/recorders.js';
21	import settingsRouter from './routes/settings.js';
22	import amppRouter from './routes/ampp.js';
23	import usersRouter    from './routes/users.js';
24	import groupsRouter   from './routes/groups.js';
25	import tokensRouter   from './routes/tokens.js';
26	import sequencesRouter from './routes/sequences.js';
27	import systemRouter  from './routes/system.js';
28	import clusterRouter from './routes/cluster.js';
29
30	const app  = express();
31	const PORT = process.env.PORT || 3000;
32
33	// ── Middleware ────────────────────────────────────────────────────────────────
34	app.use(cors({ origin: true, credentials: true }));
35	app.use(express.json({ limit: '50mb' }));
36
37	const PgSession = ConnectPgSimple(session);
38
39	app.use(
40	  session({
41	    store: new PgSession({
42	      pool,
43	      tableName: 'sessions',
44	      pruneSessionInterval: 3600,
45	    }),
46	    secret:            process.env.SESSION_SECRET || 'change-me-in-production',
47	    resave:            false,
48	    saveUninitialized: false,
49	    cookie: {
50	      secure:   process.env.NODE_ENV === 'production',
51	      httpOnly: true,
52	      maxAge:   1000 * 60 * 60 * 24,
53	    },
54	  })
55	);
56
57	// ── Health (no auth) ──────────────────────────────────────────────────────────
58	app.get('/health', (_req, res) => res.json({ status: 'ok' }));
59
60	// ── API Routes ────────────────────────────────────────────────────────────────
61	app.use('/api/v1/auth',      authRouter);
62	app.use('/api/v1/assets',    assetsRouter);
63	app.use('/api/v1/projects',  projectsRouter);
64	app.use('/api/v1/bins',      binsRouter);
65	app.use('/api/v1/jobs',      jobsRouter);
66	app.use('/api/v1/capture',   captureRouter);
67	app.use('/api/v1/upload',    uploadRouter);
68	app.use('/api/v1/recorders', recordersRouter);
69	app.use('/api/v1/settings',  settingsRouter);
70	app.use('/api/v1/ampp',      amppRouter);
71	app.use('/api/v1/users',     usersRouter);
72	app.use('/api/v1/groups',    groupsRouter);
73	app.use('/api/v1/tokens',    tokensRouter);
74	app.use('/api/v1/sequences', sequencesRouter);
75	app.use('/api/v1/system',    systemRouter);
76	app.use('/api/v1/cluster',   clusterRouter);
77
78	// ── Error handler ─────────────────────────────────────────────────────────────
79	app.use(errorHandler);
80
81	// ── Start ────────────────────────────────────────────────────────────────────
82	import { readdirSync, readFileSync } from 'node:fs';
83	import { fileURLToPath } from 'node:url';
84	import { dirname, join } from 'node:path';
85
86	const __dirnameMig = dirname(fileURLToPath(import.meta.url));
87	async function runMigrations() {
88	  const dir = join(__dirnameMig, 'db', 'migrations');
89	  let files = [];
90	  try { files = readdirSync(dir).filter(f => f.endsWith('.sql')).sort(); } catch { return; }
91	  for (const f of files) {
92	    const sql = readFileSync(join(dir, f), 'utf8');
93	    try {
94	      await pool.query(sql);
95	      console.log('[migration] applied ' + f);
96	    } catch (err) {
97	      console.error('[migration] failed ' + f, err.message);
98	    }
99	  }
100	}
101	await runMigrations();
102
103	// Load S3 config from DB so any settings saved via the Settings page override env vars
104	await loadS3ConfigFromDb();
105
106	// ── Cluster self-heartbeat ────────────────────────────────────────────────────
107	function getLocalIp() {
108	  // Prefer an explicit override — useful when running inside Docker where
109	  // os.networkInterfaces() returns container bridge IPs, not the host LAN IP.
110	  if (process.env.NODE_IP) return process.env.NODE_IP;
111
112	  const ifaces = os.networkInterfaces();
113	  for (const name of Object.keys(ifaces)) {
114	    for (const iface of (ifaces[name] || [])) {
115	      if (iface.family === 'IPv4' && !iface.internal) return iface.address;
116	    }
117	  }
118	  return '127.0.0.1';
119	}
120
121	// Detect NVIDIA GPUs available to this container via nvidia-smi.
122	// Returns an array like [{ index: 0, name: 'Tesla P4', memory_mb: 7680 }, ...]
123	// or an empty array if nvidia-smi is unavailable or no GPUs found.
124	function detectGpus() {
125	  return new Promise(resolve => {
126	    exec(
127	      'nvidia-smi --query-gpu=index,name,memory.total --format=csv,noheader,nounits',
128	      { timeout: 5000 },
129	      (err, stdout) => {
130	        if (err || !stdout.trim()) return resolve([]);
131	        const gpus = stdout.trim().split('\n').map(line => {
132	          const parts = line.split(',').map(s => s.trim());
133	          return {
134	            index:     parseInt(parts[0], 10),
135	            name:      parts[1] || 'Unknown GPU',
136	            memory_mb: parseInt(parts[2], 10) || 0,
137	          };
138	        }).filter(g => !isNaN(g.index));
139	        resolve(gpus);
140	      }
141	    );
142	  });
143	}
144
145	async function selfHeartbeat() {
146	  const load  = os.loadavg()[0];
147	  const total = os.totalmem();
148	  const used  = total - os.freemem();
149	  const gpus  = await detectGpus();
150
151	  const capabilities = { gpus, blackmagic: [] };
152
153	  pool.query(
154	    `INSERT INTO cluster_nodes
155	       (hostname, ip_address, role, version, api_url,
156	        cpu_usage, mem_used_mb, mem_total_mb, capabilities, last_seen)
157	     VALUES ($1,$2,'primary',$3,$4,$5,$6,$7,$8,NOW())
158	     ON CONFLICT (hostname) DO UPDATE SET
159	       ip_address   = EXCLUDED.ip_address,
160	       cpu_usage    = EXCLUDED.cpu_usage,
161	       mem_used_mb  = EXCLUDED.mem_used_mb,
162	       mem_total_mb = EXCLUDED.mem_total_mb,
163	       capabilities = EXCLUDED.capabilities,
164	       last_seen    = NOW()`,
165	    [
166	      process.env.NODE_HOSTNAME || os.hostname(),
167	      getLocalIp(),
168	      process.env.npm_package_version || null,
169	      `http://${getLocalIp()}:${PORT}`,
170	      parseFloat(load.toFixed(2)),
171	      Math.round(used  / 1024 / 1024),
172	      Math.round(total / 1024 / 1024),
173	      JSON.stringify(capabilities),
174	    ]
175	  ).catch(err => console.error('[cluster] heartbeat failed:', err.message));
176	}
177
178	setInterval(selfHeartbeat, 30_000);
179	selfHeartbeat();
180
181	app.listen(PORT, () => {
182	  const authMode = process.env.AUTH_ENABLED === 'true' ? 'ENABLED' : 'DISABLED (set AUTH_ENABLED=true for production)';
183	  console.log(`MAM API listening on port ${PORT}`);
184	  console.log(`Authentication: ${authMode}`);
185	});
186

import 'dotenv/config';
import express from 'express';
import cors from 'cors';
import session from 'express-session';
import ConnectPgSimple from 'connect-pg-simple';
import os from 'node:os';
import { exec } from 'node:child_process';
import pool from './db/pool.js';
import { errorHandler } from './middleware/errors.js';
import { loadS3ConfigFromDb } from './s3/client.js';

// Routes
import authRouter from './routes/auth.js';
import assetsRouter from './routes/assets.js';
import projectsRouter from './routes/projects.js';
import binsRouter from './routes/bins.js';
import jobsRouter from './routes/jobs.js';
import captureRouter from './routes/capture.js';
import uploadRouter from './routes/upload.js';
import recordersRouter from './routes/recorders.js';
import settingsRouter from './routes/settings.js';
import amppRouter from './routes/ampp.js';
import usersRouter    from './routes/users.js';
import groupsRouter   from './routes/groups.js';
import tokensRouter   from './routes/tokens.js';
import sequencesRouter from './routes/sequences.js';
import systemRouter  from './routes/system.js';
import clusterRouter from './routes/cluster.js';

const app  = express();
const PORT = process.env.PORT || 3000;

// ── Middleware ────────────────────────────────────────────────────────────────
app.use(cors({ origin: true, credentials: true }));
app.use(express.json({ limit: '50mb' }));

const PgSession = ConnectPgSimple(session);

app.use(
  session({
    store: new PgSession({
      pool,
      tableName: 'sessions',
      pruneSessionInterval: 3600,
    }),
    secret:            process.env.SESSION_SECRET || 'change-me-in-production',
    resave:            false,
    saveUninitialized: false,
    cookie: {
      secure:   process.env.NODE_ENV === 'production',
      httpOnly: true,
      maxAge:   1000 * 60 * 60 * 24,
    },
  })
);

// ── Health (no auth) ──────────────────────────────────────────────────────────
app.get('/health', (_req, res) => res.json({ status: 'ok' }));

// ── API Routes ────────────────────────────────────────────────────────────────
app.use('/api/v1/auth',      authRouter);
app.use('/api/v1/assets',    assetsRouter);
app.use('/api/v1/projects',  projectsRouter);
app.use('/api/v1/bins',      binsRouter);
app.use('/api/v1/jobs',      jobsRouter);
app.use('/api/v1/capture',   captureRouter);
app.use('/api/v1/upload',    uploadRouter);
app.use('/api/v1/recorders', recordersRouter);
app.use('/api/v1/settings',  settingsRouter);
app.use('/api/v1/ampp',      amppRouter);
app.use('/api/v1/users',     usersRouter);
app.use('/api/v1/groups',    groupsRouter);
app.use('/api/v1/tokens',    tokensRouter);
app.use('/api/v1/sequences', sequencesRouter);
app.use('/api/v1/system',    systemRouter);
app.use('/api/v1/cluster',   clusterRouter);

// ── Error handler ─────────────────────────────────────────────────────────────
app.use(errorHandler);

// ── Start ────────────────────────────────────────────────────────────────────
import { readdirSync, readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';

const __dirnameMig = dirname(fileURLToPath(import.meta.url));
async function runMigrations() {
  const dir = join(__dirnameMig, 'db', 'migrations');
  let files = [];
  try { files = readdirSync(dir).filter(f => f.endsWith('.sql')).sort(); } catch { return; }
  for (const f of files) {
    const sql = readFileSync(join(dir, f), 'utf8');
    try {
      await pool.query(sql);
      console.log('[migration] applied ' + f);
    } catch (err) {
      console.error('[migration] failed ' + f, err.message);
    }
  }
}
await runMigrations();

// Load S3 config from DB so any settings saved via the Settings page override env vars
await loadS3ConfigFromDb();

// ── Cluster self-heartbeat ────────────────────────────────────────────────────
function getLocalIp() {
  // Prefer an explicit override — useful when running inside Docker where
  // os.networkInterfaces() returns container bridge IPs, not the host LAN IP.
  if (process.env.NODE_IP) return process.env.NODE_IP;

  const ifaces = os.networkInterfaces();
  for (const name of Object.keys(ifaces)) {
    for (const iface of (ifaces[name] || [])) {
      if (iface.family === 'IPv4' && !iface.internal) return iface.address;
    }
  }
  return '127.0.0.1';
}

// Detect NVIDIA GPUs available to this container via nvidia-smi.
// Returns an array like [{ index: 0, name: 'Tesla P4', memory_mb: 7680 }, ...]
// or an empty array if nvidia-smi is unavailable or no GPUs found.
function detectGpus() {
  return new Promise(resolve => {
    exec(
      'nvidia-smi --query-gpu=index,name,memory.total --format=csv,noheader,nounits',
      { timeout: 5000 },
      (err, stdout) => {
        if (err || !stdout.trim()) return resolve([]);
        const gpus = stdout.trim().split('\n').map(line => {
          const parts = line.split(',').map(s => s.trim());
          return {
            index:     parseInt(parts[0], 10),
            name:      parts[1] || 'Unknown GPU',
            memory_mb: parseInt(parts[2], 10) || 0,
          };
        }).filter(g => !isNaN(g.index));
        resolve(gpus);
      }
    );
  });
}

async function selfHeartbeat() {
  const load  = os.loadavg()[0];
  const total = os.totalmem();
  const used  = total - os.freemem();
  const gpus  = await detectGpus();

  const capabilities = { gpus, blackmagic: [] };

  pool.query(
    `INSERT INTO cluster_nodes
       (hostname, ip_address, role, version, api_url,
        cpu_usage, mem_used_mb, mem_total_mb, capabilities, last_seen)
     VALUES ($1,$2,'primary',$3,$4,$5,$6,$7,$8,NOW())
     ON CONFLICT (hostname) DO UPDATE SET
       ip_address   = EXCLUDED.ip_address,
       cpu_usage    = EXCLUDED.cpu_usage,
       mem_used_mb  = EXCLUDED.mem_used_mb,
       mem_total_mb = EXCLUDED.mem_total_mb,
       capabilities = EXCLUDED.capabilities,
       last_seen    = NOW()`,
    [
      process.env.NODE_HOSTNAME || os.hostname(),
      getLocalIp(),
      process.env.npm_package_version || null,
      `http://${getLocalIp()}:${PORT}`,
      parseFloat(load.toFixed(2)),
      Math.round(used  / 1024 / 1024),
      Math.round(total / 1024 / 1024),
      JSON.stringify(capabilities),
    ]
  ).catch(err => console.error('[cluster] heartbeat failed:', err.message));
}

setInterval(selfHeartbeat, 30_000);
selfHeartbeat();

app.listen(PORT, () => {
  const authMode = process.env.AUTH_ENABLED === 'true' ? 'ENABLED' : 'DISABLED (set AUTH_ENABLED=true for production)';
  console.log(`MAM API listening on port ${PORT}`);
  console.log(`Authentication: ${authMode}`);
});


Recon: clone repo, snapshot deployed UI, inventory TrueNAS storage

Investigate ingest pipeline: why 12 jobs failed, why session 224002 didn't become an asset

Wire real video preview into Recorders + Monitors (replace LiveStrip/FauxFrame)

Fix Settings page – currently only S3 panel; verify what is broken on deploy

Button audit pass across all pages

Provision SMB growing-files share on TrueNAS NVME

Implement growing-file → S3 promotion pipeline

Prototype Premier CEP panel with proxy switching (build on existing premiere-plugin scaffold)

Deploy to zampp1 + push to main


Lets do a debugging & polish session on https://forge.wilddragon.net/zgaetano/wild-dragon , its currently deployed to Truenas. http://10.0.0.25:47434/   SRT / Recorder / Ingest page is still buggy, pr…


Debug ingest, monitors, buttons and prototype file editing


1	services:
2	  db:
3	    image: postgres:16
4	    environment:
5	      POSTGRES_DB: ${POSTGRES_DB}
6	      POSTGRES_USER: ${POSTGRES_USER}
7	      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
8	    ports:
9	      - "${PORT_DB:-5432}:5432"
10	    volumes:
11	      - postgres_data:/var/lib/postgresql/data
12	      - ./services/mam-api/src/db/schema.sql:/docker-entrypoint-initdb.d/001-schema.sql:ro
13	    healthcheck:
14	      test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER}"]
15	      interval: 5s
16	      timeout: 5s
17	      retries: 5
18	    networks:
19	      - wild-dragon
20
21	  queue:
22	    image: redis:7-alpine
23	    ports:
24	      - "${PORT_REDIS:-6379}:6379"
25	    volumes:
26	      - redis_data:/data
27	    networks:
28	      - wild-dragon
29
30	  mam-api:
31	    build: ./services/mam-api
32	    depends_on:
33	      db:
34	        condition: service_healthy
35	      queue:
36	        condition: service_started
37	    ports:
38	      - "${PORT_MAM_API:-7432}:3000"
39	    volumes:
40	      - /var/run/docker.sock:/var/run/docker.sock
41	      - /mnt/NVME/MAM/wild-dragon-live:/live
42	      - /dev/shm:/dev/shm
43	      - /run/dbus:/run/dbus
44	      - /run/systemd:/run/systemd
45	      - /usr/bin/nvidia-smi:/usr/bin/nvidia-smi:ro
46	    environment:
47	      DATABASE_URL: ${DATABASE_URL}
48	      REDIS_URL: ${REDIS_URL}
49	      S3_ENDPOINT: ${S3_ENDPOINT}
50	      S3_BUCKET: ${S3_BUCKET}
51	      S3_ACCESS_KEY: ${S3_ACCESS_KEY}
52	      S3_SECRET_KEY: ${S3_SECRET_KEY}
53	      S3_REGION: ${S3_REGION:-us-east-1}
54	      SESSION_SECRET: ${SESSION_SECRET}
55	      AUTH_ENABLED: ${AUTH_ENABLED:-false}
56	      DOCKER_NETWORK: wild-dragon_wild-dragon
57	      NODE_IP: ${NODE_IP}
58	      NODE_HOSTNAME: ${NODE_HOSTNAME:-}
59	    deploy:
60	      resources:
61	        reservations:
62	          devices:
63	            - driver: nvidia
64	              count: all
65	              capabilities: [gpu]
66	    networks:
67	      - wild-dragon
68
69	  capture:
70	    build: ./services/capture
71	    depends_on:
72	      - mam-api
73	    ports:
74	      - "${PORT_CAPTURE:-7433}:3001"
75	      - "${PORT_RTMP:-1935}:1935"         # RTMP ingest (listener mode)
76	      - "${PORT_SRT:-9000}:9000/udp"      # SRT ingest (listener mode)
77	    privileged: true
78	    environment:
79	      S3_ENDPOINT: ${S3_ENDPOINT}
80	      S3_BUCKET: ${S3_BUCKET}
81	      S3_ACCESS_KEY: ${S3_ACCESS_KEY}
82	      S3_SECRET_KEY: ${S3_SECRET_KEY}
83	      S3_REGION: ${S3_REGION:-us-east-1}
84	      MAM_API_URL: ${MAM_API_URL:-http://mam-api:3000}
85	    volumes:
86	      - /mnt/NVME/MAM/wild-dragon-live:/live
87	      - /dev/shm:/dev/shm
88	      - /run/dbus:/run/dbus
89	      - /run/systemd:/run/systemd
90	    networks:
91	      - wild-dragon
92
93	  worker:
94	    build: ./services/worker
95	    depends_on:
96	      - queue
97	      - db
98	    environment:
99	      REDIS_URL: ${REDIS_URL}
100	      DATABASE_URL: ${DATABASE_URL}
101	      S3_ENDPOINT: ${S3_ENDPOINT}
102	      S3_BUCKET: ${S3_BUCKET}
103	      S3_ACCESS_KEY: ${S3_ACCESS_KEY}
104	      S3_SECRET_KEY: ${S3_SECRET_KEY}
105	      S3_REGION: ${S3_REGION:-us-east-1}
106	    networks:
107	      - wild-dragon
108
109	  web-ui:
110	    build: ./services/web-ui
111	    ports:
112	      - "${PORT_WEB_UI:-7434}:80"
113	    volumes:
114	      - /mnt/NVME/MAM/wild-dragon-live:/live
115	      - /dev/shm:/dev/shm
116	      - /run/dbus:/run/dbus
117	      - /run/systemd:/run/systemd
118	    networks:
119	      - wild-dragon
120
121	  editor:
122	    build: ./services/editor
123	    depends_on:
124	      - mam-api
125	    ports:
126	      - "${PORT_EDITOR:-7435}:80"
127	    networks:
128	      - wild-dragon
129
130	volumes:
131	  postgres_data:
132	  redis_data:
133
134	networks:
135	  wild-dragon:
136	    driver: bridge
137

services:
  db:
    image: postgres:16
    environment:
      POSTGRES_DB: ${POSTGRES_DB}
      POSTGRES_USER: ${POSTGRES_USER}
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
    ports:
      - "${PORT_DB:-5432}:5432"
    volumes:
      - postgres_data:/var/lib/postgresql/data
      - ./services/mam-api/src/db/schema.sql:/docker-entrypoint-initdb.d/001-schema.sql:ro
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER}"]
      interval: 5s
      timeout: 5s
      retries: 5
    networks:
      - wild-dragon

  queue:
    image: redis:7-alpine
    ports:
      - "${PORT_REDIS:-6379}:6379"
    volumes:
      - redis_data:/data
    networks:
      - wild-dragon

  mam-api:
    build: ./services/mam-api
    depends_on:
      db:
        condition: service_healthy
      queue:
        condition: service_started
    ports:
      - "${PORT_MAM_API:-7432}:3000"
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock
      - /mnt/NVME/MAM/wild-dragon-live:/live
      - /dev/shm:/dev/shm
      - /run/dbus:/run/dbus
      - /run/systemd:/run/systemd
      - /usr/bin/nvidia-smi:/usr/bin/nvidia-smi:ro
    environment:
      DATABASE_URL: ${DATABASE_URL}
      REDIS_URL: ${REDIS_URL}
      S3_ENDPOINT: ${S3_ENDPOINT}
      S3_BUCKET: ${S3_BUCKET}
      S3_ACCESS_KEY: ${S3_ACCESS_KEY}
      S3_SECRET_KEY: ${S3_SECRET_KEY}
      S3_REGION: ${S3_REGION:-us-east-1}
      SESSION_SECRET: ${SESSION_SECRET}
      AUTH_ENABLED: ${AUTH_ENABLED:-false}
      DOCKER_NETWORK: wild-dragon_wild-dragon
      NODE_IP: ${NODE_IP}
      NODE_HOSTNAME: ${NODE_HOSTNAME:-}
    deploy:
      resources:
        reservations:
          devices:
            - driver: nvidia
              count: all
              capabilities: [gpu]
    networks:
      - wild-dragon

  capture:
    build: ./services/capture
    depends_on:
      - mam-api
    ports:
      - "${PORT_CAPTURE:-7433}:3001"
      - "${PORT_RTMP:-1935}:1935"         # RTMP ingest (listener mode)
      - "${PORT_SRT:-9000}:9000/udp"      # SRT ingest (listener mode)
    privileged: true
    environment:
      S3_ENDPOINT: ${S3_ENDPOINT}
      S3_BUCKET: ${S3_BUCKET}
      S3_ACCESS_KEY: ${S3_ACCESS_KEY}
      S3_SECRET_KEY: ${S3_SECRET_KEY}
      S3_REGION: ${S3_REGION:-us-east-1}
      MAM_API_URL: ${MAM_API_URL:-http://mam-api:3000}
    volumes:
      - /mnt/NVME/MAM/wild-dragon-live:/live
      - /dev/shm:/dev/shm
      - /run/dbus:/run/dbus
      - /run/systemd:/run/systemd
    networks:
      - wild-dragon

  worker:
    build: ./services/worker
    depends_on:
      - queue
      - db
    environment:
      REDIS_URL: ${REDIS_URL}
      DATABASE_URL: ${DATABASE_URL}
      S3_ENDPOINT: ${S3_ENDPOINT}
      S3_BUCKET: ${S3_BUCKET}
      S3_ACCESS_KEY: ${S3_ACCESS_KEY}
      S3_SECRET_KEY: ${S3_SECRET_KEY}
      S3_REGION: ${S3_REGION:-us-east-1}
    networks:
      - wild-dragon

  web-ui:
    build: ./services/web-ui
    ports:
      - "${PORT_WEB_UI:-7434}:80"
    volumes:
      - /mnt/NVME/MAM/wild-dragon-live:/live
      - /dev/shm:/dev/shm
      - /run/dbus:/run/dbus
      - /run/systemd:/run/systemd
    networks:
      - wild-dragon

  editor:
    build: ./services/editor
    depends_on:
      - mam-api
    ports:
      - "${PORT_EDITOR:-7435}:80"
    networks:
      - wild-dragon

volumes:
  postgres_data:
  redis_data:

networks:
  wild-dragon:
    driver: bridge


1	// screens-asset.jsx — asset detail (Frame.io-style player, filmstrip, comments)
2
3	const { COMMENTS: SEED_COMMENTS } = window.ZAMPP_DATA;
4
5	// Simple gradient palette — replaces the missing thumbGrad function
6	const _FRAME_GRADIENTS = [
7	  'linear-gradient(135deg,#1a1f2e 0%,#2a3045 100%)',
8	  'linear-gradient(135deg,#1e2030 0%,#2d2040 100%)',
9	  'linear-gradient(135deg,#0d1520 0%,#1a2535 100%)',
10	  'linear-gradient(135deg,#1f1a2e 0%,#2a2040 100%)',
11	  'linear-gradient(135deg,#0f1e18 0%,#1a3028 100%)',
12	  'linear-gradient(135deg,#1e1510 0%,#302018 100%)',
13	  'linear-gradient(135deg,#1a1020 0%,#281830 100%)',
14	  'linear-gradient(135deg,#101828 0%,#182438 100%)',
15	  'linear-gradient(135deg,#1e2820 0%,#283830 100%)',
16	  'linear-gradient(135deg,#201820 0%,#302030 100%)',
17	  'linear-gradient(135deg,#181e28 0%,#202838 100%)',
18	];
19
20	function AssetDetail({ asset, onClose }) {
21	  const [playing, setPlaying] = React.useState(false);
22	  const [currentMs, setCurrentMs] = React.useState(0);
23	  const [tab, setTab] = React.useState("comments");
24	  const [showResolved, setShowResolved] = React.useState(false);
25	  const [comments, setComments] = React.useState(SEED_COMMENTS || []);
26	  const [newComment, setNewComment] = React.useState("");
27
28	  // Stream / video state
29	  const [streamUrl, setStreamUrl] = React.useState(null);
30	  const [streamType, setStreamType] = React.useState(null);
31	  const [streamLoading, setStreamLoading] = React.useState(false);
32	  const [videoDuration, setVideoDuration] = React.useState(0);
33	  const [retrying, setRetrying] = React.useState(false);
34	  const videoRef = React.useRef(null);
35
36	  const assetId = asset && asset.id;
37	  const totalMs = videoDuration > 0 ? videoDuration : parseDuration(asset.duration);
38
39	  // Fetch stream URL when asset changes
40	  React.useEffect(() => {
41	    if (!assetId) return;
42	    setStreamUrl(null);
43	    setStreamType(null);
44	    setVideoDuration(0);
45	    setCurrentMs(0);
46	    setPlaying(false);
47	    setStreamLoading(true);
48	    window.ZAMPP_API.fetch('/assets/' + assetId + '/stream')
49	      .then(function(r) {
50	        if (r && r.url) {
51	          setStreamUrl(r.url);
52	          setStreamType(r.type || 'mp4');
53	        }
54	      })
55	      .catch(function() {})
56	      .finally(function() { setStreamLoading(false); });
57	  }, [assetId]);
58
59	  // Wire hls.js for live HLS streams
60	  React.useEffect(() => {
61	    if (!streamUrl || streamType !== 'hls' || !videoRef.current) return;
62	    if (!window.Hls) return;
63	    const hls = new window.Hls();
64	    hls.loadSource(streamUrl);
65	    hls.attachMedia(videoRef.current);
66	    return function() { hls.destroy(); };
67	  }, [streamUrl, streamType]);
68
69	  // Fake playback timer — only used when no real video stream
70	  React.useEffect(() => {
71	    if (!playing || totalMs <= 0 || streamUrl) return;
72	    const i = setInterval(function() {
73	      setCurrentMs(function(t) {
74	        const next = t + 100;
75	        if (next >= totalMs) { setPlaying(false); return totalMs; }
76	        return next;
77	      });
78	    }, 100);
79	    return function() { clearInterval(i); };
80	  }, [playing, totalMs, streamUrl]);
81
82	  const togglePlay = function() {
83	    if (videoRef.current) {
84	      if (videoRef.current.paused) { videoRef.current.play(); }
85	      else { videoRef.current.pause(); }
86	    } else {
87	      setPlaying(function(p) { return !p; });
88	    }
89	  };
90
91	  const seek = function(ms) {
92	    const clamped = Math.max(0, Math.min(totalMs || 0, ms));
93	    setCurrentMs(clamped);
94	    if (videoRef.current) videoRef.current.currentTime = clamped / 1000;
95	  };
96
97	  const retryProcessing = function() {
98	    if (retrying) return;
99	    setRetrying(true);
100	    window.ZAMPP_API.fetch('/assets/' + assetId + '/retry', { method: 'POST' })

// screens-asset.jsx — asset detail (Frame.io-style player, filmstrip, comments)

const { COMMENTS: SEED_COMMENTS } = window.ZAMPP_DATA;

// Simple gradient palette — replaces the missing thumbGrad function
const _FRAME_GRADIENTS = [
  'linear-gradient(135deg,#1a1f2e 0%,#2a3045 100%)',
  'linear-gradient(135deg,#1e2030 0%,#2d2040 100%)',
  'linear-gradient(135deg,#0d1520 0%,#1a2535 100%)',
  'linear-gradient(135deg,#1f1a2e 0%,#2a2040 100%)',
  'linear-gradient(135deg,#0f1e18 0%,#1a3028 100%)',
  'linear-gradient(135deg,#1e1510 0%,#302018 100%)',
  'linear-gradient(135deg,#1a1020 0%,#281830 100%)',
  'linear-gradient(135deg,#101828 0%,#182438 100%)',
  'linear-gradient(135deg,#1e2820 0%,#283830 100%)',
  'linear-gradient(135deg,#201820 0%,#302030 100%)',
  'linear-gradient(135deg,#181e28 0%,#202838 100%)',
];

function AssetDetail({ asset, onClose }) {
  const [playing, setPlaying] = React.useState(false);
  const [currentMs, setCurrentMs] = React.useState(0);
  const [tab, setTab] = React.useState("comments");
  const [showResolved, setShowResolved] = React.useState(false);
  const [comments, setComments] = React.useState(SEED_COMMENTS || []);
  const [newComment, setNewComment] = React.useState("");

  // Stream / video state
  const [streamUrl, setStreamUrl] = React.useState(null);
  const [streamType, setStreamType] = React.useState(null);
  const [streamLoading, setStreamLoading] = React.useState(false);
  const [videoDuration, setVideoDuration] = React.useState(0);
  const [retrying, setRetrying] = React.useState(false);
  const videoRef = React.useRef(null);

  const assetId = asset && asset.id;
  const totalMs = videoDuration > 0 ? videoDuration : parseDuration(asset.duration);

  // Fetch stream URL when asset changes
  React.useEffect(() => {
    if (!assetId) return;
    setStreamUrl(null);
    setStreamType(null);
    setVideoDuration(0);
    setCurrentMs(0);
    setPlaying(false);
    setStreamLoading(true);
    window.ZAMPP_API.fetch('/assets/' + assetId + '/stream')
      .then(function(r) {
        if (r && r.url) {
          setStreamUrl(r.url);
          setStreamType(r.type || 'mp4');
        }
      })
      .catch(function() {})
      .finally(function() { setStreamLoading(false); });
  }, [assetId]);

  // Wire hls.js for live HLS streams
  React.useEffect(() => {
    if (!streamUrl || streamType !== 'hls' || !videoRef.current) return;
    if (!window.Hls) return;
    const hls = new window.Hls();
    hls.loadSource(streamUrl);
    hls.attachMedia(videoRef.current);
    return function() { hls.destroy(); };
  }, [streamUrl, streamType]);

  // Fake playback timer — only used when no real video stream
  React.useEffect(() => {
    if (!playing || totalMs <= 0 || streamUrl) return;
    const i = setInterval(function() {
      setCurrentMs(function(t) {
        const next = t + 100;
        if (next >= totalMs) { setPlaying(false); return totalMs; }
        return next;
      });
    }, 100);
    return function() { clearInterval(i); };
  }, [playing, totalMs, streamUrl]);

  const togglePlay = function() {
    if (videoRef.current) {
      if (videoRef.current.paused) { videoRef.current.play(); }
      else { videoRef.current.pause(); }
    } else {
      setPlaying(function(p) { return !p; });
    }
  };

  const seek = function(ms) {
    const clamped = Math.max(0, Math.min(totalMs || 0, ms));
    setCurrentMs(clamped);
    if (videoRef.current) videoRef.current.currentTime = clamped / 1000;
  };

  const retryProcessing = function() {
    if (retrying) return;
    setRetrying(true);
    window.ZAMPP_API.fetch('/assets/' + assetId + '/retry', { method: 'POST' })


Bash

cd /tmp/wd/wild-dragon && find services/web-ui -name "*.conf" -o -name "Dockerfile" -o -name "nginx*" | head -10

Find web-ui nginx config


services/web-ui/Dockerfile
services/web-ui/nginx.conf
Shell cwd was reset to /home/node


1	server {
2	    listen 80;
3	    server_name _;
4
5	    # Docker embedded DNS — defers upstream resolution to request time
6	    # This prevents nginx crashing at startup if sibling containers aren't
7	    # ready yet (which happens on the first `docker compose up`).
8	    resolver 127.0.0.11 valid=10s ipv6=off;
9
10	    # Allow unlimited client upload size
11	    client_max_body_size 0;
12
13	    # Gzip compression
14	    gzip on;
15	    gzip_types text/plain text/css text/javascript application/javascript application/json;
16	    gzip_min_length 1000;
17
18	    # Root location - serve static files
19	    root /usr/share/nginx/html;
20
21	    # Cache static assets aggressively
22	    location ~* \.(css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
23	        expires 1y;
24	        add_header Cache-Control "public, immutable";
25	    }
26
27	    # JS files — must revalidate so a redeploy is picked up immediately.
28	    # The static index.html links api.js with a ?v=N query string anyway,
29	    # but defence-in-depth: never let a stale .js sit in a browser cache.
30	    location ~* \.js$ {
31	        expires -1;
32	        add_header Cache-Control "no-cache, must-revalidate";
33	    }
34
35	    # HTML files - no cache
36	    location ~* \.html?$ {
37	        expires -1;
38	        add_header Cache-Control "no-cache, no-store, must-revalidate";
39	    }
40
41	    # Live HLS — served from /live (bind-mounted shared volume), low cache so playlist refreshes
42	    location /live/ {
43	        alias /live/;
44	        types { application/vnd.apple.mpegurl m3u8; video/mp2t ts; }
45	        add_header Cache-Control "no-cache";
46	        add_header Access-Control-Allow-Origin *;
47	    }
48
49	    # API proxy - forward to mam-api service
50	    location /api/ {
51	        set $api_upstream http://mam-api:3000;
52	        client_max_body_size 0;
53	        proxy_pass $api_upstream;
54	        proxy_http_version 1.1;
55	        proxy_set_header Upgrade $http_upgrade;
56	        proxy_set_header Connection "upgrade";
57	        proxy_set_header Host $host;
58	        proxy_set_header X-Real-IP $remote_addr;
59	        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
60	        proxy_set_header X-Forwarded-Proto $scheme;
61	        proxy_buffering off;
62	        proxy_request_buffering off;
63	        proxy_connect_timeout 300;
64	        proxy_send_timeout 300;
65	        proxy_read_timeout 300;
66	    }
67
68	    # Editor proxy - forward to the openreel-video fork
69	    location /editor/ {
70	        set $editor_upstream http://editor:80/;
71	        proxy_pass $editor_upstream;
72	        proxy_http_version 1.1;
73	        proxy_set_header Host $host;
74	        proxy_set_header X-Real-IP $remote_addr;
75	        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
76	        client_max_body_size 8g;
77	        proxy_request_buffering off;
78	        proxy_buffering off;
79	        proxy_read_timeout 600s;
80	    }
81
82	    # Capture proxy - forward to capture service
83	    location /capture/ {
84	        set $capture_upstream http://capture:3001;
85	        proxy_pass $capture_upstream;
86	        proxy_http_version 1.1;
87	        proxy_set_header Upgrade $http_upgrade;
88	        proxy_set_header Connection "upgrade";
89	        proxy_set_header Host $host;
90	        proxy_set_header X-Real-IP $remote_addr;
91	        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
92	        proxy_set_header X-Forwarded-Proto $scheme;
93	        proxy_buffering off;
94	        proxy_request_buffering off;
95	    }
96
97	    # SPA fallback - try to serve file, else route to index.html
98	    location / {
99	        try_files $uri $uri/ /home.html;
100	        expires -1;
101	        add_header Cache-Control "no-cache, no-store, must-revalidate";
102	    }
103
104	    # Health check endpoint
105	    location /health {
106	        access_log off;
107	        return 200 "healthy\n";
108	        add_header Content-Type text/plain;
109	    }
110
111	    # Deny access to dotfiles
112	    location ~ /\. {
113	        deny all;
114	    }
115	}
116

server {
    listen 80;
    server_name _;

    # Docker embedded DNS — defers upstream resolution to request time
    # This prevents nginx crashing at startup if sibling containers aren't
    # ready yet (which happens on the first `docker compose up`).
    resolver 127.0.0.11 valid=10s ipv6=off;

    # Allow unlimited client upload size
    client_max_body_size 0;

    # Gzip compression
    gzip on;
    gzip_types text/plain text/css text/javascript application/javascript application/json;
    gzip_min_length 1000;

    # Root location - serve static files
    root /usr/share/nginx/html;

    # Cache static assets aggressively
    location ~* \.(css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
        expires 1y;
        add_header Cache-Control "public, immutable";
    }

    # JS files — must revalidate so a redeploy is picked up immediately.
    # The static index.html links api.js with a ?v=N query string anyway,
    # but defence-in-depth: never let a stale .js sit in a browser cache.
    location ~* \.js$ {
        expires -1;
        add_header Cache-Control "no-cache, must-revalidate";
    }

    # HTML files - no cache
    location ~* \.html?$ {
        expires -1;
        add_header Cache-Control "no-cache, no-store, must-revalidate";
    }

    # Live HLS — served from /live (bind-mounted shared volume), low cache so playlist refreshes
    location /live/ {
        alias /live/;
        types { application/vnd.apple.mpegurl m3u8; video/mp2t ts; }
        add_header Cache-Control "no-cache";
        add_header Access-Control-Allow-Origin *;
    }

    # API proxy - forward to mam-api service
    location /api/ {
        set $api_upstream http://mam-api:3000;
        client_max_body_size 0;
        proxy_pass $api_upstream;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection "upgrade";
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_buffering off;
        proxy_request_buffering off;
        proxy_connect_timeout 300;
        proxy_send_timeout 300;
        proxy_read_timeout 300;
    }

    # Editor proxy - forward to the openreel-video fork
    location /editor/ {
        set $editor_upstream http://editor:80/;
        proxy_pass $editor_upstream;
        proxy_http_version 1.1;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        client_max_body_size 8g;
        proxy_request_buffering off;
        proxy_buffering off;
        proxy_read_timeout 600s;
    }

    # Capture proxy - forward to capture service
    location /capture/ {
        set $capture_upstream http://capture:3001;
        proxy_pass $capture_upstream;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection "upgrade";
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_buffering off;
        proxy_request_buffering off;
    }

    # SPA fallback - try to serve file, else route to index.html
    location / {
        try_files $uri $uri/ /home.html;
        expires -1;
        add_header Cache-Control "no-cache, no-store, must-revalidate";
    }

    # Health check endpoint
    location /health {
        access_log off;
        return 200 "healthy\n";
        add_header Content-Type text/plain;
    }

    # Deny access to dotfiles
    location ~ /\. {
        deny all;
    }
}


Now I have enough context. Reading remaining files in parallel and starting fixes.


1	<!DOCTYPE html>
2
3
4	    <meta charset="UTF-8">
5	    <meta name="viewport" content="width=device-width, initial-scale=1.0">
6	    <title>Wild Dragon MAM</title>
7	    <link rel="stylesheet" href="css/styles.css">
8
9
10

11
12

13

14

15	                    <input
16	                        type="text"
17	                        id="server-url"
18	                        class="server-url"
19	                        placeholder="http://localhost:7434"
20	                        title="MAM server URL"
21	                    >
22

23

24	                <button id="connect-btn" class="connect-btn">Connect</button>
25

26

27
28
29

30

31	                <input
32	                    type="text"
33	                    id="search-input"
34	                    class="search-input"
35	                    placeholder="Search assets..."
36	                >
37

38

39	                <select id="project-filter" title="Filter by project">
40	                    <option value="all">All Projects</option>
41	                </select>
42

43

44
45
46

47	            SEQ
48
49	            <button id="seq-refresh-btn" class="seq-refresh-btn" title="Refresh active sequence">↻</button>
50

51
52
53

54
55

56

57

58
📁

59
Connect to server and load assets

60

61

62

63
64
65

66

67
Filename

68

69

70
71

72
73

74
Codec

75

76

77
78

79
Resolution

80

81

82
83

84
Frame Rate

85

86

87
88

89
Duration

90

91

92
93

94
File Size

95

96

97
98

99
100

101
Tags

102

103

104

105

106
107
108

109
Downloading...

110

111

112

113

114
115
116

117
Push Timeline to MAM

118	            <input
119	                type="text"
120	                id="export-seq-name"
121	                placeholder="Sequence name"
122	                title="Name this sequence will have in the MAM"
123	            >
124	            <select id="export-proj-select" title="Target project in MAM">
125	                <option value="">— Select project —</option>
126	            </select>
127

128

129	                <button id="export-confirm-btn">Push to MAM</button>
130	                <button id="export-cancel-btn" class="secondary">Cancel</button>
131

132

133
134
135

136
137

138	                <button id="import-btn" disabled title="Download proxy and import into Premiere">Import Proxy</button>
139	                <button id="import-hires-btn" class="secondary" disabled title="Download original hi-res and import into Premiere">Hi-Res</button>
140

141
142

143	                <button id="import-all-btn" class="secondary" title="Import all visible assets as proxy">Import All</button>
144	                <button id="export-timeline-btn" class="secondary" title="Push the current Premiere sequence to MAM">Export Timeline ↑</button>
145

146

147

148
149
150
151
152
153

<!DOCTYPE html>

    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Wild Dragon MAM</title>
    <link rel="stylesheet" href="css/styles.css">

                    <input
                        type="text"
                        id="server-url"
                        class="server-url"
                        placeholder="http://localhost:7434"
                        title="MAM server URL"
                    >

                <button id="connect-btn" class="connect-btn">Connect</button>

                <input
                    type="text"
                    id="search-input"
                    class="search-input"
                    placeholder="Search assets..."
                >

                <select id="project-filter" title="Filter by project">
                    <option value="all">All Projects</option>
                </select>

            SEQ

            <button id="seq-refresh-btn" class="seq-refresh-btn" title="Refresh active sequence">↻</button>

📁

Connect to server and load assets

Filename

Codec

Resolution

Frame Rate

Duration

File Size

Tags

Downloading...

Push Timeline to MAM

            <input
                type="text"
                id="export-seq-name"
                placeholder="Sequence name"
                title="Name this sequence will have in the MAM"
            >
            <select id="export-proj-select" title="Target project in MAM">
                <option value="">— Select project —</option>
            </select>

                <button id="export-confirm-btn">Push to MAM</button>
                <button id="export-cancel-btn" class="secondary">Cancel</button>

                <button id="import-btn" disabled title="Download proxy and import into Premiere">Import Proxy</button>
                <button id="import-hires-btn" class="secondary" disabled title="Download original hi-res and import into Premiere">Hi-Res</button>

                <button id="import-all-btn" class="secondary" title="Import all visible assets as proxy">Import All</button>
                <button id="export-timeline-btn" class="secondary" title="Push the current Premiere sequence to MAM">Export Timeline ↑</button>


1	import { join } from 'path';
2	import { unlink } from 'fs/promises';
3	import { tmpdir } from 'os';
4	import { query } from '../db/client.js';
5	import { downloadFromS3, uploadToS3 } from '../s3/client.js';
6	import { extractFrameAtTime, getMediaDuration } from '../ffmpeg/executor.js';
7
8	const S3_BUCKET = process.env.S3_BUCKET || 'wild-dragon';
9
10	/**
11	 * Pick a seek time that won't exceed the clip duration.
12	 * Tries 5s first, then 1s, then 0s as a last resort.
13	 */
14	async function pickSeekTime(inputPath) {
15	  try {
16	    const duration = await getMediaDuration(inputPath);
17	    if (duration >= 5) return '00:00:05';
18	    if (duration >= 1) return '00:00:01';
19	    return '00:00:00';
20	  } catch {
21	    // If ffprobe fails, fall back to 5s and let ffmpeg handle it
22	    return '00:00:05';
23	  }
24	}
25
26	export const thumbnailWorker = async (job) => {
27	  const { assetId, proxyKey, outputKey } = job.data;
28
29	  const tmpDir = tmpdir();
30	  const inputPath  = join(tmpDir, `thumb-input-${job.id}.mp4`);
31	  const outputPath = join(tmpDir, `thumb-output-${job.id}.jpg`);
32
33	  try {
34	    // Download proxy from S3
35	    await job.updateProgress(10);
36	    console.log(`[thumbnail] Downloading ${proxyKey} for asset ${assetId}`);
37	    await downloadFromS3(S3_BUCKET, proxyKey, inputPath);
38
39	    // Pick a safe seek time based on actual clip duration
40	    const seekTime = await pickSeekTime(inputPath);
41
42	    // Extract frame
43	    await job.updateProgress(40);
44	    console.log(`[thumbnail] Extracting frame at ${seekTime} for asset ${assetId}`);
45	    await extractFrameAtTime(inputPath, outputPath, seekTime);
46
47	    // Upload thumbnail to S3
48	    await job.updateProgress(70);
49	    console.log(`[thumbnail] Uploading to ${outputKey}`);
50	    await uploadToS3(S3_BUCKET, outputKey, outputPath);
51
52	    // Update asset: thumbnail key + mark ready
53	    await job.updateProgress(90);
54	    await query(
55	      `UPDATE assets
56	       SET thumbnail_s3_key = $1, status = 'ready', updated_at = NOW()
57	       WHERE id = $2`,
58	      [outputKey, assetId]
59	    );
60
61	    await job.updateProgress(100);
62	    console.log(`[thumbnail] Asset ${assetId} thumbnail complete → status=ready`);
63
64	    return { assetId, outputKey };
65	  } catch (error) {
66	    console.error(`[thumbnail] Error processing asset ${assetId}:`, error);
67	    // Thumbnail failed but the asset is still usable via proxy — mark it ready
68	    // so it doesn't stay in 'processing' state forever.
69	    await query(
70	      `UPDATE assets SET status = 'ready', updated_at = NOW() WHERE id = $1`,
71	      [assetId]
72	    ).catch(e => console.error('[thumbnail] Failed to update asset status:', e));
73	    throw error;
74	  } finally {
75	    await Promise.all([
76	      unlink(inputPath).catch(() => {}),
77	      unlink(outputPath).catch(() => {}),
78	    ]);
79	  }
80	};
81

import { join } from 'path';
import { unlink } from 'fs/promises';
import { tmpdir } from 'os';
import { query } from '../db/client.js';
import { downloadFromS3, uploadToS3 } from '../s3/client.js';
import { extractFrameAtTime, getMediaDuration } from '../ffmpeg/executor.js';

const S3_BUCKET = process.env.S3_BUCKET || 'wild-dragon';

/**
 * Pick a seek time that won't exceed the clip duration.
 * Tries 5s first, then 1s, then 0s as a last resort.
 */
async function pickSeekTime(inputPath) {
  try {
    const duration = await getMediaDuration(inputPath);
    if (duration >= 5) return '00:00:05';
    if (duration >= 1) return '00:00:01';
    return '00:00:00';
  } catch {
    // If ffprobe fails, fall back to 5s and let ffmpeg handle it
    return '00:00:05';
  }
}

export const thumbnailWorker = async (job) => {
  const { assetId, proxyKey, outputKey } = job.data;

  const tmpDir = tmpdir();
  const inputPath  = join(tmpDir, `thumb-input-${job.id}.mp4`);
  const outputPath = join(tmpDir, `thumb-output-${job.id}.jpg`);

  try {
    // Download proxy from S3
    await job.updateProgress(10);
    console.log(`[thumbnail] Downloading ${proxyKey} for asset ${assetId}`);
    await downloadFromS3(S3_BUCKET, proxyKey, inputPath);

    // Pick a safe seek time based on actual clip duration
    const seekTime = await pickSeekTime(inputPath);

    // Extract frame
    await job.updateProgress(40);
    console.log(`[thumbnail] Extracting frame at ${seekTime} for asset ${assetId}`);
    await extractFrameAtTime(inputPath, outputPath, seekTime);

    // Upload thumbnail to S3
    await job.updateProgress(70);
    console.log(`[thumbnail] Uploading to ${outputKey}`);
    await uploadToS3(S3_BUCKET, outputKey, outputPath);

    // Update asset: thumbnail key + mark ready
    await job.updateProgress(90);
    await query(
      `UPDATE assets
       SET thumbnail_s3_key = $1, status = 'ready', updated_at = NOW()
       WHERE id = $2`,
      [outputKey, assetId]
    );

    await job.updateProgress(100);
    console.log(`[thumbnail] Asset ${assetId} thumbnail complete → status=ready`);

    return { assetId, outputKey };
  } catch (error) {
    console.error(`[thumbnail] Error processing asset ${assetId}:`, error);
    // Thumbnail failed but the asset is still usable via proxy — mark it ready
    // so it doesn't stay in 'processing' state forever.
    await query(
      `UPDATE assets SET status = 'ready', updated_at = NOW() WHERE id = $1`,
      [assetId]
    ).catch(e => console.error('[thumbnail] Failed to update asset status:', e));
    throw error;
  } finally {
    await Promise.all([
      unlink(inputPath).catch(() => {}),
      unlink(outputPath).catch(() => {}),
    ]);
  }
};


1	<?xml version="1.0" encoding="UTF-8"?>
2	<ExtensionManifest xmlns="http://ns.adobe.com/CSXS/manifest"
3	                   xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
4	                   xsi:schemaLocation="http://ns.adobe.com/CSXS/manifest http://ns.adobe.com/CSXS/manifest">
5	    <ExtensionBundleVersion>1.0.0</ExtensionBundleVersion>
6	    <ExtensionBundleId>com.wilddragon.mam.panel</ExtensionBundleId>
7	    <Author>Wild Dragon MAM</Author>
8
9	    <License>
10	        <Text>Copyright 2026 Wild Dragon. All rights reserved.</Text>
11	    </License>
12
13	    <AbstractionLayers>
14	        <AbstractionLayerRequest Name="PProPanel"/>
15	    </AbstractionLayers>
16
17	    <Extension Id="com.wilddragon.mam.panel">
18	        <Type>Panel</Type>
19	        <HostList>
20	            <Host Name="PPRO" Version="[22.0,99.9]"/>
21	        </HostList>
22
23	        <LocalizedStrings>
24	            <LocalizedString locale="en_US">
25	                <Token name="ExtensionWindowTitle">Wild Dragon MAM</Token>
26	                <Token name="ExtensionShortDescription">Media Asset Management for Premiere Pro</Token>
27	                <Token name="ExtensionLongDescription">Browse, search, and import proxy files from Wild Dragon MAM directly into your Premiere Pro project</Token>
28	            </LocalizedString>
29	        </LocalizedStrings>
30
31	        <UI>
32	            <Type>Panel</Type>
33	            <Geometry>
34	                <Size>
35	                    <Width>400</Width>
36	                    <Height>700</Height>
37	                </Size>
38	                <MinSize>
39	                    <Width>350</Width>
40	                    <Height>500</Height>
41	                </MinSize>
42	            </Geometry>
43	        </UI>
44
45	        <!--
46	            Resources:
47	            - MainPath:   HTML entry point for the CEP panel UI
48	            - ScriptPath: ExtendScript (.jsx) auto-loaded into the Premiere Pro host
49	            - --enable-nodejs: unlocks Node.js require() for binary file downloads
50	            - --mixed-context: allows Node.js and DOM to share the same V8 context
51	        -->
52	        <Resources>
53	            <MainPath>index.html</MainPath>
54	            <ScriptPath>jsx/premiere.jsx</ScriptPath>
55	            <CEFCommandLine>
56	                <Parameter>--enable-nodejs</Parameter>
57	                <Parameter>--mixed-context</Parameter>
58	            </CEFCommandLine>
59	        </Resources>
60
61	        <CEPVersion>11.0</CEPVersion>
62	        <RequiredRuntimeVersion>11.0</RequiredRuntimeVersion>
63	    </Extension>
64
65	    <ExtensionList/>
66	    <ExecutionEnvironment>
67	        <HostList>
68	            <Host Name="PPRO" Version="[22.0,99.9]"/>
69	        </HostList>
70	    </ExecutionEnvironment>
71	</ExtensionManifest>
72

<?xml version="1.0" encoding="UTF-8"?>
<ExtensionManifest xmlns="http://ns.adobe.com/CSXS/manifest"
                   xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
                   xsi:schemaLocation="http://ns.adobe.com/CSXS/manifest http://ns.adobe.com/CSXS/manifest">
    <ExtensionBundleVersion>1.0.0</ExtensionBundleVersion>
    <ExtensionBundleId>com.wilddragon.mam.panel</ExtensionBundleId>
    <Author>Wild Dragon MAM</Author>

    <License>
        <Text>Copyright 2026 Wild Dragon. All rights reserved.</Text>
    </License>

    <AbstractionLayers>
        <AbstractionLayerRequest Name="PProPanel"/>
    </AbstractionLayers>

    <Extension Id="com.wilddragon.mam.panel">
        <Type>Panel</Type>
        <HostList>
            <Host Name="PPRO" Version="[22.0,99.9]"/>
        </HostList>

        <LocalizedStrings>
            <LocalizedString locale="en_US">
                <Token name="ExtensionWindowTitle">Wild Dragon MAM</Token>
                <Token name="ExtensionShortDescription">Media Asset Management for Premiere Pro</Token>
                <Token name="ExtensionLongDescription">Browse, search, and import proxy files from Wild Dragon MAM directly into your Premiere Pro project</Token>
            </LocalizedString>
        </LocalizedStrings>

        <UI>
            <Type>Panel</Type>
            <Geometry>
                <Size>
                    <Width>400</Width>
                    <Height>700</Height>
                </Size>
                <MinSize>
                    <Width>350</Width>
                    <Height>500</Height>
                </MinSize>
            </Geometry>
        </UI>

        <!--
            Resources:
            - MainPath:   HTML entry point for the CEP panel UI
            - ScriptPath: ExtendScript (.jsx) auto-loaded into the Premiere Pro host
            - --enable-nodejs: unlocks Node.js require() for binary file downloads
            - --mixed-context: allows Node.js and DOM to share the same V8 context
        -->
        <Resources>
            <MainPath>index.html</MainPath>
            <ScriptPath>jsx/premiere.jsx</ScriptPath>
            <CEFCommandLine>
                <Parameter>--enable-nodejs</Parameter>
                <Parameter>--mixed-context</Parameter>
            </CEFCommandLine>
        </Resources>

        <CEPVersion>11.0</CEPVersion>
        <RequiredRuntimeVersion>11.0</RequiredRuntimeVersion>
    </Extension>

    <ExtensionList/>
    <ExecutionEnvironment>
        <HostList>
            <Host Name="PPRO" Version="[22.0,99.9]"/>
        </HostList>
    </ExecutionEnvironment>
</ExtensionManifest>


Plan locked in. Starting fixes. First, the proxy worker that's causing the 12 failures.


Edit

export const transcodeVideo = async (inputPath, outputPath, options = {}) => {
  const {
    videoCodec = 'libx264',
    videoPreset = 'fast',
    videoBitrate = '10M',
    audioCodec = 'aac',
    audioBitrate = '192k',
  } = options;

  const args = [
    '-i', inputPath,
    '-c:v', videoCodec,
    '-preset', videoPreset,
    '-b:v', videoBitrate,
    '-c:a', audioCodec,
    '-b:a', audioBitrate,
    '-movflags', '+faststart',
    '-y',
    outputPath,
  ];

  await runFFmpeg(args);
};

export const transcodeVideo = async (inputPath, outputPath, options = {}) => {
  const {
    videoCodec = 'libx264',
    videoPreset = 'fast',
    videoBitrate = '10M',
    audioCodec = 'aac',
    audioBitrate = '192k',
    hasAudio = true,
  } = options;

  // libx264 / yuv420p require even width AND height. Captured frames from SDI
  // or upstream uploads sometimes arrive odd-sized (e.g. 1243x1125). Force-even
  // before pixel-format conversion so the encoder never sees odd dimensions.
  const vf = "scale='trunc(iw/2)*2:trunc(ih/2)*2',format=yuv420p";

  // analyzeduration/probesize must be set BEFORE -i. Some ProRes captures
  // write unusual timebases (60k tbn) that ffmpeg cannot resolve with the
  // default 5MB probe — bump to 100MB so we always read enough of the file.
  const args = [
    '-analyzeduration', '100M',
    '-probesize',       '100M',
    '-i', inputPath,
    '-vf', vf,
    '-c:v', videoCodec,
    '-preset', videoPreset,
    '-b:v', videoBitrate,
  ];

  if (hasAudio) {
    args.push('-c:a', audioCodec, '-b:a', audioBitrate);
  } else {
    args.push('-an');
  }

  args.push('-movflags', '+faststart', '-y', outputPath);

  await runFFmpeg(args);
};

// Single-frame poster — used as a fallback "proxy" for still-image assets
// so the library can show them without a transcoded video.
export const transcodeImage = async (inputPath, outputPath) => {
  await runFFmpeg([
    '-i', inputPath,
    '-vf', "scale='min(1920,iw)':-2",
    '-q:v', '3',
    '-y', outputPath,
  ]);
};


The file /tmp/wd/wild-dragon/services/worker/src/ffmpeg/executor.js has been updated successfully. (file state is current in your context — no need to Read it back)

export const transcodeVideo = async (inputPath, outputPath, options = {}) => {
  const {
    videoCodec = 'libx264',
    videoPreset = 'fast',
    videoBitrate = '10M',
    audioCodec = 'aac',
    audioBitrate = '192k',
    hasAudio = true,
  } = options;

  // libx264 / yuv420p require even width AND height. Captured frames from SDI
  // or upstream uploads sometimes arrive odd-sized (e.g. 1243x1125). Force-even
  // before pixel-format conversion so the encoder never sees odd dimensions.
  const vf = "scale='trunc(iw/2)*2:trunc(ih/2)*2',format=yuv420p";

  // analyzeduration/probesize must be set BEFORE -i. Some ProRes captures
  // write unusual timebases (60k tbn) that ffmpeg cannot resolve with the
  // default 5MB probe — bump to 100MB so we always read enough of the file.
  const args = [
    '-analyzeduration', '100M',
    '-probesize',       '100M',
    '-i', inputPath,
    '-vf', vf,
    '-c:v', videoCodec,
    '-preset', videoPreset,
    '-b:v', videoBitrate,
  ];

  if (hasAudio) {
    args.push('-c:a', audioCodec, '-b:a', audioBitrate);
  } else {
    args.push('-an');
  }

  args.push('-movflags', '+faststart', '-y', outputPath);

  await runFFmpeg(args);
};

// Single-frame poster — used as a fallback "proxy" for still-image assets
// so the library can show them without a transcoded video.
export const transcodeImage = async (inputPath, outputPath) => {
  await runFFmpeg([
    '-i', inputPath,
    '-vf', "scale='min(1920,iw)':-2",
    '-q:v', '3',
    '-y', outputPath,
  ]);
};

import { execFile } from 'child_process';
import { promisify } from 'util';

const execFileAsync = promisify(execFile);

export const runFFmpeg = async (args, options = {}) => {
  const { stdio = 'pipe', ...otherOptions } = options;
  try {
    const { stdout, stderr } = await execFileAsync('ffmpeg', args, {
      stdio,
      ...otherOptions,
    });
    return { stdout, stderr };
  } catch (error) {
    throw new Error(`FFmpeg error: ${error.message}\nStderr: ${error.stderr}`);
  }
};

const runFFprobe = async (args) => {
  try {
    const { stdout } = await execFileAsync('ffprobe', args);
    return { stdout };
  } catch (error) {
    throw new Error(`FFprobe error: ${error.message}\nStderr: ${error.stderr}`);
  }
};

export const getMediaDuration = async (inputPath) => {
  const args = [
    '-v', 'error',
    '-show_entries', 'format=duration',
    '-of', 'default=noprint_wrappers=1:nokey=1',
    inputPath,
  ];
  const { stdout } = await runFFprobe(args);
  return parseFloat(stdout.trim());
};

/**
 * Return structured media metadata for an input file.
 * Result shape:
 *   { fps, codec, resolution, durationMs, fileSizeBytes }
 * Any field may be null if ffprobe cannot determine it.
 */
export const getMediaInfo = async (inputPath) => {
  const args = [
    '-v', 'quiet',
    '-print_format', 'json',
    '-show_streams',
    '-show_format',
    inputPath,
  ];
  const { stdout } = await runFFprobe(args);
  const info = JSON.parse(stdout);

  const videoStream = (info.streams || []).find(s => s.codec_type === 'video');
  const fmt         = info.format || {};

  let fps = null;
  if (videoStream?.r_frame_rate) {
    const [num, den] = videoStream.r_frame_rate.split('/').map(Number);
    if (den > 0) fps = Math.round((num / den) * 1000) / 1000;
  }

  return {
    fps,
    codec:          videoStream?.codec_name  || null,
    resolution:     videoStream ? `${videoStream.width}x${videoStream.height}` : null,
    durationMs:     fmt.duration ? Math.round(parseFloat(fmt.duration) * 1000) : null,
    fileSizeBytes:  fmt.size     ? parseInt(fmt.size, 10)                       : null,
  };
};

export const extractFrameAtTime = async (inputPath, outputPath, timeCode) => {
  const args = [
    '-ss', timeCode,
    '-i', inputPath,
    '-vframes', '1',
    '-q:v', '2',
    '-y',
    outputPath,
  ];
  await runFFmpeg(args);
};

export const transcodeVideo = async (inputPath, outputPath, options = {}) => {
  const {
    videoCodec = 'libx264',
    videoPreset = 'fast',
    videoBitrate = '10M',
    audioCodec = 'aac',
    audioBitrate = '192k',
  } = options;

  const args = [
    '-i', inputPath,
    '-c:v', videoCodec,
    '-preset', videoPreset,
    '-b:v', videoBitrate,
    '-c:a', audioCodec,
    '-b:a', audioBitrate,
    '-movflags', '+faststart',
    '-y',
    outputPath,
  ];

  await runFFmpeg(args);
};

export const trimSegment = async (inputPath, outputPath, inPoint, outPoint) => {
  const args = [
    '-i', inputPath,
    '-ss', inPoint,
    '-to', outPoint,
    '-c', 'copy',
    '-y',
    outputPath,
  ];
  await runFFmpeg(args);
};

export const concatSegments = async (segmentListFile, outputPath) => {
  const args = [
    '-f', 'concat',
    '-safe', '0',
    '-i', segmentListFile,
    '-c', 'copy',
    '-y',
    outputPath,
  ];
  await runFFmpeg(args);
};

+  // libx264 / yuv420p require even width AND height. Captured frames from SDI

+  // or upstream uploads sometimes arrive odd-sized (e.g. 1243x1125). Force-even

+  // before pixel-format conversion so the encoder never sees odd dimensions.

+  const vf = "scale='trunc(iw/2)*2:trunc(ih/2)*2',format=yuv420p";

+  // analyzeduration/probesize must be set BEFORE -i. Some ProRes captures

+  // write unusual timebases (60k tbn) that ffmpeg cannot resolve with the

+  // default 5MB probe — bump to 100MB so we always read enough of the file.

+    args.push('-c:a', audioCodec, '-b:a', audioBitrate);

+  args.push('-movflags', '+faststart', '-y', outputPath);

+// Single-frame poster — used as a fallback "proxy" for still-image assets

+// so the library can show them without a transcoded video.

+export const transcodeImage = async (inputPath, outputPath) => {


import { transcodeVideo, getMediaInfo } from '../ffmpeg/executor.js';

import { transcodeVideo, transcodeImage, getMediaInfo } from '../ffmpeg/executor.js';

// Codec names ffprobe reports for still-image inputs. These bypass the video
// transcode entirely — see proxyWorker below.
const IMAGE_CODECS = new Set(['png', 'mjpeg', 'jpeg', 'webp', 'gif', 'tiff', 'bmp', 'jpegls']);


The file /tmp/wd/wild-dragon/services/worker/src/workers/proxy.js has been updated successfully. (file state is current in your context — no need to Read it back)

import { transcodeVideo, transcodeImage, getMediaInfo } from '../ffmpeg/executor.js';

// Codec names ffprobe reports for still-image inputs. These bypass the video
// transcode entirely — see proxyWorker below.
const IMAGE_CODECS = new Set(['png', 'mjpeg', 'jpeg', 'webp', 'gif', 'tiff', 'bmp', 'jpegls']);

import { join } from 'path';
import { unlink } from 'fs/promises'
import { tmpdir } from 'os';
import { Queue } from 'bullmq';
import { query } from '../db/client.js';
import { downloadFromS3, uploadToS3 } from '../s3/client.js';
import { transcodeVideo, getMediaInfo } from '../ffmpeg/executor.js';

const S3_BUCKET = process.env.S3_BUCKET || 'wild-dragon';

// Dispatch thumbnail job once proxy is ready — same Redis connection as the worker
const parseRedisUrl = (url) => {
  const parsed = new URL(url);
  return { host: parsed.hostname, port: parseInt(parsed.port, 10) };
};

const thumbnailQueue = new Queue('thumbnail', {
  connection: parseRedisUrl(process.env.REDIS_URL || 'redis://queue:6379'),
});

export const proxyWorker = async (job) => {
  const { assetId, inputKey, outputKey } = job.data;

  const tmpDir = tmpdir();
  const inputPath  = join(tmpDir, `proxy-input-${job.id}.mov`);
  const outputPath = join(tmpDir, `proxy-output-${job.id}.mp4`);

  try {
    // Download original from S3
    await job.updateProgress(10);
    console.log(`[proxy] Downloading ${inputKey} for asset ${assetId}`);
    await downloadFromS3(S3_BUCKET, inputKey, inputPath);

    // Extract source metadata (fps, codec, resolution, duration, size)
    await job.updateProgress(20);
    let mediaInfo = {};
    try {
      mediaInfo = await getMediaInfo(inputPath);
      console.log(`[proxy] Metadata for ${assetId}: ${JSON.stringify(mediaInfo)}`);
    } catch (err) {
      console.warn(`[proxy] getMediaInfo failed for ${assetId}: ${err.message}`);
    }

    // Transcode to H.264 proxy
    await job.updateProgress(30);
    console.log(`[proxy] Transcoding asset ${assetId}`);
    await transcodeVideo(inputPath, outputPath, {
      videoCodec:   'libx264',
      videoPreset:  'fast',
      videoBitrate: '10M',
      audioCodec:   'aac',
      audioBitrate: '192k',
    });

    // Upload proxy to S3
    await job.updateProgress(70);
    console.log(`[proxy] Uploading to ${outputKey}`);
    await uploadToS3(S3_BUCKET, outputKey, outputPath);

    // Update asset record — store extracted metadata + proxy key
    // Use COALESCE so we never overwrite fields the capture service already set
    await job.updateProgress(90);
    await query(
      `UPDATE assets
       SET proxy_s3_key = $1,
           fps          = COALESCE($2, fps),
           codec        = COALESCE($3, codec),
           resolution   = COALESCE($4, resolution),
           duration_ms  = COALESCE($5, duration_ms),
           file_size    = COALESCE($6, file_size),
           updated_at   = NOW()
       WHERE id = $7`,
      [
        outputKey,
        mediaInfo.fps          ?? null,
        mediaInfo.codec        ?? null,
        mediaInfo.resolution   ?? null,
        mediaInfo.durationMs   ?? null,
        mediaInfo.fileSizeBytes ?? null,
        assetId,
      ]
    );

    // Now proxy exists in S3 — safe to queue thumbnail generation
    const thumbnailKey = `thumbnails/${assetId}.jpg`;
    await thumbnailQueue.add('generate', {
      assetId,
      proxyKey:  outputKey,
      outputKey: thumbnailKey,
    });

    console.log(`[proxy] Asset ${assetId} proxy complete, thumbnail job queued`);
    await job.updateProgress(100);

    return { assetId, outputKey };
  } catch (error) {
    console.error(`[proxy] Error processing asset ${assetId}:`, error);
    await query(
      `UPDATE assets SET status = 'error', updated_at = NOW() WHERE id = $1`,
      [assetId]
    );
    throw error;
  } finally {
    await Promise.all([
      unlink(inputPath).catch(() => {}),
      unlink(outputPath).catch(() => {}),
    ]);
  }
};

-import { transcodeVideo, getMediaInfo } from '../ffmpeg/executor.js';

+import { transcodeVideo, transcodeImage, getMediaInfo } from '../ffmpeg/executor.js';

+// Codec names ffprobe reports for still-image inputs. These bypass the video

+// transcode entirely — see proxyWorker below.

+const IMAGE_CODECS = new Set(['png', 'mjpeg', 'jpeg', 'webp', 'gif', 'tiff', 'bmp', 'jpegls']);


    // Extract source metadata (fps, codec, resolution, duration, size)
    await job.updateProgress(20);
    let mediaInfo = {};
    try {
      mediaInfo = await getMediaInfo(inputPath);
      console.log(`[proxy] Metadata for ${assetId}: ${JSON.stringify(mediaInfo)}`);
    } catch (err) {
      console.warn(`[proxy] getMediaInfo failed for ${assetId}: ${err.message}`);
    }

    // Transcode to H.264 proxy
    await job.updateProgress(30);
    console.log(`[proxy] Transcoding asset ${assetId}`);
    await transcodeVideo(inputPath, outputPath, {
      videoCodec:   'libx264',
      videoPreset:  'fast',
      videoBitrate: '10M',
      audioCodec:   'aac',
      audioBitrate: '192k',
    });

    // Extract source metadata (fps, codec, resolution, duration, size, audio)
    await job.updateProgress(20);
    let mediaInfo = {};
    let hasAudio = true;
    try {
      mediaInfo = await getMediaInfo(inputPath);
      hasAudio = !!mediaInfo.hasAudio;
      console.log(`[proxy] Metadata for ${assetId}: ${JSON.stringify(mediaInfo)}`);
    } catch (err) {
      console.warn(`[proxy] getMediaInfo failed for ${assetId}: ${err.message}`);
    }

    // Still images skip the video proxy — they have no temporal stream and
    // x264 with a one-frame PNG input fails (Could not open encoder before EOF).
    // Generate a scaled JPEG poster instead; the thumbnail job will downsize it.
    const isImage = mediaInfo.codec && IMAGE_CODECS.has(mediaInfo.codec.toLowerCase());

    if (isImage) {
      const imageOutputKey = outputKey.replace(/\.mp4$/, '.jpg');
      const imageOutputPath = outputPath.replace(/\.mp4$/, '.jpg');
      console.log(`[proxy] Image asset ${assetId} (${mediaInfo.codec}) — emitting poster instead of video proxy`);
      await job.updateProgress(40);
      await transcodeImage(inputPath, imageOutputPath);
      await job.updateProgress(70);
      await uploadToS3(S3_BUCKET, imageOutputKey, imageOutputPath);
      await job.updateProgress(90);
      await query(
        `UPDATE assets
         SET thumbnail_s3_key = $1,
             proxy_s3_key     = NULL,
             resolution       = COALESCE($2, resolution),
             file_size        = COALESCE($3, file_size),
             status           = 'ready',
             updated_at       = NOW()
         WHERE id = $4`,
        [imageOutputKey, mediaInfo.resolution ?? null, mediaInfo.fileSizeBytes ?? null, assetId]
      );
      await unlink(imageOutputPath).catch(() => {});
      await job.updateProgress(100);
      return { assetId, outputKey: imageOutputKey };
    }

    // Empty/truncated capture: probe returned a video stream but ffmpeg can't
    // read any frames. Bail with a clear message instead of dumping ~3KB of
    // ffmpeg stderr into the failed-jobs list.
    if (mediaInfo.durationMs === null && mediaInfo.codec) {
      throw new Error(
        `Empty or truncated source: codec=${mediaInfo.codec}, ` +
        `resolution=${mediaInfo.resolution || 'unknown'}, no readable frames.`
      );
    }

    // Transcode to H.264 proxy
    await job.updateProgress(30);
    console.log(`[proxy] Transcoding asset ${assetId}`);
    await transcodeVideo(inputPath, outputPath, {
      videoCodec:   'libx264',
      videoPreset:  'fast',
      videoBitrate: '10M',
      audioCodec:   'aac',
      audioBitrate: '192k',
      hasAudio,
    });