feat: add album cover generation via ComfyUI

- Add database migrations for image workflow settings and generated covers tracking
- Implement ComfyUI image generation workflow with separate JSON upload in admin panel
- Add cover generation routes with polling, preview, and apply/skip functionality
- Integrate cover generation into song publish flow (auto-redirect if no cover uploaded)
- Add sharp dependency for image processing and media serving for generated files
- Update admin UI with image workflow upload and configuration options
This commit is contained in:
Brian Fertig 2026-04-16 23:38:46 -06:00
parent 024c3a5e40
commit 13a2ad52e5
12 changed files with 502 additions and 53 deletions

View File

@ -0,0 +1,2 @@
ALTER TABLE site_settings ADD COLUMN comfyui_image_workflow_json TEXT;
ALTER TABLE site_settings ADD COLUMN comfyui_node_image_prompt TEXT;

View File

@ -0,0 +1 @@
ALTER TABLE site_settings ADD COLUMN image_generation_enabled INTEGER NOT NULL DEFAULT 0;

View File

@ -0,0 +1,11 @@
CREATE TABLE IF NOT EXISTS generated_covers (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
song_id INTEGER NOT NULL,
job_id TEXT,
status TEXT NOT NULL DEFAULT 'pending',
image_path TEXT,
error_message TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS generated_covers_song ON generated_covers (user_id, song_id, created_at DESC);

View File

@ -37,6 +37,7 @@ app.use('/static', express.static(path.join(__dirname, '..', 'public'), { maxAge
const mediaDir = process.env.MEDIA_DIR || path.join(__dirname, '..', 'media'); const mediaDir = process.env.MEDIA_DIR || path.join(__dirname, '..', 'media');
app.use('/media/covers', express.static(path.join(mediaDir, 'covers'), { maxAge: '1d' })); app.use('/media/covers', express.static(path.join(mediaDir, 'covers'), { maxAge: '1d' }));
app.use('/media/avatars', express.static(path.join(mediaDir, 'avatars'), { maxAge: '1d' })); app.use('/media/avatars', express.static(path.join(mediaDir, 'avatars'), { maxAge: '1d' }));
app.use('/media/generated', express.static(path.join(mediaDir, 'generated'), { maxAge: '0' }));
app.use('/static/vendor/cropperjs', express.static( app.use('/static/vendor/cropperjs', express.static(
path.join(__dirname, '..', 'node_modules', 'cropperjs', 'dist') path.join(__dirname, '..', 'node_modules', 'cropperjs', 'dist')
)); ));

View File

@ -13,12 +13,17 @@ function unlinkQuiet(absPath) {
fs.unlink(absPath, () => {}); fs.unlink(absPath, () => {});
} }
// Workflow JSON upload — stored in memory, never written to disk // Workflow JSON uploads — stored in memory, never written to disk
const workflowUpload = multer({ const workflowUpload = multer({
storage: multer.memoryStorage(), storage: multer.memoryStorage(),
limits: { fileSize: 5 * 1024 * 1024 }, // 5 MB max limits: { fileSize: 5 * 1024 * 1024 }, // 5 MB max
}).single('workflow_json'); }).single('workflow_json');
const imageWorkflowUpload = multer({
storage: multer.memoryStorage(),
limits: { fileSize: 5 * 1024 * 1024 },
}).single('image_workflow_json');
module.exports = function adminPanelRoutes(csrfProtection) { module.exports = function adminPanelRoutes(csrfProtection) {
const router = express.Router(); const router = express.Router();
router.use(requireAdmin); router.use(requireAdmin);
@ -34,6 +39,10 @@ module.exports = function adminPanelRoutes(csrfProtection) {
workflowSize: s.comfyui_workflow_json workflowSize: s.comfyui_workflow_json
? Math.round(Buffer.byteLength(s.comfyui_workflow_json, 'utf8') / 1024) ? Math.round(Buffer.byteLength(s.comfyui_workflow_json, 'utf8') / 1024)
: 0, : 0,
imageWorkflowLoaded: !!(s.comfyui_image_workflow_json),
imageWorkflowSize: s.comfyui_image_workflow_json
? Math.round(Buffer.byteLength(s.comfyui_image_workflow_json, 'utf8') / 1024)
: 0,
}); });
}); });
@ -113,6 +122,7 @@ module.exports = function adminPanelRoutes(csrfProtection) {
generation_cooldown_seconds: (!isNaN(cooldown) && cooldown >= 0) ? cooldown : 180, generation_cooldown_seconds: (!isNaN(cooldown) && cooldown >= 0) ? cooldown : 180,
comfyui_base_url: (req.body.comfyui_base_url || '').trim() || current.comfyui_base_url || null, comfyui_base_url: (req.body.comfyui_base_url || '').trim() || current.comfyui_base_url || null,
comfyui_workflow_json: current.comfyui_workflow_json || null, comfyui_workflow_json: current.comfyui_workflow_json || null,
comfyui_image_workflow_json: current.comfyui_image_workflow_json || null,
comfyui_node_style_prompt: (req.body.node_style_prompt || '').trim() || null, comfyui_node_style_prompt: (req.body.node_style_prompt || '').trim() || null,
comfyui_node_lyrics_prompt: (req.body.node_lyrics_prompt || '').trim() || null, comfyui_node_lyrics_prompt: (req.body.node_lyrics_prompt || '').trim() || null,
comfyui_node_bpm: (req.body.node_bpm || '').trim() || null, comfyui_node_bpm: (req.body.node_bpm || '').trim() || null,
@ -120,6 +130,8 @@ module.exports = function adminPanelRoutes(csrfProtection) {
comfyui_node_duration: (req.body.node_duration || '').trim() || null, comfyui_node_duration: (req.body.node_duration || '').trim() || null,
comfyui_node_creativity: (req.body.node_creativity || '').trim() || null, comfyui_node_creativity: (req.body.node_creativity || '').trim() || null,
comfyui_node_seed: (req.body.node_seed || '').trim() || null, comfyui_node_seed: (req.body.node_seed || '').trim() || null,
comfyui_node_image_prompt: (req.body.node_image_prompt || '').trim() || null,
image_generation_enabled: req.body.image_generation_enabled === '1' ? 1 : 0,
}); });
req.flash('success', 'Generation settings saved.'); req.flash('success', 'Generation settings saved.');
res.redirect('/admin#generation'); res.redirect('/admin#generation');
@ -132,7 +144,6 @@ module.exports = function adminPanelRoutes(csrfProtection) {
req.flash('error', uploadErr.message); req.flash('error', uploadErr.message);
return res.redirect('/admin#generation'); return res.redirect('/admin#generation');
} }
// Now run CSRF check (multer must run first so body is parsed)
csrfProtection(req, res, (csrfErr) => { csrfProtection(req, res, (csrfErr) => {
if (csrfErr) return next(csrfErr); if (csrfErr) return next(csrfErr);
try { try {
@ -148,7 +159,35 @@ module.exports = function adminPanelRoutes(csrfProtection) {
} }
const current = settings.get(); const current = settings.get();
settings.set({ ...current, comfyui_workflow_json: json }); settings.set({ ...current, comfyui_workflow_json: json });
req.flash('success', 'Workflow JSON uploaded and saved.'); req.flash('success', 'Music Workflow JSON uploaded and saved.');
res.redirect('/admin#generation');
} catch (err) { next(err); }
});
});
});
router.post('/generation/image-workflow', (req, res, next) => {
imageWorkflowUpload(req, res, (uploadErr) => {
if (uploadErr) {
req.flash('error', uploadErr.message);
return res.redirect('/admin#generation');
}
csrfProtection(req, res, (csrfErr) => {
if (csrfErr) return next(csrfErr);
try {
const file = req.file;
if (!file) {
req.flash('error', 'No file uploaded.');
return res.redirect('/admin#generation');
}
const json = file.buffer.toString('utf8');
try { JSON.parse(json); } catch {
req.flash('error', 'Invalid JSON file.');
return res.redirect('/admin#generation');
}
const current = settings.get();
settings.set({ ...current, comfyui_image_workflow_json: json });
req.flash('success', 'Image Workflow JSON uploaded and saved.');
res.redirect('/admin#generation'); res.redirect('/admin#generation');
} catch (err) { next(err); } } catch (err) { next(err); }
}); });

View File

@ -9,6 +9,7 @@ const { requireUser, requireAdmin, requireVip } = require('../middleware/auth');
const songs = require('../services/songs'); const songs = require('../services/songs');
const playlists = require('../services/playlists'); const playlists = require('../services/playlists');
const imageService = require('../services/image'); const imageService = require('../services/image');
const settings = require('../services/settings');
const mediaDir = process.env.MEDIA_DIR || path.join(__dirname, '..', '..', 'media'); const mediaDir = process.env.MEDIA_DIR || path.join(__dirname, '..', '..', 'media');
@ -234,7 +235,7 @@ module.exports = function adminRoutes(csrfProtection) {
}); });
router.get('/songs/new', (req, res) => { router.get('/songs/new', (req, res) => {
res.render('mymusic/song_form', { title: 'New song', song: null, errors: {}, values: { visibility: 'logged_in' }, genres: songs.listGenres() }); res.render('mymusic/song_form', { title: 'New song', song: null, errors: {}, values: { visibility: 'logged_in' }, genres: songs.listGenres(), imageGenerationEnabled: false });
}); });
router.post('/songs', songFields, csrfProtection, async (req, res, next) => { router.post('/songs', songFields, csrfProtection, async (req, res, next) => {
@ -247,7 +248,7 @@ module.exports = function adminRoutes(csrfProtection) {
if (!req.files || !req.files.audio || !req.files.audio[0]) errors.audio = 'Audio file is required.'; if (!req.files || !req.files.audio || !req.files.audio[0]) errors.audio = 'Audio file is required.';
if (req.files?.audio?.[0]) unlinkQuiet(req.files.audio[0].path); if (req.files?.audio?.[0]) unlinkQuiet(req.files.audio[0].path);
if (req.files?.cover?.[0]) unlinkQuiet(req.files.cover[0].path); if (req.files?.cover?.[0]) unlinkQuiet(req.files.cover[0].path);
return res.status(400).render('mymusic/song_form', { title: 'New song', song: null, errors, values: body, genres: songs.listGenres() }); return res.status(400).render('mymusic/song_form', { title: 'New song', song: null, errors, values: body, genres: songs.listGenres(), imageGenerationEnabled: false });
} }
const audioFile = req.files.audio[0]; const audioFile = req.files.audio[0];
const coverFile = req.files.cover?.[0]; const coverFile = req.files.cover?.[0];
@ -310,6 +311,7 @@ module.exports = function adminRoutes(csrfProtection) {
res.render('mymusic/song_form', { res.render('mymusic/song_form', {
title: 'Edit song', song, errors: {}, title: 'Edit song', song, errors: {},
values: { ...song }, genres: songs.listGenres(), values: { ...song }, genres: songs.listGenres(),
imageGenerationEnabled: settings.isImageGenerationEnabled(),
}); });
}); });
@ -325,7 +327,7 @@ module.exports = function adminRoutes(csrfProtection) {
const errors = {}; const errors = {};
for (const i of parsed.error.issues) errors[i.path[0]] = i.message; for (const i of parsed.error.issues) errors[i.path[0]] = i.message;
if (req.files?.cover?.[0]) unlinkQuiet(req.files.cover[0].path); if (req.files?.cover?.[0]) unlinkQuiet(req.files.cover[0].path);
return res.status(400).render('mymusic/song_form', { title: 'Edit song', song: existing, errors, values: body, genres: songs.listGenres() }); return res.status(400).render('mymusic/song_form', { title: 'Edit song', song: existing, errors, values: body, genres: songs.listGenres(), imageGenerationEnabled: settings.isImageGenerationEnabled() });
} }
const canSetVip = req.session.user.isVip || req.session.user.role === 'admin'; const canSetVip = req.session.user.isVip || req.session.user.role === 'admin';
const visibility = parsed.data.visibility === 'vip' && !canSetVip ? 'logged_in' : parsed.data.visibility; const visibility = parsed.data.visibility === 'vip' && !canSetVip ? 'logged_in' : parsed.data.visibility;

View File

@ -3,6 +3,7 @@ const fs = require('fs');
const crypto = require('crypto'); const crypto = require('crypto');
const express = require('express'); const express = require('express');
const multer = require('multer'); const multer = require('multer');
const sharp = require('sharp');
const { requireUser } = require('../middleware/auth'); const { requireUser } = require('../middleware/auth');
const settings = require('../services/settings'); const settings = require('../services/settings');
@ -46,30 +47,42 @@ const KEYSCALES = [
'B major','B minor', 'B major','B minor',
]; ];
// Guard: generation must be enabled and user must be verified function requireVipVerified(req, res, next) {
function requireGeneration(req, res, next) {
if (!settings.isGenerationEnabled()) {
const err = new Error('Song generation is not enabled.');
err.status = 403; err.expose = true; return next(err);
}
if (!req.session.user.isVip && req.session.user.role !== 'admin') { if (!req.session.user.isVip && req.session.user.role !== 'admin') {
const err = new Error('VIP access is required to generate songs.'); const err = new Error('VIP access is required.');
err.status = 403; err.expose = true; return next(err); err.status = 403; err.expose = true; return next(err);
} }
if (!req.session.user.emailVerified) { if (!req.session.user.emailVerified) {
const err = new Error('Please verify your email to use song generation.'); const err = new Error('Please verify your email to use this feature.');
err.status = 403; err.expose = true; return next(err); err.status = 403; err.expose = true; return next(err);
} }
next(); next();
} }
// Guard: song generation must be enabled
function requireGeneration(req, res, next) {
if (!settings.isGenerationEnabled()) {
const err = new Error('Song generation is not enabled.');
err.status = 403; err.expose = true; return next(err);
}
requireVipVerified(req, res, next);
}
// Guard: image generation must be enabled
function requireImageGeneration(req, res, next) {
if (!settings.isImageGenerationEnabled()) {
const err = new Error('Album cover generation is not enabled.');
err.status = 403; err.expose = true; return next(err);
}
requireVipVerified(req, res, next);
}
module.exports = function generateRoutes(csrfProtection) { module.exports = function generateRoutes(csrfProtection) {
const router = express.Router(); const router = express.Router();
router.use(requireUser); router.use(requireUser);
router.use(requireGeneration);
// ── GET / — generate page ────────────────────────────────────────────── // ── GET / — generate page ──────────────────────────────────────────────
router.get('/', (req, res) => { router.get('/', requireGeneration, (req, res) => {
const userId = req.session.user.id; const userId = req.session.user.id;
const genList = generation.listByUser(userId); const genList = generation.listByUser(userId);
const count = genList.length; const count = genList.length;
@ -88,14 +101,14 @@ module.exports = function generateRoutes(csrfProtection) {
}); });
// ── GET /rate-limit — current rate-limit state (JSON) ───────────────── // ── GET /rate-limit — current rate-limit state (JSON) ─────────────────
router.get('/rate-limit', (req, res) => { router.get('/rate-limit', requireGeneration, (req, res) => {
const s = settings.get(); const s = settings.get();
const rl = generation.getRateLimitInfo(req.session.user.id); const rl = generation.getRateLimitInfo(req.session.user.id);
res.json({ ...rl, cooldownSeconds: s.generation_cooldown_seconds != null ? s.generation_cooldown_seconds : 180 }); res.json({ ...rl, cooldownSeconds: s.generation_cooldown_seconds != null ? s.generation_cooldown_seconds : 180 });
}); });
// ── POST /submit — kick off a generation job ─────────────────────────── // ── POST /submit — kick off a generation job ───────────────────────────
router.post('/submit', csrfProtection, async (req, res) => { router.post('/submit', requireGeneration, csrfProtection, async (req, res) => {
const userId = req.session.user.id; const userId = req.session.user.id;
if (!generation.canGenerate(userId)) { if (!generation.canGenerate(userId)) {
return res.status(400).json({ error: 'You have reached the 20-song limit. Delete or publish songs to make room.' }); return res.status(400).json({ error: 'You have reached the 20-song limit. Delete or publish songs to make room.' });
@ -153,7 +166,7 @@ module.exports = function generateRoutes(csrfProtection) {
}); });
// ── GET /status/:id — poll status ───────────────────────────────────── // ── GET /status/:id — poll status ─────────────────────────────────────
router.get('/status/:id', async (req, res) => { router.get('/status/:id', requireGeneration, async (req, res) => {
const id = Number(req.params.id); const id = Number(req.params.id);
const row = generation.findById(id); const row = generation.findById(id);
if (!row || row.user_id !== req.session.user.id) { if (!row || row.user_id !== req.session.user.id) {
@ -186,7 +199,7 @@ module.exports = function generateRoutes(csrfProtection) {
}); });
// ── GET /:id/stream — stream generated audio ─────────────────────────── // ── GET /:id/stream — stream generated audio ───────────────────────────
router.get('/:id/stream', (req, res, next) => { router.get('/:id/stream', requireGeneration, (req, res, next) => {
const id = Number(req.params.id); const id = Number(req.params.id);
const row = generation.findById(id); const row = generation.findById(id);
if (!row || row.user_id !== req.session.user.id || !row.audio_path) { if (!row || row.user_id !== req.session.user.id || !row.audio_path) {
@ -232,7 +245,7 @@ module.exports = function generateRoutes(csrfProtection) {
}); });
// ── GET /:id/publish — show publish metadata form ────────────────────── // ── GET /:id/publish — show publish metadata form ──────────────────────
router.get('/:id/publish', (req, res, next) => { router.get('/:id/publish', requireGeneration, (req, res, next) => {
const id = Number(req.params.id); const id = Number(req.params.id);
const row = generation.findById(id); const row = generation.findById(id);
if (!row || row.user_id !== req.session.user.id) return next(); if (!row || row.user_id !== req.session.user.id) return next();
@ -257,7 +270,7 @@ module.exports = function generateRoutes(csrfProtection) {
// uploadCover (multer) must run before csrfProtection so req.body._csrf is // uploadCover (multer) must run before csrfProtection so req.body._csrf is
// available when the CSRF middleware checks it (multipart bodies aren't parsed // available when the CSRF middleware checks it (multipart bodies aren't parsed
// by express.urlencoded). // by express.urlencoded).
router.post('/:id/publish', uploadCover, csrfProtection, async (req, res, next) => { router.post('/:id/publish', requireGeneration, uploadCover, csrfProtection, async (req, res, next) => {
try { try {
const id = Number(req.params.id); const id = Number(req.params.id);
const row = generation.findById(id); const row = generation.findById(id);
@ -322,7 +335,7 @@ module.exports = function generateRoutes(csrfProtection) {
fs.unlinkSync(oldAbs); fs.unlinkSync(oldAbs);
} }
songs.create({ const song = songs.create({
title, artist, album, genre, year, title, artist, album, genre, year,
durationSeconds: row.duration_seconds || null, durationSeconds: row.duration_seconds || null,
audioPath: newRel, audioPath: newRel,
@ -334,6 +347,9 @@ module.exports = function generateRoutes(csrfProtection) {
generation.remove(id); generation.remove(id);
req.flash('success', 'Song published to your library!'); req.flash('success', 'Song published to your library!');
if (!coverPath && settings.isImageGenerationEnabled()) {
return res.redirect(`/generate/cover/${song.id}`);
}
res.redirect('/mymusic/songs'); res.redirect('/mymusic/songs');
} catch (err) { } catch (err) {
if (req.file) fs.unlink(req.file.path, () => {}); if (req.file) fs.unlink(req.file.path, () => {});
@ -342,7 +358,7 @@ module.exports = function generateRoutes(csrfProtection) {
}); });
// ── POST /:id/delete ─────────────────────────────────────────────────── // ── POST /:id/delete ───────────────────────────────────────────────────
router.post('/:id/delete', csrfProtection, (req, res) => { router.post('/:id/delete', requireGeneration, csrfProtection, (req, res) => {
const id = Number(req.params.id); const id = Number(req.params.id);
const row = generation.findById(id); const row = generation.findById(id);
if (!row || row.user_id !== req.session.user.id) { if (!row || row.user_id !== req.session.user.id) {
@ -356,5 +372,110 @@ module.exports = function generateRoutes(csrfProtection) {
res.json({ ok: true }); res.json({ ok: true });
}); });
// ── Cover generation routes ────────────────────────────────────────────
// GET /cover/:songId — show cover generation form
router.get('/cover/:songId', requireImageGeneration, (req, res, next) => {
const songId = Number(req.params.songId);
const song = songs.findById(songId);
if (!song || song.uploadedBy !== req.session.user.id) return next();
const s = settings.get();
const configured = !!(s.comfyui_image_workflow_json && s.comfyui_base_url);
res.render('generate/cover', {
title: 'Generate Cover Art',
song,
configured,
});
});
// POST /cover/:songId/submit — kick off a cover generation job
router.post('/cover/:songId/submit', requireImageGeneration, csrfProtection, async (req, res) => {
const songId = Number(req.params.songId);
const song = songs.findById(songId);
if (!song || song.uploadedBy !== req.session.user.id) {
return res.status(404).json({ error: 'Song not found.' });
}
const s = settings.get();
if (!s.comfyui_image_workflow_json || !s.comfyui_base_url) {
return res.status(503).json({ error: 'Image workflow not configured. Contact the administrator.' });
}
const prompt = (req.body.prompt || '').trim();
const coverId = generation.createCover(req.session.user.id, songId);
try {
const promptId = await generation.submitToComfyUIImage(
s.comfyui_base_url,
s.comfyui_image_workflow_json,
prompt,
s.comfyui_node_image_prompt,
);
generation.coverUpdateJobStmt.run(promptId, 'processing', coverId);
} catch (err) {
generation.coverUpdateFailStmt.run('failed', err.message, coverId);
}
res.json({ ok: true, coverId });
});
// GET /cover/poll/:coverId — poll cover job status
router.get('/cover/poll/:coverId', requireImageGeneration, async (req, res) => {
const coverId = Number(req.params.coverId);
const row = generation.findCoverById(coverId);
if (!row || row.user_id !== req.session.user.id) {
return res.status(404).json({ error: 'Not found' });
}
if (row.status === 'done') {
return res.json({ status: 'done', previewPath: row.image_path });
}
if (row.status === 'failed') {
return res.json({ status: 'failed', error: row.error_message });
}
if (!row.job_id) return res.json({ status: row.status });
const s = settings.get();
try {
const { done, relPath } = await generation.pollComfyUIImage(s.comfyui_base_url, row.job_id, coverId);
if (done) {
generation.coverUpdateDoneStmt.run('done', relPath, coverId);
return res.json({ status: 'done', previewPath: relPath });
}
} catch (err) {
// Not ready yet — keep polling
}
res.json({ status: 'processing' });
});
// POST /cover/:songId/apply — process + save the generated cover to the song
router.post('/cover/:songId/apply', requireImageGeneration, csrfProtection, async (req, res, next) => {
try {
const songId = Number(req.params.songId);
const coverId = Number(req.body.coverId);
const song = songs.findById(songId);
const coverRow = generation.findCoverById(coverId);
if (!song || song.uploadedBy !== req.session.user.id) return next();
if (!coverRow || coverRow.user_id !== req.session.user.id || coverRow.status !== 'done' || !coverRow.image_path) {
req.flash('error', 'Cover image not ready.');
return res.redirect(`/generate/cover/${songId}`);
}
const srcAbs = path.join(generation.MEDIA_DIR, coverRow.image_path);
const finalName = `${crypto.randomUUID()}.jpg`;
const finalAbs = path.join(generation.MEDIA_DIR, 'covers', finalName);
await imageService.processCoverImage(srcAbs, finalAbs);
songs.setCover(songId, `covers/${finalName}`);
// Clean up generated file and DB row
fs.unlink(srcAbs, () => {});
generation.removeCover(coverId);
req.flash('success', 'Cover art applied!');
res.redirect(`/mymusic/songs/${songId}/edit`);
} catch (err) { next(err); }
});
// POST /cover/:songId/skip — skip cover generation
router.post('/cover/:songId/skip', requireImageGeneration, csrfProtection, (req, res) => {
res.redirect('/mymusic/songs');
});
return router; return router;
}; };

View File

@ -189,10 +189,86 @@ async function pollComfyUI(baseUrl, promptId, generationId) {
return { done: true, relPath: `generated/${saveName}` }; return { done: true, relPath: `generated/${saveName}` };
} }
// ── Cover generation ───────────────────────────────────────────────────────
const coverInsertStmt = db.prepare(`INSERT INTO generated_covers (user_id, song_id, status) VALUES (?, ?, 'pending')`);
const coverFindByIdStmt = db.prepare('SELECT * FROM generated_covers WHERE id = ?');
const coverUpdateJobStmt = db.prepare('UPDATE generated_covers SET job_id = ?, status = ? WHERE id = ?');
const coverUpdateDoneStmt = db.prepare('UPDATE generated_covers SET status = ?, image_path = ? WHERE id = ?');
const coverUpdateFailStmt = db.prepare('UPDATE generated_covers SET status = ?, error_message = ? WHERE id = ?');
const coverDeleteStmt = db.prepare('DELETE FROM generated_covers WHERE id = ?');
function createCover(userId, songId) {
return coverInsertStmt.run(userId, songId).lastInsertRowid;
}
function findCoverById(id) {
return coverFindByIdStmt.get(id);
}
function removeCover(id) {
coverDeleteStmt.run(id);
}
// Submit a single-prompt image workflow to ComfyUI. Returns prompt_id.
async function submitToComfyUIImage(baseUrl, workflowJson, prompt, nodeId) {
const workflow = JSON.parse(workflowJson);
if (nodeId && workflow[nodeId] && workflow[nodeId].inputs !== undefined) {
const inputs = workflow[nodeId].inputs;
// Use whichever key is already present; PrimitiveString nodes use "value",
// CLIPTextEncode nodes use "text".
if ('value' in inputs) {
inputs.value = prompt;
} else {
inputs.text = prompt;
}
}
const res = await fetch(`${baseUrl}/prompt`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ prompt: workflow }),
});
if (!res.ok) throw new Error(`ComfyUI /prompt returned ${res.status}`);
const data = await res.json();
if (!data.prompt_id) throw new Error('ComfyUI did not return a prompt_id');
return data.prompt_id;
}
// Poll ComfyUI for a completed image job. Downloads the file to GEN_DIR on success.
async function pollComfyUIImage(baseUrl, promptId, coverId) {
const res = await fetch(`${baseUrl}/history/${promptId}`);
if (!res.ok) throw new Error(`ComfyUI /history returned ${res.status}`);
const data = await res.json();
if (!data[promptId]) return { done: false };
const outputs = data[promptId].outputs || {};
let fileInfo = null;
for (const nodeOut of Object.values(outputs)) {
if (nodeOut.images && nodeOut.images.length) { fileInfo = nodeOut.images[0]; break; }
}
if (!fileInfo) return { done: false };
const viewUrl = `${baseUrl}/view?filename=${encodeURIComponent(fileInfo.filename)}`
+ `&subfolder=${encodeURIComponent(fileInfo.subfolder || '')}`
+ `&type=${fileInfo.type || 'output'}`;
const fileRes = await fetch(viewUrl);
if (!fileRes.ok) throw new Error(`ComfyUI /view returned ${fileRes.status}`);
const ext = path.extname(fileInfo.filename) || '.png';
const saveName = `cover-${coverId}-${Date.now()}${ext}`;
const savePath = path.join(GEN_DIR, saveName);
fs.writeFileSync(savePath, Buffer.from(await fileRes.arrayBuffer()));
return { done: true, relPath: `generated/${saveName}`, filename: saveName };
}
module.exports = { module.exports = {
MAX_GENERATED, HOURLY_LIMIT, GEN_DIR, MEDIA_DIR, MAX_GENERATED, HOURLY_LIMIT, GEN_DIR, MEDIA_DIR,
canGenerate, countByUser, listByUser, findById, create, remove, canGenerate, countByUser, listByUser, findById, create, remove,
getRateLimitInfo, getRateLimitInfo,
updateJobStmt, updateDoneStmt, updateFailStmt, updateJobStmt, updateDoneStmt, updateFailStmt,
submitToComfyUI, pollComfyUI, submitToComfyUI, pollComfyUI,
createCover, findCoverById, removeCover,
coverUpdateJobStmt, coverUpdateDoneStmt, coverUpdateFailStmt,
submitToComfyUIImage, pollComfyUIImage,
}; };

View File

@ -7,13 +7,16 @@ const setStmt = db.prepare(`
generation_cooldown_seconds = @generation_cooldown_seconds, generation_cooldown_seconds = @generation_cooldown_seconds,
comfyui_base_url = @comfyui_base_url, comfyui_base_url = @comfyui_base_url,
comfyui_workflow_json = @comfyui_workflow_json, comfyui_workflow_json = @comfyui_workflow_json,
comfyui_image_workflow_json = @comfyui_image_workflow_json,
comfyui_node_style_prompt = @comfyui_node_style_prompt, comfyui_node_style_prompt = @comfyui_node_style_prompt,
comfyui_node_lyrics_prompt = @comfyui_node_lyrics_prompt, comfyui_node_lyrics_prompt = @comfyui_node_lyrics_prompt,
comfyui_node_bpm = @comfyui_node_bpm, comfyui_node_bpm = @comfyui_node_bpm,
comfyui_node_keyscale = @comfyui_node_keyscale, comfyui_node_keyscale = @comfyui_node_keyscale,
comfyui_node_duration = @comfyui_node_duration, comfyui_node_duration = @comfyui_node_duration,
comfyui_node_creativity = @comfyui_node_creativity, comfyui_node_creativity = @comfyui_node_creativity,
comfyui_node_seed = @comfyui_node_seed comfyui_node_seed = @comfyui_node_seed,
comfyui_node_image_prompt = @comfyui_node_image_prompt,
image_generation_enabled = @image_generation_enabled
WHERE id = 1 WHERE id = 1
`); `);
@ -29,4 +32,8 @@ function isGenerationEnabled() {
return !!(get().generation_enabled); return !!(get().generation_enabled);
} }
module.exports = { get, set, isGenerationEnabled }; function isImageGenerationEnabled() {
return !!(get().image_generation_enabled);
}
module.exports = { get, set, isGenerationEnabled, isImageGenerationEnabled };

View File

@ -77,6 +77,12 @@
<span>Enable Song Generation <span class="muted small">(shows "Generate" in the nav for verified users)</span></span> <span>Enable Song Generation <span class="muted small">(shows "Generate" in the nav for verified users)</span></span>
</label> </label>
<label class="checkbox" style="flex-direction:row;align-items:center;gap:0.6rem">
<input type="checkbox" name="image_generation_enabled" value="1"
<%= genSettings.image_generation_enabled ? 'checked' : '' %>>
<span>Enable Album Cover Generation <span class="muted small">(auto-generates cover art when a song is published)</span></span>
</label>
<label style="max-width:260px"> <label style="max-width:260px">
Cooldown Between Generations <span class="muted small">(seconds)</span> Cooldown Between Generations <span class="muted small">(seconds)</span>
<input type="number" name="generation_cooldown_seconds" min="0" max="86400" <input type="number" name="generation_cooldown_seconds" min="0" max="86400"
@ -121,6 +127,9 @@
<label>Seed node ID <label>Seed node ID
<input type="text" name="node_seed" value="<%= genSettings.comfyui_node_seed || '' %>" placeholder="e.g. 16"> <input type="text" name="node_seed" value="<%= genSettings.comfyui_node_seed || '' %>" placeholder="e.g. 16">
</label> </label>
<label>Image Prompt node ID
<input type="text" name="node_image_prompt" value="<%= genSettings.comfyui_node_image_prompt || '' %>" placeholder="e.g. 20">
</label>
</div> </div>
</fieldset> </fieldset>
@ -129,9 +138,9 @@
</div> </div>
</form> </form>
<!-- Workflow JSON upload (separate form — uses multipart) --> <!-- Music Workflow JSON upload (separate form — uses multipart) -->
<div class="form-card" style="padding:1.25rem 1.5rem"> <div class="form-card" style="padding:1.25rem 1.5rem;margin-bottom:1rem">
<h3 style="margin:0 0 0.75rem">Workflow JSON</h3> <h3 style="margin:0 0 0.75rem">Music Workflow JSON</h3>
<% if (workflowLoaded) { %> <% if (workflowLoaded) { %>
<p class="muted small" style="margin-bottom:0.75rem"> <p class="muted small" style="margin-bottom:0.75rem">
✓ Workflow loaded — <strong><%= workflowSize %> KB</strong>. ✓ Workflow loaded — <strong><%= workflowSize %> KB</strong>.
@ -148,7 +157,31 @@
<input type="file" name="workflow_json" accept=".json,application/json" required> <input type="file" name="workflow_json" accept=".json,application/json" required>
</label> </label>
<div> <div>
<button type="submit" class="btn-primary">Upload Workflow</button> <button type="submit" class="btn-primary">Upload Music Workflow</button>
</div>
</form>
</div>
<!-- Image Workflow JSON upload -->
<div class="form-card" style="padding:1.25rem 1.5rem">
<h3 style="margin:0 0 0.75rem">Image Workflow JSON</h3>
<% if (imageWorkflowLoaded) { %>
<p class="muted small" style="margin-bottom:0.75rem">
✓ Image workflow loaded — <strong><%= imageWorkflowSize %> KB</strong>.
Upload a new file below to replace it.
</p>
<% } else { %>
<p class="muted small" style="margin-bottom:0.75rem">No image workflow loaded yet.</p>
<% } %>
<form method="post" action="/admin/generation/image-workflow" enctype="multipart/form-data"
class="stacked" style="gap:0.75rem">
<input type="hidden" name="_csrf" value="<%= csrfToken %>">
<label>
ComfyUI API JSON file
<input type="file" name="image_workflow_json" accept=".json,application/json" required>
</label>
<div>
<button type="submit" class="btn-primary">Upload Image Workflow</button>
</div> </div>
</form> </form>
</div> </div>

View File

@ -0,0 +1,149 @@
<div class="row-between" style="margin-bottom:1.5rem">
<div>
<h1 style="margin-bottom:0.25rem">Generate Cover Art</h1>
<p class="muted small" style="margin:0">
<strong><%= song.title %></strong> by <%= song.artist %>
</p>
</div>
<a href="/mymusic/songs" class="btn-link">Skip for now</a>
</div>
<% if (!configured) { %>
<div class="form-card" style="padding:1.25rem 1.5rem">
<p class="muted">Image generation is not fully configured yet. Ask an administrator to upload an image workflow and set a base URL.</p>
</div>
<% } else { %>
<div class="form-card stacked" id="cover-form-wrap">
<label>
Describe the cover art
<textarea id="cover-prompt" rows="4"
placeholder="e.g. Synthwave city at night, neon lights reflecting on wet streets, dark sky with purple hues"></textarea>
</label>
<div style="display:flex;gap:0.75rem;align-items:center;flex-wrap:wrap">
<button type="button" id="cover-submit-btn" class="btn-primary">Generate Cover Art</button>
<span id="cover-status" style="display:none;align-items:center;gap:0.5rem">
<span class="gen-spinner"></span>
<span id="cover-status-text" class="muted small">Generating…</span>
</span>
<form method="post" action="/generate/cover/<%= song.id %>/skip" style="margin:0">
<input type="hidden" name="_csrf" value="<%= csrfToken %>">
<button type="submit" class="btn-link">Skip for now</button>
</form>
</div>
</div>
<div id="cover-result" style="display:none;margin-top:1.5rem">
<div class="form-card" style="padding:1.25rem 1.5rem">
<h3 style="margin:0 0 1rem">Generated Cover</h3>
<div style="display:flex;gap:1.5rem;align-items:flex-start;flex-wrap:wrap">
<img id="cover-preview" src="" alt="Generated cover art"
style="width:300px;height:300px;object-fit:cover;border-radius:var(--radius);border:1px solid var(--border)">
<div class="stacked" style="gap:0.75rem">
<form id="cover-apply-form" method="post" action="/generate/cover/<%= song.id %>/apply">
<input type="hidden" name="_csrf" value="<%= csrfToken %>">
<input type="hidden" name="coverId" id="cover-id-input" value="">
<button type="submit" class="btn-primary">Use This Cover</button>
</form>
<button type="button" id="cover-retry-btn" class="btn-link">Try Again</button>
<form method="post" action="/generate/cover/<%= song.id %>/skip" style="margin:0">
<input type="hidden" name="_csrf" value="<%= csrfToken %>">
<button type="submit" class="btn-link">Skip for now</button>
</form>
</div>
</div>
</div>
</div>
<div id="cover-error" class="flash flash-error" style="display:none;margin-top:1rem"></div>
<% } %>
<script>
(function () {
var csrfToken = (document.querySelector('meta[name="csrf-token"]') || {}).content || '';
var submitBtn = document.getElementById('cover-submit-btn');
var statusEl = document.getElementById('cover-status');
var statusTxt = document.getElementById('cover-status-text');
var resultEl = document.getElementById('cover-result');
var previewImg = document.getElementById('cover-preview');
var errorEl = document.getElementById('cover-error');
var coverIdInput = document.getElementById('cover-id-input');
var retryBtn = document.getElementById('cover-retry-btn');
var promptEl = document.getElementById('cover-prompt');
if (!submitBtn) return;
function showError(msg) {
errorEl.textContent = msg;
errorEl.style.display = '';
setTimeout(function () { errorEl.style.display = 'none'; }, 8000);
}
function setGenerating(on) {
submitBtn.disabled = on;
statusEl.style.display = on ? 'flex' : 'none';
if (!on) statusTxt.textContent = 'Generating…';
}
function poll(coverId) {
setTimeout(function () {
fetch('/generate/cover/poll/' + coverId)
.then(function (r) { return r.json(); })
.then(function (data) {
if (data.status === 'done') {
setGenerating(false);
coverIdInput.value = coverId;
var imgUrl = '/media/generated/' + encodeURIComponent(data.previewPath.split('/').pop());
previewImg.onload = function () {
resultEl.style.display = '';
resultEl.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
};
previewImg.onerror = function () {
resultEl.style.display = '';
resultEl.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
};
previewImg.src = imgUrl;
} else if (data.status === 'failed') {
setGenerating(false);
showError('Generation failed: ' + (data.error || 'unknown error'));
} else {
poll(coverId);
}
})
.catch(function () { poll(coverId); });
}, 3000);
}
submitBtn.addEventListener('click', function () {
var prompt = promptEl ? promptEl.value.trim() : '';
resultEl.style.display = 'none';
errorEl.style.display = 'none';
setGenerating(true);
statusTxt.textContent = 'Submitting…';
var params = new URLSearchParams({ _csrf: csrfToken, prompt: prompt });
fetch('/generate/cover/<%= song.id %>/submit', { method: 'POST', body: params })
.then(function (r) { return r.json(); })
.then(function (data) {
if (!data.ok) {
setGenerating(false);
showError(data.error || 'Submission failed.');
return;
}
statusTxt.textContent = 'Generating… (this may take a minute)';
poll(data.coverId);
})
.catch(function () {
setGenerating(false);
showError('Network error. Please try again.');
});
});
if (retryBtn) {
retryBtn.addEventListener('click', function () {
resultEl.style.display = 'none';
});
}
})();
</script>

View File

@ -82,6 +82,13 @@
<% } %> <% } %>
<input type="file" name="cover" accept="image/*"> <input type="file" name="cover" accept="image/*">
</label> </label>
<% if (imageGenerationEnabled) { %>
<div>
<a href="/generate/cover/<%= song.id %>" class="btn-link" style="font-size:0.85rem">
✦ Generate Album Cover Art
</a>
</div>
<% } %>
<% } %> <% } %>
<fieldset class="visibility-field"> <fieldset class="visibility-field">