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:
parent
024c3a5e40
commit
13a2ad52e5
|
|
@ -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;
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
ALTER TABLE site_settings ADD COLUMN image_generation_enabled INTEGER NOT NULL DEFAULT 0;
|
||||||
|
|
@ -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);
|
||||||
|
|
@ -35,8 +35,9 @@ app.use('/static', express.static(path.join(__dirname, '..', 'public'), { maxAge
|
||||||
|
|
||||||
// Cover art is served statically; audio goes through /stream/:id for Range support + access control later.
|
// Cover art is served statically; audio goes through /stream/:id for Range support + access control later.
|
||||||
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')
|
||||||
));
|
));
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -109,17 +118,20 @@ module.exports = function adminPanelRoutes(csrfProtection) {
|
||||||
const current = settings.get();
|
const current = settings.get();
|
||||||
const cooldown = parseInt(req.body.generation_cooldown_seconds, 10);
|
const cooldown = parseInt(req.body.generation_cooldown_seconds, 10);
|
||||||
settings.set({
|
settings.set({
|
||||||
generation_enabled: req.body.generation_enabled === '1' ? 1 : 0,
|
generation_enabled: req.body.generation_enabled === '1' ? 1 : 0,
|
||||||
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_node_style_prompt: (req.body.node_style_prompt || '').trim() || null,
|
comfyui_image_workflow_json: current.comfyui_image_workflow_json || null,
|
||||||
comfyui_node_lyrics_prompt: (req.body.node_lyrics_prompt || '').trim() || null,
|
comfyui_node_style_prompt: (req.body.node_style_prompt || '').trim() || null,
|
||||||
comfyui_node_bpm: (req.body.node_bpm || '').trim() || null,
|
comfyui_node_lyrics_prompt: (req.body.node_lyrics_prompt || '').trim() || null,
|
||||||
comfyui_node_keyscale: (req.body.node_keyscale || '').trim() || null,
|
comfyui_node_bpm: (req.body.node_bpm || '').trim() || null,
|
||||||
comfyui_node_duration: (req.body.node_duration || '').trim() || null,
|
comfyui_node_keyscale: (req.body.node_keyscale || '').trim() || null,
|
||||||
comfyui_node_creativity: (req.body.node_creativity || '').trim() || null,
|
comfyui_node_duration: (req.body.node_duration || '').trim() || null,
|
||||||
comfyui_node_seed: (req.body.node_seed || '').trim() || null,
|
comfyui_node_creativity: (req.body.node_creativity || '').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); }
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -3,17 +3,20 @@ const { db } = require('../../db');
|
||||||
const getStmt = db.prepare('SELECT * FROM site_settings WHERE id = 1');
|
const getStmt = db.prepare('SELECT * FROM site_settings WHERE id = 1');
|
||||||
const setStmt = db.prepare(`
|
const setStmt = db.prepare(`
|
||||||
UPDATE site_settings SET
|
UPDATE site_settings SET
|
||||||
generation_enabled = @generation_enabled,
|
generation_enabled = @generation_enabled,
|
||||||
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_node_style_prompt = @comfyui_node_style_prompt,
|
comfyui_image_workflow_json = @comfyui_image_workflow_json,
|
||||||
comfyui_node_lyrics_prompt = @comfyui_node_lyrics_prompt,
|
comfyui_node_style_prompt = @comfyui_node_style_prompt,
|
||||||
comfyui_node_bpm = @comfyui_node_bpm,
|
comfyui_node_lyrics_prompt = @comfyui_node_lyrics_prompt,
|
||||||
comfyui_node_keyscale = @comfyui_node_keyscale,
|
comfyui_node_bpm = @comfyui_node_bpm,
|
||||||
comfyui_node_duration = @comfyui_node_duration,
|
comfyui_node_keyscale = @comfyui_node_keyscale,
|
||||||
comfyui_node_creativity = @comfyui_node_creativity,
|
comfyui_node_duration = @comfyui_node_duration,
|
||||||
comfyui_node_seed = @comfyui_node_seed
|
comfyui_node_creativity = @comfyui_node_creativity,
|
||||||
|
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 };
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue