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 { join } from 'path';
import { stat, unlink } from 'fs/promises';
import { tmpdir } from 'os';

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, transcodeImage, getMediaInfo, isHwCodec } from '../ffmpeg/executor.js';

// Read the global proxy-encoder settings from the DB. These are written by
// Settings → Proxy encoding in the GUI. Falls back to libx264 defaults if
// nothing is configured (first-run / fresh install).
async function loadProxyEncodingSettings() {
  const result = await query(
    `SELECT key, value FROM settings
     WHERE key IN ('gpu_transcode_enabled','gpu_codec','gpu_preset','gpu_bitrate_mbps',
                   'gpu_rc_mode','gpu_audio_codec','gpu_audio_bitrate_kbps')`
  );
  const map = {};
  for (const { key, value } of result.rows) map[key] = value;

  const gpuEnabled = map.gpu_transcode_enabled === 'true';
  const codec      = map.gpu_codec || (gpuEnabled ? 'h264_nvenc' : 'libx264');
  const preset     = map.gpu_preset || (gpuEnabled ? 'p4' : 'fast');
  const bitrateM   = parseInt(map.gpu_bitrate_mbps || '10', 10);
  const rcMode     = map.gpu_rc_mode || null;
  const audioCodec = map.gpu_audio_codec || 'aac';
  const audioKbps  = parseInt(map.gpu_audio_bitrate_kbps || '192', 10);

  return {
    videoCodec:   codec,
    videoPreset:  preset,
    videoBitrate: `${bitrateM}M`,
    rateControl:  rcMode,
    audioCodec,
    audioBitrate: `${audioKbps}k`,
    _gpu:         gpuEnabled && isHwCodec(codec),
  };
}

// 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']);

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, 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 using the operator-configured encoder. The proxy worker is
    // the only consumer of these settings; recorders manage their own codecs.
    await job.updateProgress(30);
    const encSettings = await loadProxyEncodingSettings();
    console.log(
      `[proxy] Transcoding asset ${assetId} via ${encSettings._gpu ? 'GPU' : 'CPU'} ` +
      `(${encSettings.videoCodec} ${encSettings.videoPreset} ${encSettings.videoBitrate})`
    );
    try {
      await transcodeVideo(inputPath, outputPath, { ...encSettings, hasAudio });
    } catch (err) {
      if (encSettings._gpu) {
        // Hardware encoder failed — typically "no NVIDIA driver" or "VAAPI
        // device not found". Fall back to libx264 so the job doesn't fail
        // when the worker host has no GPU.
        console.warn(`[proxy] GPU encode failed (${err.message}); falling back to libx264`);
        await transcodeVideo(inputPath, outputPath, {
          videoCodec: 'libx264', videoPreset: 'fast',
          videoBitrate: encSettings.videoBitrate,
          audioCodec: encSettings.audioCodec, audioBitrate: encSettings.audioBitrate,
          hasAudio,
        });
      } else { throw err; }
    }

    // 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 { stat, unlink } from 'fs/promises';


Edit

  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, audio)
    await job.updateProgress(20);

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

    // Reject obviously-empty inputs before handing them to ffmpeg. Aborted
    // SRT/RTMP recordings end up as 0-byte (or ftyp-only ~1KB) objects in S3
    // when the source disconnects before any frame is received; the proxy
    // pipeline used to bomb on "moov atom not found", which buried the
    // real reason. Bail with a clear message and let the asset go to 'error'.
    const { size: inputBytes } = await stat(inputPath);
    if (inputBytes < 4096) {
      throw new Error(
        `Source is empty or truncated (${inputBytes} bytes). The recording ` +
        `likely ended before any frames were received — check the source ` +
        `URL / SDI signal and re-record.`
      );
    }

    // Extract source metadata (fps, codec, resolution, duration, size, audio)
    await job.updateProgress(20);


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

    // Reject obviously-empty inputs before handing them to ffmpeg. Aborted
    // SRT/RTMP recordings end up as 0-byte (or ftyp-only ~1KB) objects in S3
    // when the source disconnects before any frame is received; the proxy
    // pipeline used to bomb on "moov atom not found", which buried the
    // real reason. Bail with a clear message and let the asset go to 'error'.
    const { size: inputBytes } = await stat(inputPath);
    if (inputBytes < 4096) {
      throw new Error(
        `Source is empty or truncated (${inputBytes} bytes). The recording ` +
        `likely ended before any frames were received — check the source ` +
        `URL / SDI signal and re-record.`
      );
    }

    // Extract source metadata (fps, codec, resolution, duration, size, audio)
    await job.updateProgress(20);

import { join } from 'path';
import { stat, 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, transcodeImage, getMediaInfo, isHwCodec } from '../ffmpeg/executor.js';

// Read the global proxy-encoder settings from the DB. These are written by
// Settings → Proxy encoding in the GUI. Falls back to libx264 defaults if
// nothing is configured (first-run / fresh install).
async function loadProxyEncodingSettings() {
  const result = await query(
    `SELECT key, value FROM settings
     WHERE key IN ('gpu_transcode_enabled','gpu_codec','gpu_preset','gpu_bitrate_mbps',
                   'gpu_rc_mode','gpu_audio_codec','gpu_audio_bitrate_kbps')`
  );
  const map = {};
  for (const { key, value } of result.rows) map[key] = value;

  const gpuEnabled = map.gpu_transcode_enabled === 'true';
  const codec      = map.gpu_codec || (gpuEnabled ? 'h264_nvenc' : 'libx264');
  const preset     = map.gpu_preset || (gpuEnabled ? 'p4' : 'fast');
  const bitrateM   = parseInt(map.gpu_bitrate_mbps || '10', 10);
  const rcMode     = map.gpu_rc_mode || null;
  const audioCodec = map.gpu_audio_codec || 'aac';
  const audioKbps  = parseInt(map.gpu_audio_bitrate_kbps || '192', 10);

  return {
    videoCodec:   codec,
    videoPreset:  preset,
    videoBitrate: `${bitrateM}M`,
    rateControl:  rcMode,
    audioCodec,
    audioBitrate: `${audioKbps}k`,
    _gpu:         gpuEnabled && isHwCodec(codec),
  };
}

// 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']);

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, 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 using the operator-configured encoder. The proxy worker is
    // the only consumer of these settings; recorders manage their own codecs.
    await job.updateProgress(30);
    const encSettings = await loadProxyEncodingSettings();
    console.log(
      `[proxy] Transcoding asset ${assetId} via ${encSettings._gpu ? 'GPU' : 'CPU'} ` +
      `(${encSettings.videoCodec} ${encSettings.videoPreset} ${encSettings.videoBitrate})`
    );
    try {
      await transcodeVideo(inputPath, outputPath, { ...encSettings, hasAudio });
    } catch (err) {
      if (encSettings._gpu) {
        // Hardware encoder failed — typically "no NVIDIA driver" or "VAAPI
        // device not found". Fall back to libx264 so the job doesn't fail
        // when the worker host has no GPU.
        console.warn(`[proxy] GPU encode failed (${err.message}); falling back to libx264`);
        await transcodeVideo(inputPath, outputPath, {
          videoCodec: 'libx264', videoPreset: 'fast',
          videoBitrate: encSettings.videoBitrate,
          audioCodec: encSettings.audioCodec, audioBitrate: encSettings.audioBitrate,
          hasAudio,
        });
      } else { throw err; }
    }

    // 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(() => {}),
    ]);
  }
};

+    // Reject obviously-empty inputs before handing them to ffmpeg. Aborted

+    // SRT/RTMP recordings end up as 0-byte (or ftyp-only ~1KB) objects in S3

+    // when the source disconnects before any frame is received; the proxy

+    // pipeline used to bomb on "moov atom not found", which buried the

+    // real reason. Bail with a clear message and let the asset go to 'error'.

+    const { size: inputBytes } = await stat(inputPath);

+        `Source is empty or truncated (${inputBytes} bytes). The recording ` +

+        `URL / SDI signal and re-record.`


### 2. Bins API + asset move endpoint


Bash

cd /tmp/wd/wild-dragon && grep -n "router.\(get\|post\|put\|patch\|delete\)" services/mam-api/src/routes/bins.js 2>&1 | head; echo ---; grep -n "router.patch.*:id\|bin_id\|display_name" services/mam-api/src/routes/assets.js | head

Bins API surface + assets PATCH


11:router.get('/', async (req, res, next) => {
31:router.post('/', async (req, res, next) => {
55:router.patch('/:id', async (req, res, next) => {
101:router.delete('/:id', async (req, res, next) => {
121:router.post('/:id/assets', async (req, res, next) => {
153:router.delete('/:id/assets/:assetId', async (req, res, next) => {
---
32:      bin_id,
59:    if (bin_id) {
60:      query += ` AND a.bin_id = $${paramCount++}`;
61:      params.push(bin_id);
75:      query += ` AND (a.display_name ILIKE $${paramCount} OR a.filename ILIKE $${paramCount} OR a.notes ILIKE $${paramCount})`;
117:         WHERE project_id = $1 AND display_name = $2 AND status = 'live'
132:               bin_id          = COALESCE(bin_id, $5),
143:          id, project_id, bin_id,
144:          filename, display_name,
189:      console.log(`[assets] queued proxy for ${id} (${asset.display_name})`);
Shell cwd was reset to /home/node


cd /tmp/wd/wild-dragon && sed -n '256,295p' services/mam-api/src/routes/assets.js

Read existing assets PATCH


    const result = await pool.query(
      `UPDATE assets SET ${updates.join(', ')} WHERE id = $${paramCount} RETURNING *`,
      params
    );
    if (result.rows.length === 0) return res.status(404).json({ error: 'Asset not found' });
    res.json(result.rows[0]);
  } catch (err) {
    next(err);
  }
});

// POST /:id/copy
router.post('/:id/copy', async (req, res, next) => {
  try {
    const { id } = req.params;
    const { binId, projectId } = req.body;
    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 src = r.rows[0];
    const newId = uuidv4();
    const ins = await pool.query(
      `INSERT INTO assets (
        id, project_id, bin_id, filename, display_name,
        status, media_type, original_s3_key, proxy_s3_key, thumbnail_s3_key,
        codec, resolution, fps, duration_ms, start_tc, file_size, tags, notes,
        created_at, updated_at
      ) VALUES (
        $1, $2, $3, $4, $5,
        $6, $7, $8, $9, $10,
        $11, $12, $13, $14, $15, $16, $17, $18,
        NOW(), NOW()
      ) RETURNING *`,
      [
        newId,
        projectId || src.project_id,
        binId === undefined ? src.bin_id : (binId || null),
        src.filename, src.display_name,
        src.status, src.media_type,
        src.original_s3_key, src.proxy_s3_key, src.thumbnail_s3_key,
        src.codec, src.resolution, src.fps, src.duration_ms, src.start_tc,
Shell cwd was reset to /home/node


getting a lot of proxy failures - FFmpeg error: Command failed: ffmpeg -analyzeduration 100M -probesize 100M -i /tmp/proxy-input-45.mov -vf scale='trunc(iw/2)*2:trunc(ih/2)*2',format=yuv420p -c:v libx…


Debug ingest, monitors, buttons and prototype file editing


cd /tmp/wd/wild-dragon && sed -n '232,260p' services/mam-api/src/routes/assets.js

Read PATCH /:id handler for assets


    next(err);
  }
});

// PATCH /:id
router.patch('/:id', async (req, res, next) => {
  try {
    const { id } = req.params;
    const { display_name, tags, notes, bin_id } = req.body;

    const updates = [];
    const params = [];
    let paramCount = 1;

    if (display_name !== undefined) { updates.push(`display_name = $${paramCount++}`); params.push(display_name); }
    if (tags        !== undefined) { updates.push(`tags = $${paramCount++}`);         params.push(tags); }
    if (notes       !== undefined) { updates.push(`notes = $${paramCount++}`);        params.push(notes); }
    if (bin_id      !== undefined) { updates.push(`bin_id = $${paramCount++}`);       params.push(bin_id || null); }

    if (updates.length === 0) return res.status(400).json({ error: 'No fields to update' });

    updates.push(`updated_at = NOW()`);
    params.push(id);

    const result = await pool.query(
      `UPDATE assets SET ${updates.join(', ')} WHERE id = $${paramCount} RETURNING *`,
      params
    );
    if (result.rows.length === 0) return res.status(404).json({ error: 'Asset not found' });
Shell cwd was reset to /home/node


Backend already supports bin_id move and hard delete. Now find the library asset card to add context menu:


cd /tmp/wd/wild-dragon && grep -n "function Library\|function AssetCard\|onContextMenu\|asset-card\|onOpenAsset" services/web-ui/public/screens-library.jsx | head -15

Inspect library structure


3:function Library({ navigate, onOpenAsset, openProject }) {
97:            {assets.map(function(a) { return <AssetCard key={a.id} asset={a} onOpen={function() { onOpenAsset(a); }} />; })}
106:

131:function AssetCard({ asset, onOpen }) {
169:

Shell cwd was reset to /home/node


Proxy worker: fail fast on empty/tiny S3 inputs

Find bins API + asset.bin_id update path

Library asset right-click menu (delete, move to bin)

Optional: skip upload for 0-byte captures on the capture side

Commit, push, redeploy, smoke


1	// screens-library.jsx
2
3	function Library({ navigate, onOpenAsset, openProject }) {
4	  const { ASSETS: ALL_ASSETS, BINS, PROJECTS } = window.ZAMPP_DATA;
5	  const [view, setView] = React.useState('grid');
6	  const [filter, setFilter] = React.useState('all');
7	  const [search, setSearch] = React.useState('');
8
9	  let assets = openProject
10	    ? ALL_ASSETS.filter(function(a) { return a.project_id === openProject.id; })
11	    : ALL_ASSETS;
12	  if (filter !== 'all') assets = assets.filter(function(a) { return a.status === filter; });
13	  if (search) assets = assets.filter(function(a) { return a.name.toLowerCase().includes(search.toLowerCase()); });
14
15	  const displayTitle = openProject ? openProject.name : 'All Assets';
16	  const errorCount = ALL_ASSETS.filter(function(a) { return a.status === 'error'; }).length;
17	  const recentCount = ALL_ASSETS.filter(function(a) { return (Date.now() - new Date(a.created_at)) < 86400000; }).length;
18
19	  return (
20

21
22

23
Projects

24

25	            <div className={`rail-item ${!openProject ? 'active' : ''}`} onClick={function() { navigate('library'); }} style={{ cursor: 'pointer' }}>
26	              <Icon name="library" size={13} className="rail-icon" />
27	              All projects
28	              {ALL_ASSETS.length}
29

30	            {PROJECTS.slice(0, 8).map(function(p) {
31	              return (
32	                <div key={p.id} className={`rail-item ${openProject && openProject.id === p.id ? 'active' : ''}`} style={{ cursor: 'pointer' }}
33	                  onClick={function() { navigate('projects'); }}>
34
35	                  {p.name}
36	                  {p.assets}
37

38	              );
39	            })}
40

41

42	        {BINS.length > 0 && (
43

44
Bins

45

46	              {BINS.map(function(b) {
47	                return (
48

49	                    <Icon name={binIcon(b.icon)} size={13} className="rail-icon" />
50	                    {b.name}
51	                    {b.count}
52

53	                );
54	              })}
55

56

57	        )}
58

59
Smart filters

60

61	            {errorCount > 0 &&
<Icon name="alert" size={13} className="rail-icon" />Errors{errorCount}
}
62
<Icon name="clock" size={13} className="rail-icon" />Last 24h{recentCount}

63
<Icon name="check" size={13} className="rail-icon" />Ready{ALL_ASSETS.filter(function(a) { return a.status === 'ready'; }).length}

64

65

66
67
68

69

70
{displayTitle}

71	          · {assets.length} assets
72

73

74	            <Icon name="search" className="search-icon" />
75	            <input value={search} onChange={function(e) { setSearch(e.target.value); }} placeholder="Filter assets…" />
76

77

78	            {['all', 'ready', 'processing', 'live', 'error'].map(function(f) {
79	              return (
80	                <button key={f} className={filter === f ? 'active' : ''} onClick={function() { setFilter(f); }}>
81	                  {f === 'all' ? 'All' : f[0].toUpperCase() + f.slice(1)}
82	                </button>
83	              );
84	            })}
85

86

87	            <button className={view === 'grid' ? 'active' : ''} onClick={function() { setView('grid'); }}><Icon name="grid" size={12} /></button>
88	            <button className={view === 'list' ? 'active' : ''} onClick={function() { setView('list'); }}><Icon name="list" size={12} /></button>
89

90	          <button className="btn primary" onClick={function() { navigate('upload'); }}><Icon name="upload" />Upload</button>
91

92
93	        {assets.length === 0 ? (
94
No assets match this filter.

95	        ) : view === 'grid' ? (
96

97	            {assets.map(function(a) { return <AssetCard key={a.id} asset={a} onOpen={function() { onOpenAsset(a); }} />; })}
98

99	        ) : (
100

101

102

Name

Duration

Resolution

Codec

Size

Updated

103

104	            {assets.map(function(a) {
105	              return (
106

107
<AssetThumb asset={a} />

108

109
{a.name}

110

111	                      <StatusDot status={a.status} />
112	                      {a.status}
113

114

115
{a.duration}

116
{a.res}

117
{a.codec || '—'}

118
{a.size}

119
{a.updated}

120	                  <button className="icon-btn" onClick={function(e) { e.stopPropagation(); }}><Icon name="more" /></button>
121

122	              );
123	            })}
124

125	        )}
126

127

128	  );
129	}
130

// screens-library.jsx

function Library({ navigate, onOpenAsset, openProject }) {
  const { ASSETS: ALL_ASSETS, BINS, PROJECTS } = window.ZAMPP_DATA;
  const [view, setView] = React.useState('grid');
  const [filter, setFilter] = React.useState('all');
  const [search, setSearch] = React.useState('');

  let assets = openProject
    ? ALL_ASSETS.filter(function(a) { return a.project_id === openProject.id; })
    : ALL_ASSETS;
  if (filter !== 'all') assets = assets.filter(function(a) { return a.status === filter; });
  if (search) assets = assets.filter(function(a) { return a.name.toLowerCase().includes(search.toLowerCase()); });

  const displayTitle = openProject ? openProject.name : 'All Assets';
  const errorCount = ALL_ASSETS.filter(function(a) { return a.status === 'error'; }).length;
  const recentCount = ALL_ASSETS.filter(function(a) { return (Date.now() - new Date(a.created_at)) < 86400000; }).length;

  return (

Projects

            <div className={`rail-item ${!openProject ? 'active' : ''}`} onClick={function() { navigate('library'); }} style={{ cursor: 'pointer' }}>
              <Icon name="library" size={13} className="rail-icon" />
              All projects
              {ALL_ASSETS.length}

            {PROJECTS.slice(0, 8).map(function(p) {
              return (
                <div key={p.id} className={`rail-item ${openProject && openProject.id === p.id ? 'active' : ''}`} style={{ cursor: 'pointer' }}
                  onClick={function() { navigate('projects'); }}>

                  {p.name}
                  {p.assets}

              );
            })}

        {BINS.length > 0 && (

Bins

              {BINS.map(function(b) {
                return (

                    <Icon name={binIcon(b.icon)} size={13} className="rail-icon" />
                    {b.name}
                    {b.count}

                );
              })}

        )}

Smart filters

            {errorCount > 0 &&
<Icon name="alert" size={13} className="rail-icon" />Errors{errorCount}
}

<Icon name="clock" size={13} className="rail-icon" />Last 24h{recentCount}

<Icon name="check" size={13} className="rail-icon" />Ready{ALL_ASSETS.filter(function(a) { return a.status === 'ready'; }).length}

{displayTitle}

          · {assets.length} assets

            <Icon name="search" className="search-icon" />
            <input value={search} onChange={function(e) { setSearch(e.target.value); }} placeholder="Filter assets…" />

            {['all', 'ready', 'processing', 'live', 'error'].map(function(f) {
              return (
                <button key={f} className={filter === f ? 'active' : ''} onClick={function() { setFilter(f); }}>
                  {f === 'all' ? 'All' : f[0].toUpperCase() + f.slice(1)}
                </button>
              );
            })}

            <button className={view === 'grid' ? 'active' : ''} onClick={function() { setView('grid'); }}><Icon name="grid" size={12} /></button>
            <button className={view === 'list' ? 'active' : ''} onClick={function() { setView('list'); }}><Icon name="list" size={12} /></button>

          <button className="btn primary" onClick={function() { navigate('upload'); }}><Icon name="upload" />Upload</button>

        {assets.length === 0 ? (

No assets match this filter.

        ) : view === 'grid' ? (

            {assets.map(function(a) { return <AssetCard key={a.id} asset={a} onOpen={function() { onOpenAsset(a); }} />; })}

        ) : (

Name

Duration

Resolution

Codec

Size

Updated

            {assets.map(function(a) {
              return (

<AssetThumb asset={a} />

{a.name}

                      <StatusDot status={a.status} />
                      {a.status}

{a.duration}

{a.res}

{a.codec || '—'}

{a.size}

{a.updated}

                  <button className="icon-btn" onClick={function(e) { e.stopPropagation(); }}><Icon name="more" /></button>

              );
            })}

        )}

  );
}


130
131	function AssetCard({ asset, onOpen }) {
132	  const [hoverStream, setHoverStream] = React.useState(null);
133	  const [hovered, setHovered] = React.useState(false);
134	  const timerRef = React.useRef(null);
135	  const hlsRef = React.useRef(null);
136	  const videoRef = React.useRef(null);
137
138	  const handleMouseEnter = function() {
139	    timerRef.current = setTimeout(function() {
140	      setHovered(true);
141	      if (!hoverStream) {
142	        window.ZAMPP_API.fetch('/assets/' + asset.id + '/stream')
143	          .then(function(r) { if (r && r.url) setHoverStream(r); })
144	          .catch(function() {});
145	      }
146	    }, 350);
147	  };
148
149	  const handleMouseLeave = function() {
150	    clearTimeout(timerRef.current);
151	    setHovered(false);
152	  };
153
154	  // HLS wiring
155	  React.useEffect(function() {
156	    if (!hovered || !hoverStream || hoverStream.type !== 'hls' || !videoRef.current) return;
157	    if (!window.Hls) return;
158	    hlsRef.current = new window.Hls({ maxBufferLength: 10 });
159	    hlsRef.current.loadSource(hoverStream.url);
160	    hlsRef.current.attachMedia(videoRef.current);
161	    return function() {
162	      if (hlsRef.current) { hlsRef.current.destroy(); hlsRef.current = null; }
163	    };
164	  }, [hovered, hoverStream]);
165
166	  const showVideo = hovered && hoverStream;
167
168	  return (
169

170

171	        <AssetThumb asset={asset} />
172	        {showVideo && (
173	          <video
174	            key={hoverStream.url}
175	            ref={videoRef}
176	            src={hoverStream.type !== 'hls' ? hoverStream.url : undefined}
177	            autoPlay
178	            muted
179	            loop
180	            playsInline
181	            style={{
182	              position: 'absolute',
183	              inset: 0,
184	              width: '100%',
185	              height: '100%',
186	              objectFit: 'cover',
187	              borderRadius: 6,
188	            }}
189	          />
190	        )}
191

192

193	        {asset.status === 'live' && LIVE}
194	        {asset.status === 'processing' && Processing}
195	        {asset.status === 'error' && Error}
196

197	      {(asset.type === 'video' || !asset.type) && asset.duration !== '—' &&
{asset.duration}
}
198

199
{asset.name}

200

201	          {asset.res}
202	          ·
203	          {asset.size}
204

205

206

207	  );
208	}
209

function AssetCard({ asset, onOpen }) {
  const [hoverStream, setHoverStream] = React.useState(null);
  const [hovered, setHovered] = React.useState(false);
  const timerRef = React.useRef(null);
  const hlsRef = React.useRef(null);
  const videoRef = React.useRef(null);

  const handleMouseEnter = function() {
    timerRef.current = setTimeout(function() {
      setHovered(true);
      if (!hoverStream) {
        window.ZAMPP_API.fetch('/assets/' + asset.id + '/stream')
          .then(function(r) { if (r && r.url) setHoverStream(r); })
          .catch(function() {});
      }
    }, 350);
  };

  const handleMouseLeave = function() {
    clearTimeout(timerRef.current);
    setHovered(false);
  };

  // HLS wiring
  React.useEffect(function() {
    if (!hovered || !hoverStream || hoverStream.type !== 'hls' || !videoRef.current) return;
    if (!window.Hls) return;
    hlsRef.current = new window.Hls({ maxBufferLength: 10 });
    hlsRef.current.loadSource(hoverStream.url);
    hlsRef.current.attachMedia(videoRef.current);
    return function() {
      if (hlsRef.current) { hlsRef.current.destroy(); hlsRef.current = null; }
    };
  }, [hovered, hoverStream]);

  const showVideo = hovered && hoverStream;

  return (

        <AssetThumb asset={asset} />
        {showVideo && (
          <video
            key={hoverStream.url}
            ref={videoRef}
            src={hoverStream.type !== 'hls' ? hoverStream.url : undefined}
            autoPlay
            muted
            loop
            playsInline
            style={{
              position: 'absolute',
              inset: 0,
              width: '100%',
              height: '100%',
              objectFit: 'cover',
              borderRadius: 6,
            }}
          />
        )}

        {asset.status === 'live' && LIVE}
        {asset.status === 'processing' && Processing}
        {asset.status === 'error' && Error}

      {(asset.type === 'video' || !asset.type) && asset.duration !== '—' &&
{asset.duration}
}

{asset.name}

          {asset.res}
          ·
          {asset.size}

  );
}


Edit

function Library({ navigate, onOpenAsset, openProject }) {
  const { ASSETS: ALL_ASSETS, BINS, PROJECTS } = window.ZAMPP_DATA;
  const [view, setView] = React.useState('grid');
  const [filter, setFilter] = React.useState('all');
  const [search, setSearch] = React.useState('');

  let assets = openProject
    ? ALL_ASSETS.filter(function(a) { return a.project_id === openProject.id; })
    : ALL_ASSETS;
  if (filter !== 'all') assets = assets.filter(function(a) { return a.status === filter; });
  if (search) assets = assets.filter(function(a) { return a.name.toLowerCase().includes(search.toLowerCase()); });

function Library({ navigate, onOpenAsset, openProject }) {
  const { BINS, PROJECTS } = window.ZAMPP_DATA;
  const [view, setView] = React.useState('grid');
  const [filter, setFilter] = React.useState('all');
  const [search, setSearch] = React.useState('');
  // Local state lets us re-render after delete / move-to-bin without forcing
  // a full app reload — keeps ZAMPP_DATA in sync as the cache of record.
  const [allAssets, setAllAssets] = React.useState(window.ZAMPP_DATA.ASSETS || []);
  const [ctxMenu, setCtxMenu] = React.useState(null); // { asset, x, y }

  const refreshAssets = React.useCallback(() => {
    window.ZAMPP_API.fetch('/assets?limit=500')
      .then(r => {
        const list = Array.isArray(r) ? r : (r.assets || []);
        const proj = {};
        (PROJECTS || []).forEach(p => { proj[p.id] = p.name; });
        const normalized = list.map(a => window.normalizeAsset ? window.normalizeAsset(a, proj) : a);
        window.ZAMPP_DATA.ASSETS = normalized;
        setAllAssets(normalized);
      })
      .catch(() => {});
  }, [PROJECTS]);

  // Dismiss the context menu on any outside click (capture phase so clicking
  // a menu item still fires before the menu unmounts).
  React.useEffect(() => {
    if (!ctxMenu) return;
    const close = () => setCtxMenu(null);
    window.addEventListener('click', close);
    window.addEventListener('contextmenu', close);
    window.addEventListener('scroll', close, true);
    return () => {
      window.removeEventListener('click', close);
      window.removeEventListener('contextmenu', close);
      window.removeEventListener('scroll', close, true);
    };
  }, [ctxMenu]);

  const openCtx = (asset, e) => {
    e.preventDefault();
    e.stopPropagation();
    setCtxMenu({ asset, x: e.clientX, y: e.clientY });
  };

  let assets = openProject
    ? allAssets.filter(function(a) { return a.project_id === openProject.id; })
    : allAssets;
  const ALL_ASSETS = allAssets;
  if (filter !== 'all') assets = assets.filter(function(a) { return a.status === filter; });
  if (search) assets = assets.filter(function(a) { return a.name.toLowerCase().includes(search.toLowerCase()); });


The file /tmp/wd/wild-dragon/services/web-ui/public/screens-library.jsx has been updated successfully. (file state is current in your context — no need to Read it back)

function Library({ navigate, onOpenAsset, openProject }) {
  const { BINS, PROJECTS } = window.ZAMPP_DATA;
  const [view, setView] = React.useState('grid');
  const [filter, setFilter] = React.useState('all');
  const [search, setSearch] = React.useState('');
  // Local state lets us re-render after delete / move-to-bin without forcing
  // a full app reload — keeps ZAMPP_DATA in sync as the cache of record.
  const [allAssets, setAllAssets] = React.useState(window.ZAMPP_DATA.ASSETS || []);
  const [ctxMenu, setCtxMenu] = React.useState(null); // { asset, x, y }

  const refreshAssets = React.useCallback(() => {
    window.ZAMPP_API.fetch('/assets?limit=500')
      .then(r => {
        const list = Array.isArray(r) ? r : (r.assets || []);
        const proj = {};
        (PROJECTS || []).forEach(p => { proj[p.id] = p.name; });
        const normalized = list.map(a => window.normalizeAsset ? window.normalizeAsset(a, proj) : a);
        window.ZAMPP_DATA.ASSETS = normalized;
        setAllAssets(normalized);
      })
      .catch(() => {});
  }, [PROJECTS]);

  // Dismiss the context menu on any outside click (capture phase so clicking
  // a menu item still fires before the menu unmounts).
  React.useEffect(() => {
    if (!ctxMenu) return;
    const close = () => setCtxMenu(null);
    window.addEventListener('click', close);
    window.addEventListener('contextmenu', close);
    window.addEventListener('scroll', close, true);
    return () => {
      window.removeEventListener('click', close);
      window.removeEventListener('contextmenu', close);
      window.removeEventListener('scroll', close, true);
    };
  }, [ctxMenu]);

  const openCtx = (asset, e) => {
    e.preventDefault();
    e.stopPropagation();
    setCtxMenu({ asset, x: e.clientX, y: e.clientY });
  };

  let assets = openProject
    ? allAssets.filter(function(a) { return a.project_id === openProject.id; })
    : allAssets;
  const ALL_ASSETS = allAssets;
  if (filter !== 'all') assets = assets.filter(function(a) { return a.status === filter; });
  if (search) assets = assets.filter(function(a) { return a.name.toLowerCase().includes(search.toLowerCase()); });

// screens-library.jsx

function Library({ navigate, onOpenAsset, openProject }) {
  const { ASSETS: ALL_ASSETS, BINS, PROJECTS } = window.ZAMPP_DATA;
  const [view, setView] = React.useState('grid');
  const [filter, setFilter] = React.useState('all');
  const [search, setSearch] = React.useState('');

  let assets = openProject
    ? ALL_ASSETS.filter(function(a) { return a.project_id === openProject.id; })
    : ALL_ASSETS;
  if (filter !== 'all') assets = assets.filter(function(a) { return a.status === filter; });
  if (search) assets = assets.filter(function(a) { return a.name.toLowerCase().includes(search.toLowerCase()); });

  const displayTitle = openProject ? openProject.name : 'All Assets';
  const errorCount = ALL_ASSETS.filter(function(a) { return a.status === 'error'; }).length;
  const recentCount = ALL_ASSETS.filter(function(a) { return (Date.now() - new Date(a.created_at)) < 86400000; }).length;

  return (

Projects

            <div className={`rail-item ${!openProject ? 'active' : ''}`} onClick={function() { navigate('library'); }} style={{ cursor: 'pointer' }}>
              <Icon name="library" size={13} className="rail-icon" />
              All projects
              {ALL_ASSETS.length}

            {PROJECTS.slice(0, 8).map(function(p) {
              return (
                <div key={p.id} className={`rail-item ${openProject && openProject.id === p.id ? 'active' : ''}`} style={{ cursor: 'pointer' }}
                  onClick={function() { navigate('projects'); }}>

                  {p.name}
                  {p.assets}

              );
            })}

        {BINS.length > 0 && (

Bins

              {BINS.map(function(b) {
                return (

                    <Icon name={binIcon(b.icon)} size={13} className="rail-icon" />
                    {b.name}
                    {b.count}

                );
              })}

        )}

Smart filters

            {errorCount > 0 &&
<Icon name="alert" size={13} className="rail-icon" />Errors{errorCount}
}

<Icon name="clock" size={13} className="rail-icon" />Last 24h{recentCount}

<Icon name="check" size={13} className="rail-icon" />Ready{ALL_ASSETS.filter(function(a) { return a.status === 'ready'; }).length}

{displayTitle}

          · {assets.length} assets

            <Icon name="search" className="search-icon" />
            <input value={search} onChange={function(e) { setSearch(e.target.value); }} placeholder="Filter assets…" />

            {['all', 'ready', 'processing', 'live', 'error'].map(function(f) {
              return (
                <button key={f} className={filter === f ? 'active' : ''} onClick={function() { setFilter(f); }}>
                  {f === 'all' ? 'All' : f[0].toUpperCase() + f.slice(1)}
                </button>
              );
            })}

            <button className={view === 'grid' ? 'active' : ''} onClick={function() { setView('grid'); }}><Icon name="grid" size={12} /></button>
            <button className={view === 'list' ? 'active' : ''} onClick={function() { setView('list'); }}><Icon name="list" size={12} /></button>

          <button className="btn primary" onClick={function() { navigate('upload'); }}><Icon name="upload" />Upload</button>

        {assets.length === 0 ? (

No assets match this filter.

        ) : view === 'grid' ? (

            {assets.map(function(a) { return <AssetCard key={a.id} asset={a} onOpen={function() { onOpenAsset(a); }} />; })}

        ) : (

Name

Duration

Resolution

Codec

Size

Updated

            {assets.map(function(a) {
              return (

<AssetThumb asset={a} />

{a.name}

                      <StatusDot status={a.status} />
                      {a.status}

{a.duration}

{a.res}

{a.codec || '—'}

{a.size}

{a.updated}

                  <button className="icon-btn" onClick={function(e) { e.stopPropagation(); }}><Icon name="more" /></button>

              );
            })}

        )}

  );
}

function AssetCard({ asset, onOpen }) {
  const [hoverStream, setHoverStream] = React.useState(null);
  const [hovered, setHovered] = React.useState(false);
  const timerRef = React.useRef(null);
  const hlsRef = React.useRef(null);
  const videoRef = React.useRef(null);

  const handleMouseEnter = function() {
    timerRef.current = setTimeout(function() {
      setHovered(true);
      if (!hoverStream) {
        window.ZAMPP_API.fetch('/assets/' + asset.id + '/stream')
          .then(function(r) { if (r && r.url) setHoverStream(r); })
          .catch(function() {});
      }
    }, 350);
  };

  const handleMouseLeave = function() {
    clearTimeout(timerRef.current);
    setHovered(false);
  };

  // HLS wiring
  React.useEffect(function() {
    if (!hovered || !hoverStream || hoverStream.type !== 'hls' || !videoRef.current) return;
    if (!window.Hls) return;
    hlsRef.current = new window.Hls({ maxBufferLength: 10 });
    hlsRef.current.loadSource(hoverStream.url);
    hlsRef.current.attachMedia(videoRef.current);
    return function() {
      if (hlsRef.current) { hlsRef.current.destroy(); hlsRef.current = null; }
    };
  }, [hovered, hoverStream]);

  const showVideo = hovered && hoverStream;

  return (

        <AssetThumb asset={asset} />
        {showVideo && (
          <video
            key={hoverStream.url}
            ref={videoRef}
            src={hoverStream.type !== 'hls' ? hoverStream.url : undefined}
            autoPlay
            muted
            loop
            playsInline
            style={{
              position: 'absolute',
              inset: 0,
              width: '100%',
              height: '100%',
              objectFit: 'cover',
              borderRadius: 6,
            }}
          />
        )}

        {asset.status === 'live' && LIVE}
        {asset.status === 'processing' && Processing}
        {asset.status === 'error' && Error}

      {(asset.type === 'video' || !asset.type) && asset.duration !== '—' &&
{asset.duration}
}

{asset.name}

          {asset.res}
          ·
          {asset.size}

  );
}

function binIcon(name) {
  return { grid: 'library', live: 'record', film: 'film', proxy: 'proxy', audio: 'audio', package: 'package' }[name] || 'folder';
}

window.Library = Library;
window.AssetCard = AssetCard;

-  const { ASSETS: ALL_ASSETS, BINS, PROJECTS } = window.ZAMPP_DATA;

+  const { BINS, PROJECTS } = window.ZAMPP_DATA;

+  // Local state lets us re-render after delete / move-to-bin without forcing

+  // a full app reload — keeps ZAMPP_DATA in sync as the cache of record.

+  const [allAssets, setAllAssets] = React.useState(window.ZAMPP_DATA.ASSETS || []);

+  const [ctxMenu, setCtxMenu] = React.useState(null); // { asset, x, y }

+  const refreshAssets = React.useCallback(() => {

+    window.ZAMPP_API.fetch('/assets?limit=500')

+        const list = Array.isArray(r) ? r : (r.assets || []);

+        (PROJECTS || []).forEach(p => { proj[p.id] = p.name; });

+        const normalized = list.map(a => window.normalizeAsset ? window.normalizeAsset(a, proj) : a);

+        window.ZAMPP_DATA.ASSETS = normalized;

+  // Dismiss the context menu on any outside click (capture phase so clicking

+  // a menu item still fires before the menu unmounts).

+    const close = () => setCtxMenu(null);

+    window.addEventListener('click', close);

+    window.addEventListener('contextmenu', close);

+    window.addEventListener('scroll', close, true);

+      window.removeEventListener('click', close);

+      window.removeEventListener('contextmenu', close);

+      window.removeEventListener('scroll', close, true);

+    setCtxMenu({ asset, x: e.clientX, y: e.clientY });

-    ? ALL_ASSETS.filter(function(a) { return a.project_id === openProject.id; })

+    ? allAssets.filter(function(a) { return a.project_id === openProject.id; })


{assets.length === 0 ? (

No assets match this filter.

        ) : view === 'grid' ? (

            {assets.map(function(a) { return <AssetCard key={a.id} asset={a} onOpen={function() { onOpenAsset(a); }} />; })}

        ) : (

Name

Duration

Resolution

Codec

Size

Updated

            {assets.map(function(a) {
              return (

<AssetThumb asset={a} />

{a.name}

                      <StatusDot status={a.status} />
                      {a.status}

{a.duration}

{a.res}

{a.codec || '—'}

{a.size}

{a.updated}

                  <button className="icon-btn" onClick={function(e) { e.stopPropagation(); }}><Icon name="more" /></button>

              );
            })}

        )}

  );
}

{assets.length === 0 ? (

No assets match this filter.

        ) : view === 'grid' ? (

            {assets.map(function(a) {
              return <AssetCard key={a.id} asset={a}
                       onOpen={function() { onOpenAsset(a); }}
                       onContextMenu={function(e) { openCtx(a, e); }} />;
            })}

        ) : (

Name

Duration

Resolution

Codec

Size

Updated

            {assets.map(function(a) {
              return (

<AssetThumb asset={a} />

{a.name}

                      <StatusDot status={a.status} />
                      {a.status}

{a.duration}

{a.res}

{a.codec || '—'}

{a.size}

{a.updated}

                  <button className="icon-btn" onClick={function(e) { e.stopPropagation(); openCtx(a, e); }}><Icon name="more" /></button>

              );
            })}

        )}

      {ctxMenu && (
        <AssetContextMenu
          asset={ctxMenu.asset}
          x={ctxMenu.x}
          y={ctxMenu.y}
          bins={BINS}
          onClose={function() { setCtxMenu(null); }}
          onChanged={refreshAssets}
          onOpen={function() { onOpenAsset(ctxMenu.asset); }}
        />
      )}

  );
}

function AssetContextMenu({ asset, x, y, bins, onClose, onChanged, onOpen }) {
  const ref = React.useRef(null);
  // Pin the menu inside the viewport even if the user right-clicked near
  // the bottom-right edge of the grid.
  const [pos, setPos] = React.useState({ left: x, top: y });
  React.useLayoutEffect(() => {
    if (!ref.current) return;
    const r = ref.current.getBoundingClientRect();
    const margin = 8;
    let nx = x, ny = y;
    if (x + r.width  + margin > window.innerWidth)  nx = window.innerWidth  - r.width  - margin;
    if (y + r.height + margin > window.innerHeight) ny = window.innerHeight - r.height - margin;
    setPos({ left: Math.max(margin, nx), top: Math.max(margin, ny) });
  }, [x, y]);

  const rename = function() {
    onClose();
    const next = prompt('Rename asset', asset.display_name || asset.name || '');
    if (next == null) return;
    const trimmed = next.trim();
    if (!trimmed || trimmed === (asset.display_name || asset.name)) return;
    window.ZAMPP_API.fetch('/assets/' + asset.id, { method: 'PATCH', body: JSON.stringify({ display_name: trimmed }) })
      .then(onChanged)
      .catch(function(e) { alert('Rename failed: ' + e.message); });
  };

  const moveToBin = function(binId) {
    onClose();
    window.ZAMPP_API.fetch('/assets/' + asset.id, { method: 'PATCH', body: JSON.stringify({ bin_id: binId }) })
      .then(onChanged)
      .catch(function(e) { alert('Move failed: ' + e.message); });
  };

  const copyId = function() {
    onClose();
    if (navigator.clipboard) navigator.clipboard.writeText(asset.id).catch(function() {});
  };

  const remove = function() {
    onClose();
    if (!confirm('Delete "' + (asset.display_name || asset.name) + '" permanently?\nThis removes the database row and the S3 objects.\nThis cannot be undone.')) return;
    window.ZAMPP_API.fetch('/assets/' + asset.id + '?hard=true', { method: 'DELETE' })
      .then(onChanged)
      .catch(function(e) { alert('Delete failed: ' + e.message); });
  };

  return (

{asset.display_name || asset.name}

      <button onClick={function() { onClose(); onOpen(); }}><Icon name="play" size={11} />Open</button>
      <button onClick={rename}><Icon name="edit" size={11} />Rename…</button>

      {(bins && bins.length > 0) ? (
        <>

Move to bin

          {bins.slice(0, 10).map(function(b) {
            const isCurrent = asset.bin_id === b.id;
            return (
              <button key={b.id} onClick={function() { moveToBin(b.id); }} disabled={isCurrent}>
                <Icon name="folder" size={11} />{b.name}{isCurrent && current}
              </button>
            );
          })}
          {asset.bin_id && (
            <button onClick={function() { moveToBin(null); }}>
              <Icon name="x" size={11} />Remove from bin
            </button>
          )}
        </>
      ) : (

No bins — create one in a project

      )}

      <button onClick={copyId}><Icon name="library" size={11} />Copy asset ID</button>
      <button className="danger" onClick={remove}><Icon name="trash" size={11} />Delete permanently</button>

  );
}


{assets.length === 0 ? (

No assets match this filter.

        ) : view === 'grid' ? (

            {assets.map(function(a) { return <AssetCard key={a.id} asset={a} onOpen={function() { onOpenAsset(a); }} />; })}

        ) : (

Name

Duration

Resolution

Codec

Size

Updated

            {assets.map(function(a) {
              return (

<AssetThumb asset={a} />

{a.name}

                      <StatusDot status={a.status} />
                      {a.status}

{a.duration}

{a.res}

{a.codec || '—'}

{a.size}

{a.updated}

                  <button className="icon-btn" onClick={function(e) { e.stopPropagation(); }}><Icon name="more" /></button>

              );
            })}

        )}

  );
}

{assets.length === 0 ? (

No assets match this filter.

        ) : view === 'grid' ? (

            {assets.map(function(a) {
              return <AssetCard key={a.id} asset={a}
                       onOpen={function() { onOpenAsset(a); }}
                       onContextMenu={function(e) { openCtx(a, e); }} />;
            })}

        ) : (

Name

Duration

Resolution

Codec

Size

Updated

            {assets.map(function(a) {
              return (

<AssetThumb asset={a} />

{a.name}

                      <StatusDot status={a.status} />
                      {a.status}

{a.duration}

{a.res}

{a.codec || '—'}

{a.size}

{a.updated}

                  <button className="icon-btn" onClick={function(e) { e.stopPropagation(); openCtx(a, e); }}><Icon name="more" /></button>

              );
            })}

        )}

      {ctxMenu && (
        <AssetContextMenu
          asset={ctxMenu.asset}
          x={ctxMenu.x}
          y={ctxMenu.y}
          bins={BINS}
          onClose={function() { setCtxMenu(null); }}
          onChanged={refreshAssets}
          onOpen={function() { onOpenAsset(ctxMenu.asset); }}
        />
      )}

  );
}

function AssetContextMenu({ asset, x, y, bins, onClose, onChanged, onOpen }) {
  const ref = React.useRef(null);
  // Pin the menu inside the viewport even if the user right-clicked near
  // the bottom-right edge of the grid.
  const [pos, setPos] = React.useState({ left: x, top: y });
  React.useLayoutEffect(() => {
    if (!ref.current) return;
    const r = ref.current.getBoundingClientRect();
    const margin = 8;
    let nx = x, ny = y;
    if (x + r.width  + margin > window.innerWidth)  nx = window.innerWidth  - r.width  - margin;
    if (y + r.height + margin > window.innerHeight) ny = window.innerHeight - r.height - margin;
    setPos({ left: Math.max(margin, nx), top: Math.max(margin, ny) });
  }, [x, y]);

  const rename = function() {
    onClose();
    const next = prompt('Rename asset', asset.display_name || asset.name || '');
    if (next == null) return;
    const trimmed = next.trim();
    if (!trimmed || trimmed === (asset.display_name || asset.name)) return;
    window.ZAMPP_API.fetch('/assets/' + asset.id, { method: 'PATCH', body: JSON.stringify({ display_name: trimmed }) })
      .then(onChanged)
      .catch(function(e) { alert('Rename failed: ' + e.message); });
  };

  const moveToBin = function(binId) {
    onClose();
    window.ZAMPP_API.fetch('/assets/' + asset.id, { method: 'PATCH', body: JSON.stringify({ bin_id: binId }) })
      .then(onChanged)
      .catch(function(e) { alert('Move failed: ' + e.message); });
  };

  const copyId = function() {
    onClose();
    if (navigator.clipboard) navigator.clipboard.writeText(asset.id).catch(function() {});
  };

  const remove = function() {
    onClose();
    if (!confirm('Delete "' + (asset.display_name || asset.name) + '" permanently?\nThis removes the database row and the S3 objects.\nThis cannot be undone.')) return;
    window.ZAMPP_API.fetch('/assets/' + asset.id + '?hard=true', { method: 'DELETE' })
      .then(onChanged)
      .catch(function(e) { alert('Delete failed: ' + e.message); });
  };

  return (

{asset.display_name || asset.name}

      <button onClick={function() { onClose(); onOpen(); }}><Icon name="play" size={11} />Open</button>
      <button onClick={rename}><Icon name="edit" size={11} />Rename…</button>

      {(bins && bins.length > 0) ? (
        <>

Move to bin

          {bins.slice(0, 10).map(function(b) {
            const isCurrent = asset.bin_id === b.id;
            return (
              <button key={b.id} onClick={function() { moveToBin(b.id); }} disabled={isCurrent}>
                <Icon name="folder" size={11} />{b.name}{isCurrent && current}
              </button>
            );
          })}
          {asset.bin_id && (
            <button onClick={function() { moveToBin(null); }}>
              <Icon name="x" size={11} />Remove from bin
            </button>
          )}
        </>
      ) : (

No bins — create one in a project

      )}

      <button onClick={copyId}><Icon name="library" size={11} />Copy asset ID</button>
      <button className="danger" onClick={remove}><Icon name="trash" size={11} />Delete permanently</button>

  );
}

-            {assets.map(function(a) { return <AssetCard key={a.id} asset={a} onOpen={function() { onOpenAsset(a); }} />; })}

+              return <AssetCard key={a.id} asset={a}

+                       onOpen={function() { onOpenAsset(a); }}

+                       onContextMenu={function(e) { openCtx(a, e); }} />;

-                <div key={a.id} className="list-row" onClick={function() { onOpenAsset(a); }} style={{ cursor: 'pointer' }}>

+                <div key={a.id} className="list-row" onClick={function() { onOpenAsset(a); }} onContextMenu={function(e) { openCtx(a, e); }} style={{ cursor: 'pointer' }}>

-                  <button className="icon-btn" onClick={function(e) { e.stopPropagation(); }}><Icon name="more" /></button>

+                  <button className="icon-btn" onClick={function(e) { e.stopPropagation(); openCtx(a, e); }}><Icon name="more" /></button>

+          onClose={function() { setCtxMenu(null); }}

+          onOpen={function() { onOpenAsset(ctxMenu.asset); }}

+function AssetContextMenu({ asset, x, y, bins, onClose, onChanged, onOpen }) {

+  // Pin the menu inside the viewport even if the user right-clicked near

+  // the bottom-right edge of the grid.

+  const [pos, setPos] = React.useState({ left: x, top: y });

+    const r = ref.current.getBoundingClientRect();

+    if (x + r.width  + margin > window.innerWidth)  nx = window.innerWidth  - r.width  - margin;

+    if (y + r.height + margin > window.innerHeight) ny = window.innerHeight - r.height - margin;

+    setPos({ left: Math.max(margin, nx), top: Math.max(margin, ny) });

+    const next = prompt('Rename asset', asset.display_name || asset.name || '');

+    if (!trimmed || trimmed === (asset.display_name || asset.name)) return;

+    window.ZAMPP_API.fetch('/assets/' + asset.id, { method: 'PATCH', body: JSON.stringify({ display_name: trimmed }) })

+      .catch(function(e) { alert('Rename failed: ' + e.message); });

+    window.ZAMPP_API.fetch('/assets/' + asset.id, { method: 'PATCH', body: JSON.stringify({ bin_id: binId }) })

+      .catch(function(e) { alert('Move failed: ' + e.message); });

+    if (navigator.clipboard) navigator.clipboard.writeText(asset.id).catch(function() {});

+    if (!confirm('Delete "' + (asset.display_name || asset.name) + '" permanently?\nThis removes the database row and the S3 objects.\nThis cannot be undone.')) return;

+    window.ZAMPP_API.fetch('/assets/' + asset.id + '?hard=true', { method: 'DELETE' })

+      .catch(function(e) { alert('Delete failed: ' + e.message); });

+    <div ref={ref} className="ctx-menu" style={{ left: pos.left, top: pos.top }}

+         onClick={function(e) { e.stopPropagation(); }}

+         onContextMenu={function(e) { e.preventDefault(); e.stopPropagation(); }}>

+      <button onClick={function() { onClose(); onOpen(); }}><Icon name="play" size={11} />Open</button>

+      <button onClick={rename}><Icon name="edit" size={11} />Rename…</button>

+          {bins.slice(0, 10).map(function(b) {

+            const isCurrent = asset.bin_id === b.id;

+              <button key={b.id} onClick={function() { moveToBin(b.id); }} disabled={isCurrent}>

+                <Icon name="folder" size={11} />{b.name}{isCurrent && current}

+            <button onClick={function() { moveToBin(null); }}>

+              <Icon name="x" size={11} />Remove from bin

+      <button onClick={copyId}><Icon name="library" size={11} />Copy asset ID</button>

+      <button className="danger" onClick={remove}><Icon name="trash" size={11} />Delete permanently</button>

const [hoverStream, setHoverStream] = React.useState(null);

const [hovered, setHovered] = React.useState(false);


Now update the AssetCard signature and add ctx-menu styles:


function AssetCard({ asset, onOpen, onContextMenu }) {


-function AssetCard({ asset, onOpen }) {

+function AssetCard({ asset, onOpen, onContextMenu }) {


<div className="asset-card" onClick={onOpen} onMouseEnter={handleMouseEnter} onMouseLeave={handleMouseLeave}>

<div className="asset-card" onClick={onOpen} onContextMenu={onContextMenu} onMouseEnter={handleMouseEnter} onMouseLeave={handleMouseLeave}>


const showVideo = hovered && hoverStream;

-    <div className="asset-card" onClick={onOpen} onMouseEnter={handleMouseEnter} onMouseLeave={handleMouseLeave}>

+    <div className="asset-card" onClick={onOpen} onContextMenu={onContextMenu} onMouseEnter={handleMouseEnter} onMouseLeave={handleMouseLeave}>


Bash

cd /tmp/wd/wild-dragon && grep -n "normalizeAsset\|window\.normalize" services/web-ui/public/data.jsx | head

Check normalizeAsset export


55:function normalizeAsset(a, projectMap) {
137:    window.ZAMPP_DATA.ASSETS = list.map(a => normalizeAsset(a, projectMap));
Shell cwd was reset to /home/node


170	      icon: b.type || 'grid',
171	    }));
172	  }
173	}
174
175	window.ZAMPP_API = { fetch: apiFetch, loadData, fmtDuration, fmtSize, fmtRelative };
176

      icon: b.type || 'grid',
    }));
  }
}

window.ZAMPP_API = { fetch: apiFetch, loadData, fmtDuration, fmtSize, fmtRelative };


Edit

window.ZAMPP_API = { fetch: apiFetch, loadData, fmtDuration, fmtSize, fmtRelative };

window.ZAMPP_API = { fetch: apiFetch, loadData, fmtDuration, fmtSize, fmtRelative };
// Library re-renders after mutations: expose normalizeAsset so the screen
// can re-fetch /assets and produce rows with the same shape as the boot load.
window.normalizeAsset = normalizeAsset;


The file /tmp/wd/wild-dragon/services/web-ui/public/data.jsx has been updated successfully. (file state is current in your context — no need to Read it back)

window.ZAMPP_API = { fetch: apiFetch, loadData, fmtDuration, fmtSize, fmtRelative };
// Library re-renders after mutations: expose normalizeAsset so the screen
// can re-fetch /assets and produce rows with the same shape as the boot load.
window.normalizeAsset = normalizeAsset;

// data.jsx — API client; populates window.ZAMPP_DATA from real endpoints

const API = '/api/v1';

window.ZAMPP_DATA = {
  PROJECTS: [],
  ASSETS: [],
  RECORDERS: [],
  JOBS: [],
  NODES: [],
  USERS: [],
  BINS: [],
  COMMENTS: [],
};

async function apiFetch(path, opts = {}) {
  const res = await fetch(API + path, {
    credentials: 'include',
    headers: { 'Content-Type': 'application/json', ...(opts.headers || {}) },
    ...opts,
  });
  if (!res.ok) throw new Error(res.status + ' ' + res.statusText);
  return res.json();
}

function fmtDuration(ms) {
  if (!ms) return '—';
  const s = Math.round(ms / 1000);
  const h = Math.floor(s / 3600);
  const m = Math.floor((s % 3600) / 60);
  const sec = s % 60;
  if (h > 0) return h + ':' + String(m).padStart(2, '0') + ':' + String(sec).padStart(2, '0');
  return m + ':' + String(sec).padStart(2, '0');
}

function fmtSize(bytes) {
  if (!bytes) return '—';
  if (bytes >= 1e12) return (bytes / 1e12).toFixed(1) + ' TB';
  if (bytes >= 1e9) return (bytes / 1e9).toFixed(1) + ' GB';
  if (bytes >= 1e6) return Math.round(bytes / 1e6) + ' MB';
  return Math.round(bytes / 1e3) + ' KB';
}

function fmtRelative(iso) {
  if (!iso) return '—';
  const diff = (Date.now() - new Date(iso)) / 1000;
  if (diff < 60) return 'just now';
  if (diff < 3600) return Math.floor(diff / 60) + 'm ago';
  if (diff < 86400) return Math.floor(diff / 3600) + 'h ago';
  return Math.floor(diff / 86400) + 'd ago';
}

const PROJECT_COLORS = ['#5B7CFA', '#2DD4A8', '#FF5B5B', '#F5A623', '#B57CFA', '#6B7280'];

function normalizeAsset(a, projectMap) {
  return {
    ...a,
    name: a.display_name || a.filename || 'Untitled',
    type: a.media_type || 'video',
    duration: fmtDuration(a.duration_ms),
    size: fmtSize(a.file_size),
    res: a.resolution || '—',
    updated: fmtRelative(a.updated_at),
    project: (projectMap && projectMap[a.project_id]) || '',
    comments: 0,
    progress: 0,
    tc: a.start_tc || null,
    seed: a.id ? a.id.charCodeAt(0) % 6 : 1,
  };
}

function normalizeRecorder(r) {
  let elapsed = '—';
  if (r.status === 'recording' && r.started_at) {
    const s = Math.floor((Date.now() - new Date(r.started_at)) / 1000);
    elapsed = String(Math.floor(s / 3600)).padStart(2, '0') + ':' +
              String(Math.floor((s % 3600) / 60)).padStart(2, '0') + ':' +
              String(s % 60).padStart(2, '0');
  }
  const cfg = r.source_config || {};
  return {
    ...r,
    source: r.source_type || '—',
    url: cfg.url || cfg.address || cfg.srt_url || cfg.rtmp_url || r.source_type || '—',
    codec: r.recording_codec || '—',
    res: r.recording_resolution || '—',
    node: r.node_id ? r.node_id.slice(0, 8) : 'primary',
    elapsed,
    bitrate: '—',
    health: 100,
    audio: false,
  };
}

function normalizeJob(j) {
  const statusMap = { waiting: 'queued', active: 'running', completed: 'done', failed: 'failed' };
  const kindMap = { proxy: 'Proxy', thumbnail: 'Thumbnail', conform: 'Conform', transcode: 'Transcode' };
  const meta = j.metadata || {};
  return {
    ...j,
    status: statusMap[j.status] || j.status,
    kind: kindMap[j.type] || j.type || 'Job',
    asset: j.asset_name || meta.filename || '—',
    eta: '—',
    node: meta.node || '—',
    priority: meta.priority || 'normal',
    error: j.error || null,
    progress: j.progress || 0,
  };
}

async function loadData() {
  const [projectsR, assetsR, recordersR, jobsR, clusterR, usersR, binsR] = await Promise.allSettled([
    apiFetch('/projects'),
    apiFetch('/assets?limit=500'),
    apiFetch('/recorders'),
    apiFetch('/jobs'),
    apiFetch('/cluster'),
    apiFetch('/users'),
    apiFetch('/bins'),
  ]);

  const projectMap = {};
  if (projectsR.status === 'fulfilled') {
    window.ZAMPP_DATA.PROJECTS = (projectsR.value || []).map((p, i) => ({
      ...p,
      color: PROJECT_COLORS[i % PROJECT_COLORS.length],
      assets: 0,
      updated: fmtRelative(p.updated_at),
    }));
    window.ZAMPP_DATA.PROJECTS.forEach(p => { projectMap[p.id] = p.name; });
  }

  if (assetsR.status === 'fulfilled') {
    const raw = assetsR.value;
    const list = Array.isArray(raw) ? raw : (raw.assets || []);
    window.ZAMPP_DATA.ASSETS = list.map(a => normalizeAsset(a, projectMap));
    const counts = {};
    list.forEach(a => { if (a.project_id) counts[a.project_id] = (counts[a.project_id] || 0) + 1; });
    window.ZAMPP_DATA.PROJECTS = window.ZAMPP_DATA.PROJECTS.map(p => ({ ...p, assets: counts[p.id] || 0 }));
  }

  if (recordersR.status === 'fulfilled') {
    window.ZAMPP_DATA.RECORDERS = (recordersR.value || []).map(normalizeRecorder);
  }

  if (jobsR.status === 'fulfilled') {
    window.ZAMPP_DATA.JOBS = (jobsR.value || []).map(normalizeJob);
  }

  if (clusterR.status === 'fulfilled') {
    window.ZAMPP_DATA.NODES = clusterR.value || [];
  }

  if (usersR.status === 'fulfilled') {
    window.ZAMPP_DATA.USERS = (usersR.value || []).map(u => ({
      ...u,
      name: u.display_name || u.username || u.email || 'Unknown',
      initials: (u.display_name || u.username || '?').slice(0, 2).toUpperCase(),
      role: u.role || 'viewer',
      joined: fmtRelative(u.created_at),
      lastSeen: fmtRelative(u.last_seen || u.updated_at),
    }));
  }

  if (binsR.status === 'fulfilled') {
    window.ZAMPP_DATA.BINS = (binsR.value || []).map(b => ({
      ...b,
      count: b.asset_count || 0,
      icon: b.type || 'grid',
    }));
  }
}

window.ZAMPP_API = { fetch: apiFetch, loadData, fmtDuration, fmtSize, fmtRelative };

+// Library re-renders after mutations: expose normalizeAsset so the screen

+// can re-fetch /assets and produce rows with the same shape as the boot load.


Commit, push, redeploy, smoke


Now the ctx-menu CSS:


/* ── Row popover menu (Users, etc.) ────────────────────────────────── */

/* ── Right-click context menu (Library, etc.) ─────────────────────── */
.ctx-menu {
  position: fixed;
  z-index: 1000;
  min-width: 200px;
  max-width: 280px;
  background: var(--bg-1);
  border: 1px solid var(--border-stronger);
  border-radius: 6px;
  box-shadow: 0 10px 28px rgba(0, 0, 0, 0.45);
  padding: 4px;
  display: flex;
  flex-direction: column;
  user-select: none;
}
.ctx-menu .ctx-header {
  padding: 6px 10px 4px;
  font-size: 11px;
  color: var(--text-3);
  text-transform: uppercase;
  letter-spacing: 0.06em;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.ctx-menu .ctx-section-label {
  padding: 6px 10px 2px;
  font-size: 10.5px;
  color: var(--text-4);
  text-transform: uppercase;
  letter-spacing: 0.06em;
}
.ctx-menu .ctx-divider {
  height: 1px;
  background: var(--border);
  margin: 4px 0;
}
.ctx-menu .ctx-empty {
  padding: 6px 10px;
  font-size: 11.5px;
  color: var(--text-3);
  font-style: italic;
}
.ctx-menu button {
  display: flex;
  align-items: center;
  gap: 8px;
  background: transparent;
  border: 0;
  color: var(--text-1);
  font-size: 12.5px;
  padding: 7px 10px;
  border-radius: 4px;
  cursor: pointer;
  text-align: left;
}
.ctx-menu button:hover:not(:disabled) { background: var(--bg-3); }
.ctx-menu button:disabled { opacity: 0.45; cursor: default; }
.ctx-menu button.danger { color: var(--danger); }
.ctx-menu button.danger:hover { background: var(--danger-soft); }

/* ── Row popover menu (Users, etc.) ────────────────────────────────── */


The file /tmp/wd/wild-dragon/services/web-ui/public/styles-rest.css has been updated successfully. (file state is current in your context — no need to Read it back)

/* ── Right-click context menu (Library, etc.) ─────────────────────── */
.ctx-menu {
  position: fixed;
  z-index: 1000;
  min-width: 200px;
  max-width: 280px;
  background: var(--bg-1);
  border: 1px solid var(--border-stronger);
  border-radius: 6px;
  box-shadow: 0 10px 28px rgba(0, 0, 0, 0.45);
  padding: 4px;
  display: flex;
  flex-direction: column;
  user-select: none;
}
.ctx-menu .ctx-header {
  padding: 6px 10px 4px;
  font-size: 11px;
  color: var(--text-3);
  text-transform: uppercase;
  letter-spacing: 0.06em;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.ctx-menu .ctx-section-label {
  padding: 6px 10px 2px;
  font-size: 10.5px;
  color: var(--text-4);
  text-transform: uppercase;
  letter-spacing: 0.06em;
}
.ctx-menu .ctx-divider {
  height: 1px;
  background: var(--border);
  margin: 4px 0;
}
.ctx-menu .ctx-empty {
  padding: 6px 10px;
  font-size: 11.5px;
  color: var(--text-3);
  font-style: italic;
}
.ctx-menu button {
  display: flex;
  align-items: center;
  gap: 8px;
  background: transparent;
  border: 0;
  color: var(--text-1);
  font-size: 12.5px;
  padding: 7px 10px;
  border-radius: 4px;
  cursor: pointer;
  text-align: left;
}
.ctx-menu button:hover:not(:disabled) { background: var(--bg-3); }
.ctx-menu button:disabled { opacity: 0.45; cursor: default; }
.ctx-menu button.danger { color: var(--danger); }
.ctx-menu button.danger:hover { background: var(--danger-soft); }

/* ── Row popover menu (Users, etc.) ────────────────────────────────── */

+/* ── Right-click context menu (Library, etc.) ─────────────────────── */

+  border: 1px solid var(--border-stronger);

+.ctx-menu button:hover:not(:disabled) { background: var(--bg-3); }

+.ctx-menu button:disabled { opacity: 0.45; cursor: default; }

+.ctx-menu button.danger { color: var(--danger); }

+.ctx-menu button.danger:hover { background: var(--danger-soft); }


Bash

cd /tmp/wd/wild-dragon && grep -n "framesReceived\|frames received\|frame=" services/capture/src/capture-manager.js | head

Check frame counting


92:      framesReceived: 0,
290:      const m = text.match(/frame=\s*(\d+)\s+fps=\s*([\d.]+)/);
292:        this.state.framesReceived = parseInt(m[1], 10);
338:    this.state.framesReceived = 0;
420:    if (this.state.framesReceived > 0) {
436:      framesReceived: this.state.framesReceived,
Shell cwd was reset to /home/node


414	    const now = new Date();
415	    const duration = Math.round((now - startTime) / 1000);
416
417	    const lastFrameAt = this.state.lastFrameAt;
418	    const msSinceFrame = lastFrameAt ? (Date.now() - new Date(lastFrameAt).getTime()) : null;
419	    let signal = 'connecting';
420	    if (this.state.framesReceived > 0) {
421	      signal = (msSinceFrame !== null && msSinceFrame < 5000) ? 'receiving' : 'lost';
422	    } else if (this.state.lastError) {
423	      signal = 'error';
424	    }
425	    return {
426	      recording: true,
427	      sessionId: this.state.sessionId,
428	      sourceType: this.state.currentSession.sourceType,
429	      device: this.state.currentSession.device,
430	      clipName: this.state.currentSession.clipName,
431	      projectId: this.state.currentSession.projectId,
432	      binId: this.state.currentSession.binId,
433	      duration,
434	      startedAt: this.state.currentSession.startedAt,
435	      signal,
436	      framesReceived: this.state.framesReceived,
437	      currentFps: this.state.currentFps,
438	      lastFrameAt,
439	      msSinceFrame,
440	      lastError: this.state.lastError,
441	      codecs: this.state.currentSession.codecs,
442	    };
443	  }

    const now = new Date();
    const duration = Math.round((now - startTime) / 1000);

    const lastFrameAt = this.state.lastFrameAt;
    const msSinceFrame = lastFrameAt ? (Date.now() - new Date(lastFrameAt).getTime()) : null;
    let signal = 'connecting';
    if (this.state.framesReceived > 0) {
      signal = (msSinceFrame !== null && msSinceFrame < 5000) ? 'receiving' : 'lost';
    } else if (this.state.lastError) {
      signal = 'error';
    }
    return {
      recording: true,
      sessionId: this.state.sessionId,
      sourceType: this.state.currentSession.sourceType,
      device: this.state.currentSession.device,
      clipName: this.state.currentSession.clipName,
      projectId: this.state.currentSession.projectId,
      binId: this.state.currentSession.binId,
      duration,
      startedAt: this.state.currentSession.startedAt,
      signal,
      framesReceived: this.state.framesReceived,
      currentFps: this.state.currentFps,
      lastFrameAt,
      msSinceFrame,
      lastError: this.state.lastError,
      codecs: this.state.currentSession.codecs,
    };
  }


365	  }
366
367	  async stop(sessionId) {
368	    if (!this.state.recording || this.state.sessionId !== sessionId) {
369	      throw new Error('No active capture session or session ID mismatch');
370	    }
371
372	    const { processes, currentSession } = this.state;
373
374	    if (processes.hires) processes.hires.kill('SIGINT');
375	    if (processes.proxy) processes.proxy.kill('SIGINT');
376	    if (processes.hls)   { try { processes.hls.kill('SIGINT'); } catch (_) {} }
377
378	    try {
379	      const uploadPromises = [currentSession.uploads.hires];
380	      if (currentSession.uploads.proxy) uploadPromises.push(currentSession.uploads.proxy);
381	      await Promise.all(uploadPromises);
382	    } catch (error) {
383	      console.error('Error during upload completion:', error);
384	    }
385
386	    const stoppedAt = new Date().toISOString();
387	    const startTime = new Date(currentSession.startedAt);
388	    const stopTime = new Date(stoppedAt);
389	    const duration = Math.round((stopTime - startTime) / 1000);
390
391	    this.state.recording = false;
392	    this.state.sessionId = null;
393	    this.state.processes = {};
394
395	    return {
396	      sessionId,
397	      projectId: currentSession.projectId,
398	      binId: currentSession.binId,
399	      clipName: currentSession.clipName,
400	      sourceType: currentSession.sourceType,
401	      hiresKey: currentSession.hiresKey,
402	      proxyKey: currentSession.proxyKey,
403	      growingPath: currentSession.growingPath || null,
404	      startedAt: currentSession.startedAt,
405	      stoppedAt,
406	      duration,
407	    };
408	  }
409
410	  getStatus() {
411	    if (!this.state.recording) return { recording: false };
412
413	    const startTime = new Date(this.state.currentSession.startedAt);
414	    const now = new Date();

  }

  async stop(sessionId) {
    if (!this.state.recording || this.state.sessionId !== sessionId) {
      throw new Error('No active capture session or session ID mismatch');
    }

    const { processes, currentSession } = this.state;

    if (processes.hires) processes.hires.kill('SIGINT');
    if (processes.proxy) processes.proxy.kill('SIGINT');
    if (processes.hls)   { try { processes.hls.kill('SIGINT'); } catch (_) {} }

    try {
      const uploadPromises = [currentSession.uploads.hires];
      if (currentSession.uploads.proxy) uploadPromises.push(currentSession.uploads.proxy);
      await Promise.all(uploadPromises);
    } catch (error) {
      console.error('Error during upload completion:', error);
    }

    const stoppedAt = new Date().toISOString();
    const startTime = new Date(currentSession.startedAt);
    const stopTime = new Date(stoppedAt);
    const duration = Math.round((stopTime - startTime) / 1000);

    this.state.recording = false;
    this.state.sessionId = null;
    this.state.processes = {};

    return {
      sessionId,
      projectId: currentSession.projectId,
      binId: currentSession.binId,
      clipName: currentSession.clipName,
      sourceType: currentSession.sourceType,
      hiresKey: currentSession.hiresKey,
      proxyKey: currentSession.proxyKey,
      growingPath: currentSession.growingPath || null,
      startedAt: currentSession.startedAt,
      stoppedAt,
      duration,
    };
  }

  getStatus() {
    if (!this.state.recording) return { recording: false };

    const startTime = new Date(this.state.currentSession.startedAt);
    const now = new Date();


Edit

    return {
      sessionId,
      projectId: currentSession.projectId,
      binId: currentSession.binId,
      clipName: currentSession.clipName,
      sourceType: currentSession.sourceType,
      hiresKey: currentSession.hiresKey,
      proxyKey: currentSession.proxyKey,
      growingPath: currentSession.growingPath || null,
      startedAt: currentSession.startedAt,
      stoppedAt,
      duration,
    };
  }

    // No frames received → the upload (if any) produced a 0-byte object.
    // Surface that so the shutdown handler can mark the asset as 'error'
    // instead of posting a broken hi-res key downstream.
    const framesReceived = this.state.framesReceived;

    return {
      sessionId,
      projectId: currentSession.projectId,
      binId: currentSession.binId,
      clipName: currentSession.clipName,
      sourceType: currentSession.sourceType,
      hiresKey: currentSession.hiresKey,
      proxyKey: currentSession.proxyKey,
      growingPath: currentSession.growingPath || null,
      startedAt: currentSession.startedAt,
      stoppedAt,
      duration,
      framesReceived,
      empty: framesReceived === 0,
    };
  }


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

    return {
      sessionId,
      projectId: currentSession.projectId,
      binId: currentSession.binId,
      clipName: currentSession.clipName,
      sourceType: currentSession.sourceType,
      hiresKey: currentSession.hiresKey,
      proxyKey: currentSession.proxyKey,
      growingPath: currentSession.growingPath || null,
      startedAt: currentSession.startedAt,
      stoppedAt,
      duration,
    };
  }

    // No frames received → the upload (if any) produced a 0-byte object.
    // Surface that so the shutdown handler can mark the asset as 'error'
    // instead of posting a broken hi-res key downstream.
    const framesReceived = this.state.framesReceived;

    return {
      sessionId,
      projectId: currentSession.projectId,
      binId: currentSession.binId,
      clipName: currentSession.clipName,
      sourceType: currentSession.sourceType,
      hiresKey: currentSession.hiresKey,
      proxyKey: currentSession.proxyKey,
      growingPath: currentSession.growingPath || null,
      startedAt: currentSession.startedAt,
      stoppedAt,
      duration,
      framesReceived,
      empty: framesReceived === 0,
    };
  }

+    // No frames received → the upload (if any) produced a 0-byte object.

+    // Surface that so the shutdown handler can mark the asset as 'error'

+    // instead of posting a broken hi-res key downstream.

+    const framesReceived = this.state.framesReceived;


Now update the shutdown handler in capture/index.js to skip the asset POST when empty + add an `assetId` to flip the pre-created live asset to `error`:


  if (status.recording) {
    console.log(`[shutdown] stopping active session ${status.sessionId}...`);
    try {
      const completed = await captureManager.stop(status.sessionId);
      console.log(`[shutdown] session ${completed.sessionId} finalised; duration=${completed.duration}s`);

      try {
        const res = await fetch(`${MAM_API_URL}/api/v1/assets`, {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({
            projectId:  completed.projectId,
            binId:      completed.binId,
            clipName:   completed.clipName,
            sourceType: completed.sourceType,
            hiresKey:   completed.hiresKey,
            proxyKey:   completed.proxyKey,
            needsProxy: completed.proxyKey === null,
            duration:   completed.duration,
            capturedAt: completed.startedAt,
          }),
        });
        if (!res.ok) {
          console.warn(`[shutdown] mam-api /assets returned ${res.status}: ${await res.text()}`);
        } else {
          console.log('[shutdown] asset registered with mam-api');
        }
      } catch (mamErr) {
        console.error('[shutdown] failed to register asset:', mamErr.message);
      }
    } catch (err) {
      console.error('[shutdown] error during stop:', err);
    }
  }

  if (status.recording) {
    console.log(`[shutdown] stopping active session ${status.sessionId}...`);
    try {
      const completed = await captureManager.stop(status.sessionId);
      console.log(`[shutdown] session ${completed.sessionId} finalised; duration=${completed.duration}s frames=${completed.framesReceived}`);

      const liveAssetId = process.env.ASSET_ID || null;

      // No frames received → the source never connected (bad SRT URL, dead
      // SDI signal, RTMP stream key mismatch, etc.). The S3 upload at this
      // point is 0 bytes and would just clog the proxy queue with "moov
      // atom not found" failures. Mark the pre-created live asset as
      // 'error' and skip the POST /assets registration entirely.
      if (completed.empty) {
        console.warn('[shutdown] no frames received — marking asset as error and skipping registration');
        if (liveAssetId) {
          try {
            await fetch(`${MAM_API_URL}/api/v1/assets/${liveAssetId}/mark-empty`, {
              method: 'POST',
              headers: { 'Content-Type': 'application/json' },
            });
          } catch (e) {
            console.error('[shutdown] failed to flag empty asset:', e.message);
          }
        }
      } else {
        try {
          const res = await fetch(`${MAM_API_URL}/api/v1/assets`, {
            method: 'POST',
            headers: { 'Content-Type': 'application/json' },
            body: JSON.stringify({
              projectId:  completed.projectId,
              binId:      completed.binId,
              clipName:   completed.clipName,
              sourceType: completed.sourceType,
              hiresKey:   completed.hiresKey,
              proxyKey:   completed.proxyKey,
              needsProxy: completed.proxyKey === null,
              duration:   completed.duration,
              capturedAt: completed.startedAt,
            }),
          });
          if (!res.ok) {
            console.warn(`[shutdown] mam-api /assets returned ${res.status}: ${await res.text()}`);
          } else {
            console.log('[shutdown] asset registered with mam-api');
          }
        } catch (mamErr) {
          console.error('[shutdown] failed to register asset:', mamErr.message);
        }
      }
    } catch (err) {
      console.error('[shutdown] error during stop:', err);
    }
  }


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

  if (status.recording) {
    console.log(`[shutdown] stopping active session ${status.sessionId}...`);
    try {
      const completed = await captureManager.stop(status.sessionId);
      console.log(`[shutdown] session ${completed.sessionId} finalised; duration=${completed.duration}s frames=${completed.framesReceived}`);

      const liveAssetId = process.env.ASSET_ID || null;

      // No frames received → the source never connected (bad SRT URL, dead
      // SDI signal, RTMP stream key mismatch, etc.). The S3 upload at this
      // point is 0 bytes and would just clog the proxy queue with "moov
      // atom not found" failures. Mark the pre-created live asset as
      // 'error' and skip the POST /assets registration entirely.
      if (completed.empty) {
        console.warn('[shutdown] no frames received — marking asset as error and skipping registration');
        if (liveAssetId) {
          try {
            await fetch(`${MAM_API_URL}/api/v1/assets/${liveAssetId}/mark-empty`, {
              method: 'POST',
              headers: { 'Content-Type': 'application/json' },
            });
          } catch (e) {
            console.error('[shutdown] failed to flag empty asset:', e.message);
          }
        }
      } else {
        try {
          const res = await fetch(`${MAM_API_URL}/api/v1/assets`, {
            method: 'POST',
            headers: { 'Content-Type': 'application/json' },
            body: JSON.stringify({
              projectId:  completed.projectId,
              binId:      completed.binId,
              clipName:   completed.clipName,
              sourceType: completed.sourceType,
              hiresKey:   completed.hiresKey,
              proxyKey:   completed.proxyKey,
              needsProxy: completed.proxyKey === null,
              duration:   completed.duration,
              capturedAt: completed.startedAt,
            }),
          });
          if (!res.ok) {
            console.warn(`[shutdown] mam-api /assets returned ${res.status}: ${await res.text()}`);
          } else {
            console.log('[shutdown] asset registered with mam-api');
          }
        } catch (mamErr) {
          console.error('[shutdown] failed to register asset:', mamErr.message);
        }
      }
    } catch (err) {
      console.error('[shutdown] error during stop:', err);
    }
  }

import express from 'express';
import cors from 'cors';
import dotenv from 'dotenv';
import captureRoutes from './routes/capture.js';
import captureManager from './capture-manager.js';

dotenv.config();

const app = express();
const PORT = process.env.PORT || 3001;
const MAM_API_URL = process.env.MAM_API_URL || 'http://mam-api:3000';

app.use(cors());
app.use(express.json());

app.get('/health', (req, res) => {
  res.json({ status: 'ok' });
});

app.use('/capture', captureRoutes);

const server = app.listen(PORT, () => {
  console.log(`Wild Dragon Capture Service listening on port ${PORT}`);
  bootstrapAutoStart();
});

// Mapped from the env vars routes/recorders.js writes into the container.
// Empty strings collapse to undefined so capture-manager's defaults win.
function envOpt(name) {
  const v = process.env[name];
  return v === undefined || v === '' ? undefined : v;
}
function envInt(name) {
  const v = envOpt(name);
  if (v === undefined) return undefined;
  const n = parseInt(v, 10);
  return Number.isFinite(n) ? n : undefined;
}
function envBool(name) {
  const v = envOpt(name);
  if (v === undefined) return undefined;
  return v === 'true' || v === '1' || v === 'yes';
}

async function bootstrapAutoStart() {
  const recorderId = process.env.RECORDER_ID;
  const sourceType = process.env.SOURCE_TYPE;
  if (!recorderId || !sourceType) {
    console.log('[bootstrap] no RECORDER_ID/SOURCE_TYPE - on-demand sidecar');
    return;
  }

  const projectId = process.env.PROJECT_ID;
  const clipName  = process.env.CLIP_NAME;
  if (!projectId || !clipName) {
    console.error('[bootstrap] missing PROJECT_ID or CLIP_NAME - cannot start');
    return;
  }

  const listen = process.env.LISTEN === '1' || process.env.LISTEN === 'true';
  const listenPort = envInt('LISTEN_PORT');
  const streamKey = envOpt('STREAM_KEY');
  const sourceUrl = envOpt('SOURCE_URL');
  const device    = envInt('DEVICE_INDEX');

  console.log(`[bootstrap] starting ${sourceType} ingest (listen=${listen} port=${listenPort || 'n/a'})...`);
  try {
    const session = await captureManager.start({
      assetId: envOpt('ASSET_ID') || null,
      projectId,
      binId: envOpt('BIN_ID') || null,
      clipName,
      device,
      sourceType,
      sourceUrl,
      listen,
      listenPort,
      streamKey,

      // Recording codec — recorders.js passes these straight through
      videoCodec:    envOpt('RECORDING_CODEC')           || 'prores_hq',
      videoBitrate:  envOpt('RECORDING_VIDEO_BITRATE'),
      framerate:     envOpt('RECORDING_FRAMERATE'),
      audioCodec:    envOpt('RECORDING_AUDIO_CODEC')     || 'pcm_s24le',
      audioBitrate:  envOpt('RECORDING_AUDIO_BITRATE'),
      audioChannels: envInt('RECORDING_AUDIO_CHANNELS')  ?? 2,
      container:     envOpt('RECORDING_CONTAINER')       || 'mov',

      proxyEnabled:       envBool('PROXY_ENABLED') ?? true,
      proxyVideoCodec:    envOpt('PROXY_CODEC')          || 'h264',
      proxyVideoBitrate:  envOpt('PROXY_VIDEO_BITRATE')  || '8M',
      proxyFramerate:     envOpt('PROXY_FRAMERATE'),
      proxyAudioCodec:    envOpt('PROXY_AUDIO_CODEC')    || 'aac',
      proxyAudioBitrate:  envOpt('PROXY_AUDIO_BITRATE')  || '192k',
      proxyAudioChannels: envInt('PROXY_AUDIO_CHANNELS') ?? 2,
      proxyContainer:     envOpt('PROXY_CONTAINER')      || 'mp4',
    });
    console.log(`[bootstrap] session ${session.sessionId} started for clip ${clipName}`);
  } catch (err) {
    console.error('[bootstrap] failed to start capture:', err);
  }
}

let shuttingDown = false;
async function gracefulShutdown(signal) {
  if (shuttingDown) return;
  shuttingDown = true;
  console.log(`[shutdown] ${signal} received`);

  const status = captureManager.getStatus();

  if (status.recording) {
    console.log(`[shutdown] stopping active session ${status.sessionId}...`);
    try {
      const completed = await captureManager.stop(status.sessionId);
      console.log(`[shutdown] session ${completed.sessionId} finalised; duration=${completed.duration}s`);

      try {
        const res = await fetch(`${MAM_API_URL}/api/v1/assets`, {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({
            projectId:  completed.projectId,
            binId:      completed.binId,
            clipName:   completed.clipName,
            sourceType: completed.sourceType,
            hiresKey:   completed.hiresKey,
            proxyKey:   completed.proxyKey,
            needsProxy: completed.proxyKey === null,
            duration:   completed.duration,
            capturedAt: completed.startedAt,
          }),
        });
        if (!res.ok) {
          console.warn(`[shutdown] mam-api /assets returned ${res.status}: ${await res.text()}`);
        } else {
          console.log('[shutdown] asset registered with mam-api');
        }
      } catch (mamErr) {
        console.error('[shutdown] failed to register asset:', mamErr.message);
      }
    } catch (err) {
      console.error('[shutdown] error during stop:', err);
    }
  }

  server.close(() => {
    console.log('[shutdown] http server closed - exiting');
    process.exit(0);
  });

  setTimeout(() => process.exit(0), 5000).unref();
}

process.on('SIGTERM', () => gracefulShutdown('SIGTERM'));
process.on('SIGINT',  () => gracefulShutdown('SIGINT'));

-      console.log(`[shutdown] session ${completed.sessionId} finalised; duration=${completed.duration}s`);

+      console.log(`[shutdown] session ${completed.sessionId} finalised; duration=${completed.duration}s frames=${completed.framesReceived}`);

-        const res = await fetch(`${MAM_API_URL}/api/v1/assets`, {

-          headers: { 'Content-Type': 'application/json' },

-            projectId:  completed.projectId,

-            binId:      completed.binId,

-            clipName:   completed.clipName,

-            sourceType: completed.sourceType,

-            hiresKey:   completed.hiresKey,

-            proxyKey:   completed.proxyKey,

-            needsProxy: completed.proxyKey === null,

-            duration:   completed.duration,

-            capturedAt: completed.startedAt,

-          console.warn(`[shutdown] mam-api /assets returned ${res.status}: ${await res.text()}`);

-          console.log('[shutdown] asset registered with mam-api');

+      const liveAssetId = process.env.ASSET_ID || null;

+      // No frames received → the source never connected (bad SRT URL, dead

+      // SDI signal, RTMP stream key mismatch, etc.). The S3 upload at this

+      // point is 0 bytes and would just clog the proxy queue with "moov

+      // atom not found" failures. Mark the pre-created live asset as

+      // 'error' and skip the POST /assets registration entirely.

+        console.warn('[shutdown] no frames received — marking asset as error and skipping registration');

+            await fetch(`${MAM_API_URL}/api/v1/assets/${liveAssetId}/mark-empty`, {

+              headers: { 'Content-Type': 'application/json' },

+            console.error('[shutdown] failed to flag empty asset:', e.message);

-        console.error('[shutdown] failed to register asset:', mamErr.message);

+          const res = await fetch(`${MAM_API_URL}/api/v1/assets`, {

+              projectId:  completed.projectId,

+              binId:      completed.binId,

+              clipName:   completed.clipName,

+              sourceType: completed.sourceType,

+              hiresKey:   completed.hiresKey,

+              proxyKey:   completed.proxyKey,

+              needsProxy: completed.proxyKey === null,

+              duration:   completed.duration,

+              capturedAt: completed.startedAt,

+            console.warn(`[shutdown] mam-api /assets returned ${res.status}: ${await res.text()}`);

+            console.log('[shutdown] asset registered with mam-api');

+          console.error('[shutdown] failed to register asset:', mamErr.message);


Add the `mark-empty` route on assets so the capture sidecar can flip the pre-created live asset to error:


// POST /:id/generate-proxy — re-queue proxy for an asset that has a hi-res

// POST /:id/mark-empty — flag a pre-created live asset as 'error' because
// the recorder finished a session without any frames (bad source URL, dead
// SDI signal, etc.). Called by the capture sidecar's shutdown handler.
router.post('/:id/mark-empty', async (req, res, next) => {
  try {
    const { id } = req.params;
    const result = await pool.query(
      `UPDATE assets
         SET status = 'error',
             notes  = COALESCE(notes || E'\\n', '') || 'Recording produced no frames — source never connected.',
             updated_at = NOW()
       WHERE id = $1 AND status = 'live'
       RETURNING id`,
      [id]
    );
    if (result.rows.length === 0) return res.status(404).json({ error: 'No matching live asset' });
    res.json({ id });
  } catch (err) { next(err); }
});

// POST /:id/generate-proxy — re-queue proxy for an asset that has a hi-res