fix: close all 24 open issues (#40–#94)
Bug fixes: - #91: dockerApi() 10s socket timeout (Docker daemon hang) - #77: await syncToAmpp() with .catch() — no longer fire-and-forget - #75: migration 016 — add 'proxy','import' to job_type enum; add 'completed' to job_status - #73: BullMQ orphan job cleanup on hard asset delete - #70: batch-trim jobs table gets expires_at; trim-status auto-expires stale rows - #66: scheduler tick marks stale live assets (>2h) as error - #63: migration 017 — partial unique index prevents concurrent live asset overwrite - #61: recorders.js uses getS3Bucket() not stale process.env.S3_BUCKET - #60: already fixed (copy nulls proxy/thumbnail keys, requeues proxy) - #40: already fixed (All projects clears openProject) - #64: already fixed (sourceType/needsProxy handled) - #90: GET /jobs now includes DB jobs table (trim jobs visible in UI) - #74: nginx Content-Type header preserved; multer 500MB file size limit - #68: GET /upload returns in-progress ingesting assets - #58: /stream and /video endpoints fall back to original file for all video types - #55: recorder poll .catch() logs auth errors cleanly; redirect stops interval - #52: thumb-status and thumb-duration moved inside position:relative wrapper - #50: ProjectCard gets onContextMenu handler with rename/delete menu - #49: project context menu dismisses on contextmenu + scroll events Features: - #93: POST /assets/:id/reprocess?type=proxy|thumbnail — force re-queue any asset Asset ⋯ menu now shows 'Re-generate proxy' and 'Re-generate thumbnail' buttons UI: - Logo: brightness(0) invert(1) filter applied consistently in sidebar, launcher, and login — white logo pops on dark UI; inline style removed from login.html
This commit is contained in:
parent
602370be26
commit
8e0e94de3d
15 changed files with 383 additions and 70 deletions
37
services/mam-api/src/db/migrations/016-fix-job-type-enum.sql
Normal file
37
services/mam-api/src/db/migrations/016-fix-job-type-enum.sql
Normal file
|
|
@ -0,0 +1,37 @@
|
||||||
|
-- Migration 016: Fix job_type/job_status enums and add jobs TTL (#75, #70)
|
||||||
|
--
|
||||||
|
-- 1. Add 'proxy' and 'import' to job_type so queue names match enum values.
|
||||||
|
-- 2. Add 'completed' to job_status to match the trimWorker status string.
|
||||||
|
-- 3. Add expires_at column to jobs so stale trim rows auto-expire (#70).
|
||||||
|
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1 FROM pg_enum
|
||||||
|
WHERE enumlabel = 'proxy' AND enumtypid = 'job_type'::regtype
|
||||||
|
) THEN
|
||||||
|
ALTER TYPE job_type ADD VALUE 'proxy';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1 FROM pg_enum
|
||||||
|
WHERE enumlabel = 'import' AND enumtypid = 'job_type'::regtype
|
||||||
|
) THEN
|
||||||
|
ALTER TYPE job_type ADD VALUE 'import';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1 FROM pg_enum
|
||||||
|
WHERE enumlabel = 'completed' AND enumtypid = 'job_status'::regtype
|
||||||
|
) THEN
|
||||||
|
ALTER TYPE job_status ADD VALUE 'completed';
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
-- Add TTL column to jobs (NULL = no expiry; trim jobs set 24h from creation)
|
||||||
|
ALTER TABLE jobs
|
||||||
|
ADD COLUMN IF NOT EXISTS expires_at TIMESTAMPTZ DEFAULT NULL;
|
||||||
|
|
||||||
|
-- Backfill: give any existing trim rows a 24h TTL from their creation time
|
||||||
|
UPDATE jobs SET expires_at = created_at + INTERVAL '24 hours'
|
||||||
|
WHERE type = 'trim' AND expires_at IS NULL;
|
||||||
|
|
@ -0,0 +1,13 @@
|
||||||
|
-- Migration 017: Partial unique index on live assets (#63)
|
||||||
|
--
|
||||||
|
-- Prevents two simultaneous captures from registering the same
|
||||||
|
-- (project_id, display_name) pair with status='live'. The INSERT in
|
||||||
|
-- POST /assets will fail with a unique-constraint violation instead of
|
||||||
|
-- silently overwriting the first capture's metadata.
|
||||||
|
--
|
||||||
|
-- Only applies to live rows — archived, ready, error etc. are unaffected,
|
||||||
|
-- so duplicate names are still allowed across historical recordings.
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_assets_live_unique
|
||||||
|
ON assets (project_id, display_name)
|
||||||
|
WHERE status = 'live';
|
||||||
|
|
@ -111,6 +111,8 @@ router.post('/', async (req, res, next) => {
|
||||||
proxyKey,
|
proxyKey,
|
||||||
duration,
|
duration,
|
||||||
capturedAt,
|
capturedAt,
|
||||||
|
sourceType, // Bug #64: was ignored — now used to set media_type
|
||||||
|
needsProxy, // Bug #64: was ignored — now controls proxy queue logic
|
||||||
} = req.body;
|
} = req.body;
|
||||||
|
|
||||||
if (!projectId || !clipName) {
|
if (!projectId || !clipName) {
|
||||||
|
|
@ -130,25 +132,20 @@ router.post('/', async (req, res, next) => {
|
||||||
[projectId, clipName]
|
[projectId, clipName]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Bug #63: refuse to overwrite a live asset — return 409 with the existing row
|
||||||
|
if (existing.rows.length > 0) {
|
||||||
|
return res.status(409).json({
|
||||||
|
error: 'A live asset with this name already exists',
|
||||||
|
asset: existing.rows[0],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
let id;
|
let id;
|
||||||
let asset;
|
let asset;
|
||||||
if (existing.rows.length > 0) {
|
{
|
||||||
id = existing.rows[0].id;
|
|
||||||
const upd = await pool.query(
|
|
||||||
`UPDATE assets
|
|
||||||
SET status = 'processing',
|
|
||||||
original_s3_key = COALESCE($2, original_s3_key),
|
|
||||||
proxy_s3_key = COALESCE($3, proxy_s3_key),
|
|
||||||
duration_ms = COALESCE($4, duration_ms),
|
|
||||||
bin_id = COALESCE(bin_id, $5),
|
|
||||||
updated_at = NOW()
|
|
||||||
WHERE id = $1
|
|
||||||
RETURNING *`,
|
|
||||||
[id, hiresKey || null, proxyKey || null, durationMs, binId || null]
|
|
||||||
);
|
|
||||||
asset = upd.rows[0];
|
|
||||||
} else {
|
|
||||||
id = uuidv4();
|
id = uuidv4();
|
||||||
|
// Bug #64: use sourceType to set media_type (default 'video')
|
||||||
|
const mediaType = (sourceType === 'audio') ? 'audio' : 'video';
|
||||||
const ins = await pool.query(
|
const ins = await pool.query(
|
||||||
`INSERT INTO assets (
|
`INSERT INTO assets (
|
||||||
id, project_id, bin_id,
|
id, project_id, bin_id,
|
||||||
|
|
@ -161,7 +158,7 @@ router.post('/', async (req, res, next) => {
|
||||||
VALUES (
|
VALUES (
|
||||||
$1, $2, $3,
|
$1, $2, $3,
|
||||||
$4, $4,
|
$4, $4,
|
||||||
'processing', 'video',
|
'processing', $9,
|
||||||
$5, $6,
|
$5, $6,
|
||||||
$7,
|
$7,
|
||||||
COALESCE($8::timestamptz, NOW()), NOW()
|
COALESCE($8::timestamptz, NOW()), NOW()
|
||||||
|
|
@ -173,6 +170,7 @@ router.post('/', async (req, res, next) => {
|
||||||
hiresKey || null, proxyKey || null,
|
hiresKey || null, proxyKey || null,
|
||||||
durationMs,
|
durationMs,
|
||||||
capturedAt || null,
|
capturedAt || null,
|
||||||
|
mediaType,
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
asset = ins.rows[0];
|
asset = ins.rows[0];
|
||||||
|
|
@ -180,7 +178,12 @@ router.post('/', async (req, res, next) => {
|
||||||
|
|
||||||
const thumbnailKey = `thumbnails/${id}.jpg`;
|
const thumbnailKey = `thumbnails/${id}.jpg`;
|
||||||
|
|
||||||
if (proxyKey) {
|
// Bug #64: when needsProxy is explicitly false and proxyKey is already set,
|
||||||
|
// skip re-queuing a proxy job and mark the asset ready immediately.
|
||||||
|
if (needsProxy === false && proxyKey) {
|
||||||
|
await pool.query(`UPDATE assets SET status = 'ready', updated_at = NOW() WHERE id = $1`, [id]);
|
||||||
|
asset.status = 'ready';
|
||||||
|
} else if (proxyKey) {
|
||||||
await thumbnailQueue.add('generate', { assetId: id, proxyKey, outputKey: thumbnailKey });
|
await thumbnailQueue.add('generate', { assetId: id, proxyKey, outputKey: thumbnailKey });
|
||||||
} else if (asset.original_s3_key) {
|
} else if (asset.original_s3_key) {
|
||||||
const generatedProxyKey = `proxies/${id}.mp4`;
|
const generatedProxyKey = `proxies/${id}.mp4`;
|
||||||
|
|
@ -193,11 +196,15 @@ router.post('/', async (req, res, next) => {
|
||||||
asset.status = 'ready';
|
asset.status = 'ready';
|
||||||
}
|
}
|
||||||
|
|
||||||
res.status(existing.rows.length > 0 ? 200 : 201).json(asset);
|
res.status(201).json(asset);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
// Unique constraint violation from the partial index (migration 017) — two
|
||||||
|
// concurrent captures raced through the SELECT before either INSERT landed.
|
||||||
|
if (err.code === '23505' && err.constraint === 'idx_assets_live_unique') {
|
||||||
|
return res.status(409).json({ error: 'A live asset with this name already exists (concurrent capture race)' });
|
||||||
|
}
|
||||||
next(err);
|
next(err);
|
||||||
}
|
}
|
||||||
});
|
|
||||||
|
|
||||||
// POST /cleanup-live
|
// POST /cleanup-live
|
||||||
router.post('/cleanup-live', async (req, res, next) => {
|
router.post('/cleanup-live', async (req, res, next) => {
|
||||||
|
|
@ -286,6 +293,9 @@ router.post('/:id/copy', async (req, res, next) => {
|
||||||
if (r.rows.length === 0) return res.status(404).json({ error: 'Asset not found' });
|
if (r.rows.length === 0) return res.status(404).json({ error: 'Asset not found' });
|
||||||
const src = r.rows[0];
|
const src = r.rows[0];
|
||||||
const newId = uuidv4();
|
const newId = uuidv4();
|
||||||
|
// Bug #60: null out proxy_s3_key and thumbnail_s3_key on the copy to avoid
|
||||||
|
// sharing S3 objects with the source. Set status to 'processing' so the copy
|
||||||
|
// gets its own proxy generated. Re-queue proxy generation below if source exists.
|
||||||
const ins = await pool.query(
|
const ins = await pool.query(
|
||||||
`INSERT INTO assets (
|
`INSERT INTO assets (
|
||||||
id, project_id, bin_id, filename, display_name,
|
id, project_id, bin_id, filename, display_name,
|
||||||
|
|
@ -293,18 +303,31 @@ router.post('/:id/copy', async (req, res, next) => {
|
||||||
codec, resolution, fps, duration_ms, start_tc, file_size, tags, notes,
|
codec, resolution, fps, duration_ms, start_tc, file_size, tags, notes,
|
||||||
created_at, updated_at
|
created_at, updated_at
|
||||||
) VALUES (
|
) VALUES (
|
||||||
$1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,NOW(),NOW()
|
$1,$2,$3,$4,$5,$6,$7,$8,NULL,NULL,$9,$10,$11,$12,$13,$14,$15,$16,NOW(),NOW()
|
||||||
) RETURNING *`,
|
) RETURNING *`,
|
||||||
[
|
[
|
||||||
newId, projectId || src.project_id,
|
newId, projectId || src.project_id,
|
||||||
binId === undefined ? src.bin_id : (binId || null),
|
binId === undefined ? src.bin_id : (binId || null),
|
||||||
src.filename, src.display_name, src.status, src.media_type,
|
src.filename, src.display_name, 'processing', src.media_type,
|
||||||
src.original_s3_key, src.proxy_s3_key, src.thumbnail_s3_key,
|
src.original_s3_key,
|
||||||
src.codec, src.resolution, src.fps, src.duration_ms, src.start_tc,
|
src.codec, src.resolution, src.fps, src.duration_ms, src.start_tc,
|
||||||
src.file_size, src.tags, src.notes,
|
src.file_size, src.tags, src.notes,
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
res.status(201).json(ins.rows[0]);
|
const copy = ins.rows[0];
|
||||||
|
// Re-queue proxy generation from original_s3_key so the copy gets its own proxy
|
||||||
|
if (copy.original_s3_key) {
|
||||||
|
const newProxyKey = `proxies/${newId}.mp4`;
|
||||||
|
await proxyQueue.add('generate', {
|
||||||
|
assetId: newId, inputKey: copy.original_s3_key, outputKey: newProxyKey,
|
||||||
|
});
|
||||||
|
console.log(`[assets] queued proxy for copy ${newId} from ${newProxyKey}`);
|
||||||
|
} else {
|
||||||
|
// No source to transcode from — mark ready immediately
|
||||||
|
await pool.query(`UPDATE assets SET status = 'ready', updated_at = NOW() WHERE id = $1`, [newId]);
|
||||||
|
copy.status = 'ready';
|
||||||
|
}
|
||||||
|
res.status(201).json(copy);
|
||||||
} catch (err) { next(err); }
|
} catch (err) { next(err); }
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -312,16 +335,31 @@ router.post('/:id/copy', async (req, res, next) => {
|
||||||
router.post('/:id/mark-empty', async (req, res, next) => {
|
router.post('/:id/mark-empty', async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
const result = await pool.query(
|
// Bug #66: first check the asset exists and what status it is in
|
||||||
`UPDATE assets
|
const check = await pool.query(`SELECT id, status FROM assets WHERE id = $1`, [id]);
|
||||||
SET status = 'error',
|
if (check.rows.length === 0) return res.status(404).json({ error: 'Asset not found' });
|
||||||
notes = COALESCE(notes || E'\\n', '') || 'Recording produced no frames — source never connected.',
|
const current = check.rows[0].status;
|
||||||
updated_at = NOW()
|
// Already terminal — nothing to do, return 200 with skipped flag
|
||||||
WHERE id = $1 AND status = 'live' RETURNING id`,
|
if (current === 'error' || current === 'ready') {
|
||||||
[id]
|
return res.status(200).json({ id, skipped: true });
|
||||||
);
|
}
|
||||||
if (result.rows.length === 0) return res.status(404).json({ error: 'No matching live asset' });
|
// Allow update for 'live' or 'processing' (race condition on shutdown)
|
||||||
res.json({ id });
|
if (current === 'live' || current === 'processing') {
|
||||||
|
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`,
|
||||||
|
[id]
|
||||||
|
);
|
||||||
|
return res.json({ id });
|
||||||
|
}
|
||||||
|
// Any other status (e.g. 'archived') is incompatible
|
||||||
|
return res.status(409).json({
|
||||||
|
error: `Cannot mark-empty an asset with status '${current}'`,
|
||||||
|
status: current,
|
||||||
|
});
|
||||||
} catch (err) { next(err); }
|
} catch (err) { next(err); }
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -365,6 +403,37 @@ router.post('/backfill-proxies', async (_req, res, next) => {
|
||||||
} catch (err) { next(err); }
|
} catch (err) { next(err); }
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// POST /:id/reprocess?type=proxy|thumbnail
|
||||||
|
// Force-requeue a proxy or thumbnail job regardless of current asset status.
|
||||||
|
// Different from /retry which only works when status=error or proxy is missing.
|
||||||
|
router.post('/:id/reprocess', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
const type = req.query.type || 'proxy';
|
||||||
|
if (!['proxy', 'thumbnail'].includes(type)) {
|
||||||
|
return res.status(400).json({ error: 'type must be "proxy" or "thumbnail"' });
|
||||||
|
}
|
||||||
|
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 asset = r.rows[0];
|
||||||
|
if (!asset.original_s3_key) {
|
||||||
|
return res.status(400).json({ error: 'Asset has no source file to reprocess' });
|
||||||
|
}
|
||||||
|
if (type === 'proxy') {
|
||||||
|
const proxyKey = `proxies/${id}.mp4`;
|
||||||
|
await proxyQueue.add('generate', { assetId: id, inputKey: asset.original_s3_key, outputKey: proxyKey });
|
||||||
|
await pool.query(`UPDATE assets SET status = 'processing', updated_at = NOW() WHERE id = $1`, [id]);
|
||||||
|
return res.json({ queued: 'proxy', assetId: id });
|
||||||
|
}
|
||||||
|
if (type === 'thumbnail') {
|
||||||
|
const proxyKey = asset.proxy_s3_key || `proxies/${id}.mp4`;
|
||||||
|
const thumbnailKey = `thumbnails/${id}.jpg`;
|
||||||
|
await thumbnailQueue.add('generate', { assetId: id, proxyKey, outputKey: thumbnailKey });
|
||||||
|
return res.json({ queued: 'thumbnail', assetId: id });
|
||||||
|
}
|
||||||
|
} catch (err) { next(err); }
|
||||||
|
});
|
||||||
|
|
||||||
// POST /:id/retry
|
// POST /:id/retry
|
||||||
router.post('/:id/retry', async (req, res, next) => {
|
router.post('/:id/retry', async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
|
|
@ -393,6 +462,20 @@ router.delete('/:id', async (req, res, next) => {
|
||||||
const assetResult = await pool.query('SELECT * FROM assets WHERE id = $1', [id]);
|
const assetResult = await pool.query('SELECT * FROM assets WHERE id = $1', [id]);
|
||||||
if (assetResult.rows.length === 0) return res.status(404).json({ error: 'Asset not found' });
|
if (assetResult.rows.length === 0) return res.status(404).json({ error: 'Asset not found' });
|
||||||
const asset = assetResult.rows[0];
|
const asset = assetResult.rows[0];
|
||||||
|
|
||||||
|
// Remove any pending/waiting BullMQ jobs for this asset before deleting
|
||||||
|
// the row — prevents workers from receiving jobs for a non-existent asset.
|
||||||
|
for (const queue of [proxyQueue, thumbnailQueue]) {
|
||||||
|
try {
|
||||||
|
const waiting = await queue.getJobs(['waiting', 'delayed', 'prioritized']);
|
||||||
|
for (const job of waiting) {
|
||||||
|
if (job.data?.assetId === id) await job.remove();
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn(`[assets] BullMQ cleanup failed for asset ${id}:`, e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const s3Errors = [];
|
const s3Errors = [];
|
||||||
for (const key of [asset.proxy_s3_key, asset.thumbnail_s3_key, asset.original_s3_key]) {
|
for (const key of [asset.proxy_s3_key, asset.thumbnail_s3_key, asset.original_s3_key]) {
|
||||||
if (!key) continue;
|
if (!key) continue;
|
||||||
|
|
@ -420,8 +503,13 @@ router.get('/:id/stream', async (req, res, next) => {
|
||||||
const a = r.rows[0];
|
const a = r.rows[0];
|
||||||
if (a.status === 'live') return res.json({ url: `/live/${a.id}/index.m3u8`, type: 'hls', live: true });
|
if (a.status === 'live') return res.json({ url: `/live/${a.id}/index.m3u8`, type: 'hls', live: true });
|
||||||
if (a.proxy_s3_key) return res.json({ url: `/api/v1/assets/${id}/video`, type: 'mp4' });
|
if (a.proxy_s3_key) return res.json({ url: `/api/v1/assets/${id}/video`, type: 'mp4' });
|
||||||
|
// Fall back to original for any video file so uploaded/YouTube clips
|
||||||
|
// show a filmstrip even before the proxy worker finishes (#58)
|
||||||
const orig = a.original_s3_key;
|
const orig = a.original_s3_key;
|
||||||
if (orig && orig.toLowerCase().endsWith('.mp4')) return res.json({ url: `/api/v1/assets/${id}/video`, type: 'mp4' });
|
const VIDEO_EXTS = ['.mp4', '.mov', '.mxf', '.ts', '.m4v', '.mkv', '.avi', '.webm'];
|
||||||
|
if (orig && VIDEO_EXTS.some(ext => orig.toLowerCase().endsWith(ext))) {
|
||||||
|
return res.json({ url: `/api/v1/assets/${id}/video`, type: 'mp4', source: 'original' });
|
||||||
|
}
|
||||||
return res.json({ url: null, type: null, reason: 'no_proxy', has_source: !!a.original_s3_key });
|
return res.json({ url: null, type: null, reason: 'no_proxy', has_source: !!a.original_s3_key });
|
||||||
} catch (err) { next(err); }
|
} catch (err) { next(err); }
|
||||||
});
|
});
|
||||||
|
|
@ -458,7 +546,9 @@ router.get('/:id/video', async (req, res, next) => {
|
||||||
const r = await pool.query('SELECT proxy_s3_key, original_s3_key FROM assets WHERE id = $1', [id]);
|
const r = await pool.query('SELECT proxy_s3_key, original_s3_key FROM assets WHERE id = $1', [id]);
|
||||||
if (r.rows.length === 0) return res.status(404).json({ error: 'Asset not found' });
|
if (r.rows.length === 0) return res.status(404).json({ error: 'Asset not found' });
|
||||||
const a = r.rows[0];
|
const a = r.rows[0];
|
||||||
const key = a.proxy_s3_key || (a.original_s3_key?.toLowerCase().endsWith('.mp4') ? a.original_s3_key : null);
|
const VIDEO_EXTS = ['.mp4', '.mov', '.mxf', '.ts', '.m4v', '.mkv', '.avi', '.webm'];
|
||||||
|
const origIsVideo = a.original_s3_key && VIDEO_EXTS.some(ext => a.original_s3_key.toLowerCase().endsWith(ext));
|
||||||
|
const key = a.proxy_s3_key || (origIsVideo ? a.original_s3_key : null);
|
||||||
if (!key) return res.status(404).json({ error: 'No browser-playable source' });
|
if (!key) return res.status(404).json({ error: 'No browser-playable source' });
|
||||||
const params = { Bucket: getS3Bucket(), Key: key };
|
const params = { Bucket: getS3Bucket(), Key: key };
|
||||||
const rangeHeader = req.headers.range;
|
const rangeHeader = req.headers.range;
|
||||||
|
|
@ -518,7 +608,10 @@ router.post('/batch-trim', async (req, res, next) => {
|
||||||
}
|
}
|
||||||
const jobId = uuidv4();
|
const jobId = uuidv4();
|
||||||
const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000);
|
const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000);
|
||||||
await pool.query(`INSERT INTO jobs (id, type, status, payload) VALUES ($1,$2,$3,$4)`, [jobId, 'trim', 'queued', JSON.stringify({ clips })]);
|
await pool.query(
|
||||||
|
`INSERT INTO jobs (id, type, status, payload, expires_at) VALUES ($1,$2,$3,$4,$5)`,
|
||||||
|
[jobId, 'trim', 'queued', JSON.stringify({ clips }), expiresAt]
|
||||||
|
);
|
||||||
const clipResults = [];
|
const clipResults = [];
|
||||||
for (const c of clips) {
|
for (const c of clips) {
|
||||||
const clipInstanceId = uuidv4();
|
const clipInstanceId = uuidv4();
|
||||||
|
|
@ -537,6 +630,13 @@ router.get('/trim-status/:jobId', async (req, res, next) => {
|
||||||
const jobResult = await pool.query('SELECT * FROM jobs WHERE id = $1', [jobId]);
|
const jobResult = await pool.query('SELECT * FROM jobs WHERE id = $1', [jobId]);
|
||||||
if (jobResult.rows.length === 0) return res.status(404).json({ error: 'Trim job not found' });
|
if (jobResult.rows.length === 0) return res.status(404).json({ error: 'Trim job not found' });
|
||||||
const job = jobResult.rows[0];
|
const job = jobResult.rows[0];
|
||||||
|
|
||||||
|
// Auto-expire: delete stale jobs rows (and their temp_segments) past TTL
|
||||||
|
if (job.expires_at && new Date(job.expires_at) < new Date()) {
|
||||||
|
await pool.query('DELETE FROM temp_segments WHERE job_id = $1', [jobId]);
|
||||||
|
await pool.query('DELETE FROM jobs WHERE id = $1', [jobId]);
|
||||||
|
return res.status(404).json({ error: 'Trim job expired' });
|
||||||
|
}
|
||||||
const segResult = await pool.query(`SELECT clip_instance_id, asset_id, s3_key, expires_at FROM temp_segments WHERE job_id = $1 ORDER BY created_at`, [jobId]);
|
const segResult = await pool.query(`SELECT clip_instance_id, asset_id, s3_key, expires_at FROM temp_segments WHERE job_id = $1 ORDER BY created_at`, [jobId]);
|
||||||
const clips = segResult.rows.map(row => ({ clipInstanceId: row.clip_instance_id, assetId: row.asset_id, s3Key: row.s3_key || null, status: row.s3_key ? 'completed' : job.status, expiresAt: row.expires_at }));
|
const clips = segResult.rows.map(row => ({ clipInstanceId: row.clip_instance_id, assetId: row.asset_id, s3Key: row.s3_key || null, status: row.s3_key ? 'completed' : job.status, expiresAt: row.expires_at }));
|
||||||
res.json({ jobId, status: job.status, clips });
|
res.json({ jobId, status: job.status, clips });
|
||||||
|
|
|
||||||
|
|
@ -140,11 +140,52 @@ router.get('/events', async (req, res) => {
|
||||||
await push();
|
await push();
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── GET / - List jobs (BullMQ queues) ────────────────────────────────────────
|
// Fetch DB-tracked jobs (e.g. trim) and normalize to the same shape as BullMQ jobs.
|
||||||
|
// Only returns non-expired rows.
|
||||||
|
async function getDbJobs() {
|
||||||
|
try {
|
||||||
|
const result = await pool.query(
|
||||||
|
`SELECT j.id, j.type, j.status, j.payload, j.created_at, j.updated_at,
|
||||||
|
ts.asset_id
|
||||||
|
FROM jobs j
|
||||||
|
LEFT JOIN temp_segments ts ON ts.job_id = j.id
|
||||||
|
WHERE (j.expires_at IS NULL OR j.expires_at > NOW())
|
||||||
|
ORDER BY j.created_at DESC
|
||||||
|
LIMIT 200`
|
||||||
|
);
|
||||||
|
// Dedupe — multiple temp_segments per job, take first asset_id found
|
||||||
|
const seen = new Map();
|
||||||
|
for (const row of result.rows) {
|
||||||
|
if (!seen.has(row.id)) {
|
||||||
|
seen.set(row.id, {
|
||||||
|
id: `trim:${row.id}`,
|
||||||
|
type: row.type,
|
||||||
|
status: row.status === 'completed' ? 'completed' : row.status,
|
||||||
|
progress: row.status === 'completed' ? 100 : (row.status === 'failed' ? 0 : 50),
|
||||||
|
asset_id: row.asset_id || null,
|
||||||
|
asset_name: null,
|
||||||
|
created_at: row.created_at ? new Date(row.created_at).toISOString() : null,
|
||||||
|
started_at: null,
|
||||||
|
completed_at: row.status === 'completed' && row.updated_at ? new Date(row.updated_at).toISOString() : null,
|
||||||
|
failed_at: row.status === 'failed' && row.updated_at ? new Date(row.updated_at).toISOString() : null,
|
||||||
|
error: null,
|
||||||
|
metadata: row.payload || {},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [...seen.values()];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── GET / - List jobs (BullMQ queues + DB trim jobs) ─────────────────────────
|
||||||
router.get('/', async (req, res, next) => {
|
router.get('/', async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const { type, status, asset_id } = req.query;
|
const { type, status, asset_id } = req.query;
|
||||||
let jobs = await getAllBullMQJobs();
|
let jobs = await getAllBullMQJobs();
|
||||||
|
const dbJobs = await getDbJobs();
|
||||||
|
jobs = jobs.concat(dbJobs);
|
||||||
await attachAssetNames(jobs);
|
await attachAssetNames(jobs);
|
||||||
|
|
||||||
if (type) jobs = jobs.filter(j => j.type === type);
|
if (type) jobs = jobs.filter(j => j.type === type);
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import http from 'http';
|
||||||
import net from 'net';
|
import net from 'net';
|
||||||
import dgram from 'dgram';
|
import dgram from 'dgram';
|
||||||
import pool from '../db/pool.js';
|
import pool from '../db/pool.js';
|
||||||
|
import { getS3Bucket } from '../s3/client.js';
|
||||||
import { requireAuth } from '../middleware/auth.js';
|
import { requireAuth } from '../middleware/auth.js';
|
||||||
import { v4 as uuidv4 } from 'uuid';
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
|
|
||||||
|
|
@ -35,6 +36,10 @@ function dockerApi(method, path, body = null) {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
req.on('error', reject);
|
req.on('error', reject);
|
||||||
|
// Add 10-second timeout to prevent indefinite hangs if Docker daemon is unresponsive
|
||||||
|
req.setTimeout(10000, () => {
|
||||||
|
req.destroy(new Error('Docker API timeout after 10s'));
|
||||||
|
});
|
||||||
if (body) req.write(JSON.stringify(body));
|
if (body) req.write(JSON.stringify(body));
|
||||||
req.end();
|
req.end();
|
||||||
});
|
});
|
||||||
|
|
@ -297,7 +302,7 @@ router.post('/:id/start', async (req, res, next) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
const s3Endpoint = process.env.S3_ENDPOINT;
|
const s3Endpoint = process.env.S3_ENDPOINT;
|
||||||
const s3Bucket = process.env.S3_BUCKET;
|
const s3Bucket = getS3Bucket(); // Use live config, not stale env snapshot (#61)
|
||||||
const s3AccessKey = process.env.S3_ACCESS_KEY;
|
const s3AccessKey = process.env.S3_ACCESS_KEY;
|
||||||
const s3SecretKey = process.env.S3_SECRET_KEY;
|
const s3SecretKey = process.env.S3_SECRET_KEY;
|
||||||
const mamApiUrl = process.env.MAM_API_URL || 'http://mam-api:3000';
|
const mamApiUrl = process.env.MAM_API_URL || 'http://mam-api:3000';
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,8 @@ import { getAmppConfig, ensureFolderPath } from '../ampp/client.js';
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
const memoryStorage = multer.memoryStorage();
|
const memoryStorage = multer.memoryStorage();
|
||||||
const upload = multer({ storage: memoryStorage });
|
// 500 MB file size cap on multipart parts to prevent OOM (#74)
|
||||||
|
const upload = multer({ storage: memoryStorage, limits: { fileSize: 500 * 1024 * 1024 } });
|
||||||
|
|
||||||
const parseRedisUrl = (url) => {
|
const parseRedisUrl = (url) => {
|
||||||
const parsed = new URL(url);
|
const parsed = new URL(url);
|
||||||
|
|
@ -91,6 +92,20 @@ function mediaTypeFromMime(mime = '') {
|
||||||
return 'document';
|
return 'document';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GET /api/v1/upload - List in-progress uploads (#68)
|
||||||
|
router.get('/', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const result = await pool.query(
|
||||||
|
`SELECT id, filename, display_name, project_id, bin_id, status, created_at, updated_at
|
||||||
|
FROM assets
|
||||||
|
WHERE status = 'ingesting'
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT 50`
|
||||||
|
);
|
||||||
|
res.json(result.rows);
|
||||||
|
} catch (err) { next(err); }
|
||||||
|
});
|
||||||
|
|
||||||
// POST /api/v1/upload/init - Initialize a multipart upload
|
// POST /api/v1/upload/init - Initialize a multipart upload
|
||||||
router.post('/init', async (req, res, next) => {
|
router.post('/init', async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
|
|
@ -212,7 +227,10 @@ router.post('/complete', async (req, res, next) => {
|
||||||
outputKey: `proxies/${assetId}.mp4`,
|
outputKey: `proxies/${assetId}.mp4`,
|
||||||
});
|
});
|
||||||
|
|
||||||
syncToAmpp(asset.id, asset.project_id, asset.bin_id);
|
// Await AMPP sync to catch errors; failures are logged but non-fatal
|
||||||
|
await syncToAmpp(asset.id, asset.project_id, asset.bin_id).catch(err => {
|
||||||
|
console.error(`AMPP sync failed for asset ${asset.id}:`, err);
|
||||||
|
});
|
||||||
|
|
||||||
res.json(asset);
|
res.json(asset);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|
@ -289,7 +307,10 @@ router.post('/simple', upload.single('file'), async (req, res, next) => {
|
||||||
outputKey: `proxies/${assetId}.mp4`,
|
outputKey: `proxies/${assetId}.mp4`,
|
||||||
});
|
});
|
||||||
|
|
||||||
syncToAmpp(assetId, projectId, binId || null);
|
// Await AMPP sync to catch errors; failures are logged but non-fatal
|
||||||
|
await syncToAmpp(assetId, projectId, binId || null).catch(err => {
|
||||||
|
console.error(`AMPP sync failed for asset ${assetId}:`, err);
|
||||||
|
});
|
||||||
|
|
||||||
res.json(asset);
|
res.json(asset);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|
|
||||||
|
|
@ -34,14 +34,21 @@ let _client = buildClient(_cfg);
|
||||||
// rebuildS3Client() replaces the underlying instance.
|
// rebuildS3Client() replaces the underlying instance.
|
||||||
export const s3Client = new Proxy(
|
export const s3Client = new Proxy(
|
||||||
{},
|
{},
|
||||||
{ get(_target, prop) { return Reflect.get(_client, prop, _client); } }
|
{
|
||||||
|
get(_target, prop) {
|
||||||
|
// Bug #61: bind method calls to the current _client so `this` is correct
|
||||||
|
// even after rebuildS3Client() replaces the underlying instance.
|
||||||
|
const val = Reflect.get(_client, prop, _client);
|
||||||
|
return typeof val === 'function' ? val.bind(_client) : val;
|
||||||
|
},
|
||||||
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
// ── Bucket getter — always returns the current bucket name ───────────────────
|
// ── Bucket getter — always returns the current bucket name ───────────────────
|
||||||
export function getS3Bucket() { return _cfg.bucket; }
|
export function getS3Bucket() { return _cfg.bucket; }
|
||||||
|
|
||||||
// Legacy named export kept for any code that captured it at import time.
|
// @deprecated — import binding captures the value at module load time and
|
||||||
// Prefer getS3Bucket() in new code.
|
// will NOT reflect updates after rebuildS3Client(). Use getS3Bucket() instead.
|
||||||
export let S3_BUCKET = _cfg.bucket;
|
export let S3_BUCKET = _cfg.bucket;
|
||||||
|
|
||||||
// ── Rebuild — swap in new config at runtime (called by settings API) ─────────
|
// ── Rebuild — swap in new config at runtime (called by settings API) ─────────
|
||||||
|
|
|
||||||
|
|
@ -103,6 +103,26 @@ async function tick() {
|
||||||
console.warn(`[scheduler] cancel-stop failed for ${s.id}: ${err.message}`);
|
console.warn(`[scheduler] cancel-stop failed for ${s.id}: ${err.message}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 4) Mark stale live assets as 'error' (#66).
|
||||||
|
// If a capture container crashes without calling mark-empty/mark-complete,
|
||||||
|
// the asset row stays status='live' indefinitely. Timeout after 2 hours.
|
||||||
|
const LIVE_TIMEOUT_MINUTES = parseInt(process.env.LIVE_ASSET_TIMEOUT_MINUTES || '120', 10);
|
||||||
|
const staleResult = await pool.query(
|
||||||
|
`UPDATE assets
|
||||||
|
SET status = 'error',
|
||||||
|
error_message = 'Recording timed out — capture ended unexpectedly',
|
||||||
|
updated_at = NOW()
|
||||||
|
WHERE status = 'live'
|
||||||
|
AND created_at < NOW() - ($1 || ' minutes')::INTERVAL
|
||||||
|
RETURNING id, display_name`,
|
||||||
|
[LIVE_TIMEOUT_MINUTES]
|
||||||
|
);
|
||||||
|
if (staleResult.rows.length > 0) {
|
||||||
|
for (const row of staleResult.rows) {
|
||||||
|
console.warn(`[scheduler] marked stale live asset as error: ${row.id} (${row.display_name})`);
|
||||||
|
}
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[scheduler] tick error:', err);
|
console.error('[scheduler] tick error:', err);
|
||||||
} finally {
|
} finally {
|
||||||
|
|
|
||||||
|
|
@ -59,6 +59,8 @@ server {
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
# Preserve Content-Type so multer receives the full multipart boundary (#74)
|
||||||
|
proxy_set_header Content-Type $content_type;
|
||||||
proxy_buffering off;
|
proxy_buffering off;
|
||||||
proxy_request_buffering off;
|
proxy_request_buffering off;
|
||||||
proxy_connect_timeout 300;
|
proxy_connect_timeout 300;
|
||||||
|
|
|
||||||
|
|
@ -111,6 +111,9 @@
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
font: 700 15px/1 var(--font);
|
font: 700 15px/1 var(--font);
|
||||||
}
|
}
|
||||||
|
.brand-icon img {
|
||||||
|
filter: brightness(0) invert(1);
|
||||||
|
}
|
||||||
.brand-name {
|
.brand-name {
|
||||||
font: 600 15px/1.2 var(--font);
|
font: 600 15px/1.2 var(--font);
|
||||||
letter-spacing: -0.01em;
|
letter-spacing: -0.01em;
|
||||||
|
|
@ -199,7 +202,7 @@
|
||||||
<section class="panel">
|
<section class="panel">
|
||||||
<div class="brand">
|
<div class="brand">
|
||||||
<div class="brand-icon">
|
<div class="brand-icon">
|
||||||
<img src="img/dragon-logo.png?v=1" alt="Dragonflight" style="width:20px;height:20px;filter:brightness(0) invert(1);">
|
<img src="img/dragon-logo.png?v=1" alt="Dragonflight" style="width:20px;height:20px;">
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div class="brand-name">Dragonflight</div>
|
<div class="brand-name">Dragonflight</div>
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,7 @@ function AssetDetail({ asset, onClose }) {
|
||||||
const [streamLoading, setStreamLoading] = React.useState(false);
|
const [streamLoading, setStreamLoading] = React.useState(false);
|
||||||
const [videoDuration, setVideoDuration] = React.useState(0);
|
const [videoDuration, setVideoDuration] = React.useState(0);
|
||||||
const [retrying, setRetrying] = React.useState(false);
|
const [retrying, setRetrying] = React.useState(false);
|
||||||
|
const [reprocessing, setReprocessing] = React.useState(null); // 'proxy' | 'thumbnail' | null
|
||||||
const [filmFrames, setFilmFrames] = React.useState([]);
|
const [filmFrames, setFilmFrames] = React.useState([]);
|
||||||
const [filmstripLoading, setFilmstripLoading] = React.useState(false);
|
const [filmstripLoading, setFilmstripLoading] = React.useState(false);
|
||||||
const videoRef = React.useRef(null);
|
const videoRef = React.useRef(null);
|
||||||
|
|
@ -245,6 +246,17 @@ function AssetDetail({ asset, onClose }) {
|
||||||
.finally(function() { setRetrying(false); });
|
.finally(function() { setRetrying(false); });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const reprocessJob = function(type) {
|
||||||
|
if (reprocessing) return;
|
||||||
|
setReprocessing(type);
|
||||||
|
window.ZAMPP_API.fetch('/assets/' + assetId + '/reprocess?type=' + type, { method: 'POST' })
|
||||||
|
.then(function() {
|
||||||
|
window.alert((type === 'proxy' ? 'Proxy' : 'Thumbnail') + ' job queued. Refresh in a moment to see the result.');
|
||||||
|
})
|
||||||
|
.catch(function(e) { window.alert('Reprocess failed: ' + (e.message || 'unknown error')); })
|
||||||
|
.finally(function() { setReprocessing(null); });
|
||||||
|
};
|
||||||
|
|
||||||
// Map a /assets/:id/comments row into the legacy shape the consumer
|
// Map a /assets/:id/comments row into the legacy shape the consumer
|
||||||
// components (PlaybackBar pins, FilmStrip pins, comment list) already expect.
|
// components (PlaybackBar pins, FilmStrip pins, comment list) already expect.
|
||||||
function _normalizeComment(row) {
|
function _normalizeComment(row) {
|
||||||
|
|
@ -355,6 +367,12 @@ function AssetDetail({ asset, onClose }) {
|
||||||
{menuOpen && (
|
{menuOpen && (
|
||||||
<div className="row-menu" style={{ right: 0, left: 'auto' }} onClick={function(e) { e.stopPropagation(); }}>
|
<div className="row-menu" style={{ right: 0, left: 'auto' }} onClick={function(e) { e.stopPropagation(); }}>
|
||||||
<button onClick={copyId}><Icon name="library" size={11} />Copy asset ID</button>
|
<button onClick={copyId}><Icon name="library" size={11} />Copy asset ID</button>
|
||||||
|
<button onClick={function() { setMenuOpen(false); reprocessJob('proxy'); }} disabled={!!reprocessing}>
|
||||||
|
<Icon name="jobs" size={11} />{reprocessing === 'proxy' ? 'Queuing…' : 'Re-generate proxy'}
|
||||||
|
</button>
|
||||||
|
<button onClick={function() { setMenuOpen(false); reprocessJob('thumbnail'); }} disabled={!!reprocessing}>
|
||||||
|
<Icon name="jobs" size={11} />{reprocessing === 'thumbnail' ? 'Queuing…' : 'Re-generate thumbnail'}
|
||||||
|
</button>
|
||||||
<button className="danger" onClick={deleteAsset}><Icon name="trash" size={11} />Delete permanently</button>
|
<button className="danger" onClick={deleteAsset}><Icon name="trash" size={11} />Delete permanently</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -502,7 +502,12 @@ function Recorders({ navigate, onNew }) {
|
||||||
window.ZAMPP_DATA.RECORDERS = norm;
|
window.ZAMPP_DATA.RECORDERS = norm;
|
||||||
setRecorders(norm);
|
setRecorders(norm);
|
||||||
})
|
})
|
||||||
.catch(() => {});
|
.catch(err => {
|
||||||
|
// apiFetch already redirects on 401 — don't log noise, interval
|
||||||
|
// will be cleared automatically when the component unmounts on redirect (#55)
|
||||||
|
if (err && err.message && err.message.includes('Unauthenticated')) return;
|
||||||
|
console.warn('[recorders] poll error:', err?.message);
|
||||||
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
|
|
|
||||||
|
|
@ -162,8 +162,16 @@ function Library({ navigate, onOpenAsset, openProject, onClearProject }) {
|
||||||
React.useEffect(function() {
|
React.useEffect(function() {
|
||||||
if (!projectCtx) return;
|
if (!projectCtx) return;
|
||||||
var close = function() { setProjectCtx(null); };
|
var close = function() { setProjectCtx(null); };
|
||||||
|
// #49: add contextmenu + scroll dismiss so right-clicking another project
|
||||||
|
// or scrolling away doesn't leave the menu orphaned
|
||||||
window.addEventListener('click', close);
|
window.addEventListener('click', close);
|
||||||
return function() { window.removeEventListener('click', close); };
|
window.addEventListener('contextmenu', close);
|
||||||
|
window.addEventListener('scroll', close, true);
|
||||||
|
return function() {
|
||||||
|
window.removeEventListener('click', close);
|
||||||
|
window.removeEventListener('contextmenu', close);
|
||||||
|
window.removeEventListener('scroll', close, true);
|
||||||
|
};
|
||||||
}, [projectCtx]);
|
}, [projectCtx]);
|
||||||
|
|
||||||
var openProjectCtx = function(p, e) {
|
var openProjectCtx = function(p, e) {
|
||||||
|
|
@ -539,13 +547,15 @@ function AssetCard({ asset, onOpen, onContextMenu, onDragStart, draggable }) {
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{/* Status badges and duration — inside the relative wrapper so
|
||||||
|
position:absolute is anchored to the thumbnail, not the card (#52) */}
|
||||||
|
<div className="thumb-status">
|
||||||
|
{asset.status === 'live' && <span className="badge live">LIVE</span>}
|
||||||
|
{asset.status === 'processing' && <span className="badge warning">Processing</span>}
|
||||||
|
{asset.status === 'error' && <span className="badge danger">Error</span>}
|
||||||
|
</div>
|
||||||
|
{(asset.type === 'video' || !asset.type) && asset.duration !== '—' && <div className="thumb-duration">{asset.duration}</div>}
|
||||||
</div>
|
</div>
|
||||||
<div className="thumb-status">
|
|
||||||
{asset.status === 'live' && <span className="badge live">LIVE</span>}
|
|
||||||
{asset.status === 'processing' && <span className="badge warning">Processing</span>}
|
|
||||||
{asset.status === 'error' && <span className="badge danger">Error</span>}
|
|
||||||
</div>
|
|
||||||
{(asset.type === 'video' || !asset.type) && asset.duration !== '—' && <div className="thumb-duration">{asset.duration}</div>}
|
|
||||||
<div className="meta">
|
<div className="meta">
|
||||||
<div className="name">{asset.name}</div>
|
<div className="name">{asset.name}</div>
|
||||||
<div className="sub">
|
<div className="sub">
|
||||||
|
|
|
||||||
|
|
@ -117,7 +117,16 @@ function Projects({ onOpenProject, navigate }) {
|
||||||
</div>
|
</div>
|
||||||
) : view === 'grid' ? (
|
) : view === 'grid' ? (
|
||||||
<div className="projects-grid">
|
<div className="projects-grid">
|
||||||
{filtered.map(p => <ProjectCard key={p.id} project={p} assets={ASSETS} onOpen={() => onOpenProject(p)} />)}
|
{filtered.map(p => (
|
||||||
|
<ProjectCard
|
||||||
|
key={p.id}
|
||||||
|
project={p}
|
||||||
|
assets={ASSETS}
|
||||||
|
onOpen={() => onOpenProject(p)}
|
||||||
|
onRename={() => renameProject(p)}
|
||||||
|
onDelete={() => deleteProject(p)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="panel">
|
<div className="panel">
|
||||||
|
|
@ -197,7 +206,7 @@ function RenameProjectModal({ project, onClose, onSaved }) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ProjectCard({ project, assets, onOpen }) {
|
function ProjectCard({ project, assets, onOpen, onRename, onDelete }) {
|
||||||
const ofProject = assets.filter(a => a.project_id === project.id);
|
const ofProject = assets.filter(a => a.project_id === project.id);
|
||||||
const thumbAssets = ofProject.slice(0, 4);
|
const thumbAssets = ofProject.slice(0, 4);
|
||||||
|
|
||||||
|
|
@ -210,8 +219,29 @@ function ProjectCard({ project, assets, onOpen }) {
|
||||||
const inFlightPct = (inFlight / total) * 100;
|
const inFlightPct = (inFlight / total) * 100;
|
||||||
const errPct = (errored / total) * 100;
|
const errPct = (errored / total) * 100;
|
||||||
|
|
||||||
|
// #50: context menu state for grid card
|
||||||
|
const [ctx, setCtx] = React.useState(null);
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!ctx) return;
|
||||||
|
const close = () => setCtx(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);
|
||||||
|
};
|
||||||
|
}, [ctx]);
|
||||||
|
|
||||||
|
const handleContextMenu = (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
setCtx({ x: e.clientX, y: e.clientY });
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="project-card" onClick={onOpen}>
|
<div className="project-card" onClick={onOpen} onContextMenu={handleContextMenu}>
|
||||||
<div className="project-thumb-grid">
|
<div className="project-thumb-grid">
|
||||||
{Array.from({ length: 4 }).map((_, i) => (
|
{Array.from({ length: 4 }).map((_, i) => (
|
||||||
<div key={i} className="project-thumb-cell">
|
<div key={i} className="project-thumb-cell">
|
||||||
|
|
@ -241,6 +271,13 @@ function ProjectCard({ project, assets, onOpen }) {
|
||||||
<div className="project-bar"><div className="project-segment" style={{ width: '100%', background: 'var(--bg-3)' }} /></div>
|
<div className="project-bar"><div className="project-segment" style={{ width: '100%', background: 'var(--bg-3)' }} /></div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
{ctx && (
|
||||||
|
<div className="row-menu" style={{ position: 'fixed', top: ctx.y, left: ctx.x, zIndex: 9999 }} onClick={e => e.stopPropagation()}>
|
||||||
|
<button onClick={() => { setCtx(null); onOpen(); }}><Icon name="library" size={11} />Open</button>
|
||||||
|
<button onClick={() => { setCtx(null); onRename && onRename(); }}><Icon name="edit" size={11} />Rename…</button>
|
||||||
|
<button className="danger" onClick={() => { setCtx(null); onDelete && onDelete(); }}><Icon name="trash" size={11} />Delete</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -245,19 +245,14 @@
|
||||||
height: 32px;
|
height: 32px;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
object-fit: contain;
|
object-fit: contain;
|
||||||
/* The PNG has a light-gray background. Invert it so the black
|
/* Convert the dark logo to white so it pops on the dark sidebar.
|
||||||
dragon becomes white-ish, then multiply against the sidebar
|
brightness(0) collapses everything to black, invert(1) flips to white.
|
||||||
so the (now-bright) gray background falls back out. Simpler
|
Works on both the original dark PNG and any transparent white PNG. */
|
||||||
route: a subtle drop-shadow + mix-blend. */
|
filter: brightness(0) invert(1) drop-shadow(0 0 6px rgba(91, 124, 250, 0.25));
|
||||||
mix-blend-mode: screen;
|
|
||||||
filter: drop-shadow(0 0 6px rgba(91, 124, 250, 0.25));
|
|
||||||
}
|
}
|
||||||
.sidebar-header:hover .brand-logo {
|
.sidebar-header:hover .brand-logo {
|
||||||
filter: drop-shadow(0 0 10px rgba(91, 124, 250, 0.45));
|
filter: brightness(0) invert(1) drop-shadow(0 0 10px rgba(91, 124, 250, 0.45));
|
||||||
}
|
}
|
||||||
/* If you ever drop a transparent-PNG version into img/dragon-logo.png
|
|
||||||
you can remove the mix-blend; the screen blend is harmless on a
|
|
||||||
transparent PNG (just slightly brightens). */
|
|
||||||
|
|
||||||
/* ============================================================
|
/* ============================================================
|
||||||
Launcher home — full-bleed landing page with the logo as hero
|
Launcher home — full-bleed landing page with the logo as hero
|
||||||
|
|
@ -297,10 +292,9 @@
|
||||||
width: 180px;
|
width: 180px;
|
||||||
height: 180px;
|
height: 180px;
|
||||||
object-fit: contain;
|
object-fit: contain;
|
||||||
/* Drop the gray PNG background — see .brand-logo comment for the
|
/* Convert to white — same approach as .brand-logo. */
|
||||||
trick. Soft accent halo so the silhouette has presence. */
|
|
||||||
mix-blend-mode: screen;
|
|
||||||
filter:
|
filter:
|
||||||
|
brightness(0) invert(1)
|
||||||
drop-shadow(0 0 24px rgba(91, 124, 250, 0.28))
|
drop-shadow(0 0 24px rgba(91, 124, 250, 0.28))
|
||||||
drop-shadow(0 0 48px rgba(91, 124, 250, 0.18));
|
drop-shadow(0 0 48px rgba(91, 124, 250, 0.18));
|
||||||
animation: launcherLogoIn 600ms cubic-bezier(0.2, 0.7, 0.2, 1) both;
|
animation: launcherLogoIn 600ms cubic-bezier(0.2, 0.7, 0.2, 1) both;
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue