import { Router } from 'express'; import Bottleneck from 'bottleneck'; import { clear, doRender, explodeUrl, generateOutputBundle, getNpmScript, listOutputFiles, } from './renderer.js'; import path, { dirname } from 'path'; import { fileURLToPath } from 'url'; import pkg from 'common-utils'; const { Utils, PerformanceRecorder, FileUploader, Vault } = pkg; import bodyParser from 'body-parser'; import { existsSync } from 'fs'; import { applyPluginsPostrender, applyPluginsPrerender } from './server-plugins/apply.js'; import { startChildProcess } from './proxy-renderer.js'; import { CaptionRenderer } from './utils/CaptionRender.js'; import { AvatarRenderer } from './utils/AvatarRender.js'; const RenderRouter = Router(); const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); const limiter = new Bottleneck({ maxConcurrent: 1, }); RenderRouter.use(bodyParser.json()); RenderRouter.use(bodyParser.urlencoded()); const renderSyncRequestStatuses = new Map(); class RenderController { stop() { throw new Error('Render not stoppable !'); } } RenderRouter.get('/api/jobs', async (req, res) => { const jobIds = Array.from(renderSyncRequestStatuses.keys()); const jobStatuses = jobIds.map((jobId) => { const jobStatus = renderSyncRequestStatuses.get(jobId); return { jobId: jobId, status: jobStatus.statusMessage, ...jobStatus.result }; }); return res.status(200).json(jobStatuses); }); RenderRouter.get('/api/jobs/:jobId/stop', async (req, res) => { const jobId = req.params.jobId; if (!jobId) { return res.status(400).json({ message: 'Missing jobId in request body.' }); } if (jobId == 'all') { for (const [jobId, jobStatus] of renderSyncRequestStatuses.entries()) { if (jobStatus.controller && jobStatus.controller.stop) { try { jobStatus.controller.stopped = true; jobStatus.controller.stop(); } catch (e) { console.error(`Failed to stop job ${jobId}: ${e.message}`); } } renderSyncRequestStatuses.delete(jobId); } return res.status(200).json({ message: 'All jobs stopped successfully.' }); } const jobStatus = renderSyncRequestStatuses.get(jobId); if (!jobStatus) { return res.status(404).json({ message: `Job with ID ${jobId} not found.` }); } if (jobStatus.controller && jobStatus.controller.stop) { try { jobStatus.controller.stopped = true; jobStatus.controller.stop(); res .status(200) .json({ message: `Job ${jobId} stopped successfully.` }); } catch (e) { res .status(400) .json({ message: `Job ${jobId} stoping failed. ${e.message}` }); } } else { res .status(400) .json({ message: `Job ${jobId} is not currently running or cannot be stopped.` }); } renderSyncRequestStatuses.delete(jobId); }); RenderRouter.get('/api/jobs/:jobId', async (req, res) => { const jobId = req.params.jobId; const jobStatus = renderSyncRequestStatuses.get(jobId); if (jobStatus) { return res.status(200).json({ message: jobStatus.message, statusMessage: jobStatus.statusMessage, ...jobStatus.result }); } else { return res.status(404).json({ message: `Job with ID ${jobId} not found.` }); } }); RenderRouter.post('/api/v1/start-preview', async (req, res) => { startChildProcess() res.status(200).json({ message: 'Starting...' }); }); RenderRouter.post('/api/render-sync', async (req, res) => { const jobId = req.body.jobId || Utils.generateUID(req.body.fileUrl); // delete all jobs that finished more than 24 hours ago const now = Date.now(); for (const [jobId, jobStatus] of renderSyncRequestStatuses.entries()) { if (jobStatus.finished && (now - jobStatus.finished) > 24 * 60 * 60 * 1000) { renderSyncRequestStatuses.delete(jobId); } } const controller = new RenderController(); if (renderSyncRequestStatuses.has(jobId) && !req.body.force && !req.query.force && renderSyncRequestStatuses.get(jobId).statusMessage == 'IN_PROGRESS' ) { let job = renderSyncRequestStatuses.get(jobId); return res.status(202).json({ jobId, message: 'Job already in progress or queued.', ...job.result }); } let fileUrl = req.body.fileUrl; let targetUrl = req.body.targetUrl; let skipClear = req.body.skip_clear; let skipRender = req.body.skip_render; let zip = req.body.zip ?? true; // {name,...options}[] let plugins = req.body.plugins || []; if (!fileUrl) { return res.status(400).send({ message: 'Missing `fileUrl` in body. Required params `fileUrl`, `targetUrl`. Optionally pass `zip` to skip zipping and directly upload the files to targetUrl/{fileName}, `skip_clear` if you dont want to clear the folders after render, `skip_render` is you want to skip rendering, useful with `skip_clear` ', }); } if (!targetUrl) { return res.status(400).send({ message: 'Missing `targetUrl` in body. The result will be uploaded to `targetUrl`', }); } // make sure only 1 request is being processed at a time // set headers appropriately to hint that timeout must be large res.setHeader('X-Job-Id', jobId); res.setTimeout(0); res.setHeader('Connection', 'keep-alive'); const perf = new PerformanceRecorder(); let logs = []; try { const run = async () => { const dir = path.join(__dirname, 'public') const zipFile = path.join(dir, `exported-${jobId}.zip`) if (!existsSync(zipFile) || req.body.force || req.query.force) { if (!skipClear) { clear(); } await explodeUrl(fileUrl, jobId, dir, zipFile); } else { console.log(`Job ${jobId} assets already exploded to public. Not downloading again.`) } let renderMethod = req.body.method || req.query.method || 'cli'; const originalManuscriptPath = path.join(__dirname, `public`, `original_manuscript.json`) let originalManuscript = Utils.readFileToObject(originalManuscriptPath); await applyPluginsPrerender([...plugins, { name: 'flatten-paths' }], originalManuscript, originalManuscriptPath, jobId) renderMethod = originalManuscript.meta?.generationConfig?.extras?.renderMethod || renderMethod if (!skipRender) { if (renderMethod == 'media-with-caption-only') { let renderer = new AvatarRenderer(); renderer.validateCaption(originalManuscript); await renderer.doRender(jobId, originalManuscript, (log) => { logs.push(log); }, getNpmScript(req.query.media_type), req.body.caption_only, req.body.caption_only, controller); } else if (renderMethod == 'caption-only') { let renderer = new CaptionRenderer(); renderer.validateCaption(originalManuscript); await renderer.doRender(jobId, originalManuscript, (log) => { logs.push(log); }, getNpmScript(req.query.media_type), req.body.caption_only, req.body.caption_only, controller); } else if (renderMethod == 'cli' || req.query.media_type == 'image') { await doRender( jobId, originalManuscript, (jobId, log) => { logs.push(log); }, getNpmScript(req.query.media_type), undefined, undefined, controller ); } else if (renderMethod === 'all') { let errorMsg = '' let renderComplete = false; try { console.log('Render start via CLI') await doRender( jobId, originalManuscript, (jobId, log) => { logs.push(log); }, getNpmScript(req.query.media_type), undefined, undefined, controller ) renderComplete = true; } catch (e) { console.log('Render via CLI failed', e.message) errorMsg = "CLI: " + e.message if (controller.stopped) { throw new Error('Failed to render. Aborted. ' + errorMsg) } } if (!renderComplete) { try { console.log('Render start via Proxy') await doRender( jobId, originalManuscript, (jobId, log) => { logs.push(log); }, getNpmScript(req.query.media_type), undefined, { ...req.body.proxy, "framesPerChunk": req.body.proxy?.framesPerChunk || 99999 }, controller ) renderComplete = true; } catch (e) { console.log('Render via Proxy failed', e.message) errorMsg = errorMsg + "\n\nProxy: " + e.message if (controller.stopped) { throw new Error('Failed to render. Aborted. ' + errorMsg) } } } if (!renderComplete) { try { console.log('Render start via Proxy Chunked') await doRender( jobId, originalManuscript, (jobId, log) => { logs.push(log); }, getNpmScript(req.query.media_type), undefined, { ...req.body.proxy }, controller ) renderComplete = true; } catch (e) { console.log('Render via Proxy Chunked failed', e.message) errorMsg = errorMsg + "\n\nProxyChunked: " + e.message if (controller.stopped) { throw new Error('Failed to render. Aborted. ' + errorMsg) } } } if (!renderComplete) { try { console.log('Render start via SSR') await doRender( jobId, originalManuscript, (jobId, log) => { logs.push(log); }, getNpmScript(req.query.media_type), req.body.ssr || {}, undefined, controller ) renderComplete = true; } catch (e) { console.log('Render via SSR failed', e.message) errorMsg = errorMsg + "\n\nSSR: " + e.message if (controller.stopped) { throw new Error('Failed to render. Aborted. ' + errorMsg) } } } if (!renderComplete) { throw new Error('All render methods exhausted. Failed to render. ' + errorMsg) } } else if (renderMethod === 'ssr') { await doRender( jobId, originalManuscript, (jobId, log) => { logs.push(log); }, getNpmScript(req.query.media_type), req.body.ssr || {}, undefined, controller ); } else if (renderMethod === 'proxy') { await doRender( jobId, originalManuscript, (jobId, log) => { logs.push(log); }, getNpmScript(req.query.media_type), undefined, { ...req.body.proxy, "framesPerChunk": req.body.proxy?.framesPerChunk || 99999 }, controller ); } else if (renderMethod === 'proxy-chunked') { await doRender( jobId, originalManuscript, (jobId, log) => { logs.push(log); }, getNpmScript(req.query.media_type), undefined, req.body.proxy || {}, controller ); } } const uploader = new FileUploader('oracle', { url: targetUrl + jobId + '/', }); uploader.creds.url = uploader.creds.url; let outFiles = await listOutputFiles(jobId); await applyPluginsPostrender([...plugins, { name: 'hook' }], originalManuscript, originalManuscriptPath, jobId, outFiles) outFiles = await listOutputFiles(jobId); console.log('Render complete, took', perf.elapsedString(), 'uploading... to target folder: ' + targetUrl + jobId + '/') console.log('Files to upload: ', outFiles) if (zip) { let outFile = await generateOutputBundle(jobId, outFiles); let uploadResult = await uploader.upload(outFile); if (!skipClear) { clear(); } return { response_time: perf.elapsed(), urls: [uploadResult.url], url: uploadResult.url, original_manuscript: originalManuscript, }; } else { let urls = await Promise.all( outFiles.map((file) => { return uploader.upload(file); }) ).then((results) => { return results.map((result) => result.url); }); let url = urls.find((u) => u.includes('.mp4')) || urls.find((u) => u.includes('.webm')) || urls.find((u) => u.includes('.mov')) || urls.find((u) => u.includes('.avi')) || urls.find((u) => u.includes('.jpg')) || urls.find((u) => u.includes('.jpeg')) || urls.find((u) => u.includes('.png')); return { response_time: perf.elapsed(), urls: urls, url, original_manuscript: originalManuscript, }; } } renderSyncRequestStatuses.set(jobId, { id: jobId, controller, statusMessage: 'IN_PROGRESS', result: undefined } ); const result = await limiter.schedule(run); let job = renderSyncRequestStatuses.get(jobId); if (job) { delete job.controller renderSyncRequestStatuses.set(jobId, { finished: Date.now(), id: jobId, response_time: perf.elapsedSeconds(), statusMessage: 'COMPLETED', result: { jobId, success: true, ...result } } ); } res.status(200).json({ jobId, success: true, response_time: perf.elapsedSeconds(), ...result }); } catch (err) { console.error(`Render job ${jobId} failed:`, err.message, err); let job = renderSyncRequestStatuses.get(jobId); if (job) { delete job.controller renderSyncRequestStatuses.set(jobId, { finished: Date.now(), id: jobId, message: err.message + ' Details : ' + logs.join('\n'), response_time: perf.elapsedSeconds(), statusMessage: 'FAILED', result: { jobId, success: false } } ); } res.status(500).json({ jobId, success: false, message: err.message + ' Details : ' + logs.join('\n'), }); } }); export default RenderRouter;