diff --git a/app/assets/styles.css b/app/assets/styles.css index 1241fd9..cfdb777 100644 --- a/app/assets/styles.css +++ b/app/assets/styles.css @@ -182,6 +182,23 @@ p { max-width: 640px; } +.app-nav { + display: flex; + flex-wrap: wrap; + gap: var(--spacing-md); +} + +.app-link { + color: var(--color-primary); + font-weight: var(--font-weight-semibold); + font-size: var(--font-size-sm); + text-decoration: none; +} + +.app-link:hover { + text-decoration: underline; +} + .app-grid { display: grid; gap: var(--spacing-lg); @@ -515,6 +532,229 @@ p { gap: var(--spacing-xl); } +.trim-grid { + align-items: start; +} + +.trim-preview { + display: flex; + flex-direction: column; + gap: var(--spacing-sm); +} + +.trim-hint { + margin: 0; +} + +.trim-track { + position: relative; + height: 96px; + border-radius: var(--radius-xl); + border: 1px solid var(--color-border); + background: linear-gradient( + 90deg, + var(--color-border-muted) 0%, + var(--color-border-muted) 2%, + transparent 2%, + transparent 20% + ); + background-size: 20% 100%; + overflow: hidden; +} + +.trim-waveform { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + pointer-events: none; + color: var(--color-text-faint); + opacity: 0.7; + z-index: 1; +} + +.trim-track--disabled { + opacity: 0.6; + pointer-events: none; +} + +.trim-track--skeleton { + min-height: 96px; + background: linear-gradient( + 90deg, + var(--color-background) 0%, + var(--color-border) 45%, + var(--color-background) 90% + ); + background-size: 200% 100%; + animation: shimmer 1.8s infinite; +} + +.trim-range { + position: absolute; + top: 18px; + height: 60px; + border-radius: var(--radius-md); + border: 1px solid var(--color-warning-border); + background: var(--color-warning-surface); + left: var(--range-left); + width: var(--range-width); + display: flex; + align-items: center; + justify-content: center; + gap: var(--spacing-sm); + padding: 0 var(--spacing-lg); + box-shadow: inset 0 0 0 1px + color-mix(in srgb, var(--color-warning-border) 40%, transparent); + z-index: 2; +} + +.trim-range.is-selected { + box-shadow: 0 0 0 2px + color-mix(in srgb, var(--color-primary) 55%, transparent); +} + +.trim-range-label { + font-size: var(--font-size-xs); + font-weight: var(--font-weight-semibold); + color: var(--color-text-secondary); +} + +.trim-handle { + position: absolute; + top: 50%; + width: 14px; + height: 52px; + border-radius: var(--radius-sm); + background: var(--color-primary); + border: 1px solid var(--color-primary-active); + box-shadow: var(--shadow-sm); + cursor: ew-resize; + transform: translate(-50%, -50%); +} + +.trim-handle--start { + left: 0; +} + +.trim-handle--end { + right: 0; + transform: translate(50%, -50%); +} + +.trim-handle:focus-visible { + outline: 2px solid var(--color-border-accent); + outline-offset: 2px; +} + +.trim-handle-label { + position: absolute; + top: -18px; + font-size: var(--font-size-xs); + font-weight: var(--font-weight-semibold); + color: var(--color-text-inverse); + background: var(--color-surface-inverse); + padding: 2px 6px; + border-radius: var(--radius-pill); + white-space: nowrap; +} + +.trim-handle-label--start { + left: 0; + transform: translateX(-50%); +} + +.trim-handle-label--end { + right: 0; + transform: translateX(50%); +} + +.trim-playhead { + position: absolute; + top: 0; + bottom: 0; + width: 2px; + left: var(--playhead); + background: var(--color-primary); + box-shadow: 0 0 0 1px + color-mix(in srgb, var(--color-primary) 40%, transparent); + z-index: 3; +} + +.trim-range-list { + gap: var(--spacing-sm); +} + +.trim-range-row { + border: 1px solid var(--color-border); + padding: var(--spacing-md); + display: flex; + flex-direction: column; + gap: var(--spacing-md); + background: var(--color-surface); +} + +.trim-range-summary { + border: none; + padding: 0; + background: transparent; + display: flex; + flex-direction: column; + gap: var(--spacing-xs); + text-align: left; + cursor: pointer; +} + +.trim-range-time { + font-weight: var(--font-weight-semibold); +} + +.trim-range-fields { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)) auto; + gap: var(--spacing-sm); + align-items: end; +} + +.trim-waveform-meta { + display: flex; + justify-content: flex-end; +} + +.trim-time-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--spacing-md); + flex-wrap: wrap; +} + +.trim-progress { + display: flex; + align-items: center; + gap: var(--spacing-md); +} + +.trim-progress progress { + flex: 1; + height: 10px; +} + +.trim-command-card { + gap: var(--spacing-lg); +} + +.trim-command-actions { + display: flex; + gap: var(--spacing-sm); + flex-wrap: wrap; +} + +.trim-output { + max-height: 240px; + overflow-y: auto; +} + .timeline-header { display: flex; align-items: flex-start; @@ -955,4 +1195,8 @@ p { .timeline-controls { grid-template-columns: 1fr; } + + .trim-range-fields { + grid-template-columns: 1fr; + } } diff --git a/app/client/app.tsx b/app/client/app.tsx index 60b3247..6f4b62d 100644 --- a/app/client/app.tsx +++ b/app/client/app.tsx @@ -1,5 +1,14 @@ +import type { Handle } from 'remix/component' import { EditingWorkspace } from './editing-workspace.tsx' +import { TrimPoints } from './trim-points.tsx' -export function App() { - return () => +export function App(handle: Handle) { + return () => { + const pathname = + typeof window === 'undefined' ? '/' : window.location.pathname + if (pathname.startsWith('/trim-points')) { + return + } + return + } } diff --git a/app/client/editing-workspace.tsx b/app/client/editing-workspace.tsx index 3bb1589..5711509 100644 --- a/app/client/editing-workspace.tsx +++ b/app/client/editing-workspace.tsx @@ -492,14 +492,18 @@ export function EditingWorkspace(handle: Handle) { void requestQueue('/mark-done', { method: 'POST' }) } + const cancelActiveTask = (taskId: string) => { + void requestQueue(`/task/${encodeURIComponent(taskId)}`, { + method: 'DELETE', + }) + } + const clearCompletedTasks = () => { void requestQueue('/clear-completed', { method: 'POST' }) } const removeTask = (taskId: string) => { - void requestQueue(`/task/${encodeURIComponent(taskId)}`, { - method: 'DELETE', - }) + cancelActiveTask(taskId) } const syncVideoToPlayhead = (value: number) => { @@ -601,6 +605,11 @@ export function EditingWorkspace(handle: Handle) { Review transcript-based edits, refine command windows, and prepare the final CLI export in one place.

+
@@ -761,6 +770,20 @@ export function EditingWorkspace(handle: Handle) { > Mark running done + + ) : null} {task.status === 'queued' || task.status === 'error' ? ( + + + {pathStatus === 'error' && pathError ? ( +

{pathError}

+ ) : null} + +
+
+

Preview

+ + {previewReady + ? `Duration ${formatTimestamp(previewDuration)}` + : 'Load a video to preview'} + +
+
+ +
+ +
+
+
+

Trim timeline

+

+ Drag the trim handles or use arrow keys to fine-tune start and + end timestamps. +

+
+ +
+

+ Use arrow keys to nudge by {KEYBOARD_STEP}s. Hold Shift for {SHIFT_STEP} + s. +

+
{ + trackNode = node + }} + style={`--playhead:${duration > 0 ? (playhead / duration) * 100 : 0}%`} + > + { + waveformNode = node + drawWaveform() + if (typeof ResizeObserver === 'undefined') return + const observer = new ResizeObserver(() => drawWaveform()) + observer.observe(node) + signal.addEventListener('abort', () => { + observer.disconnect() + if (waveformNode === node) { + waveformNode = null + } + }) + }} + /> + {sortedRanges.map((range) => ( +
0 ? (range.start / duration) * 100 : 0}%; --range-width:${duration > 0 ? ((range.end - range.start) / duration) * 100 : 0}%`} + on={{ click: () => selectRange(range.id) }} + role="group" + aria-label={`Trim range ${formatTimestamp(range.start)} to ${formatTimestamp(range.end)}`} + > + + Remove {formatTimestamp(range.start)} -{' '} + {formatTimestamp(range.end)} + + + {formatTimestamp(range.start)} + +
+ ))} + +
+
+ {waveformStatus === 'loading' ? ( + Rendering waveform... + ) : waveformStatus === 'error' ? ( + {waveformError} + ) : ( + + Waveform {waveformSamples.length > 0 ? 'ready' : 'idle'} + + )} +
+
+ + { + const target = event.currentTarget as HTMLInputElement + setPlayhead(Number(target.value)) + }, + }} + /> + +
+
+ +
+
+
+

Trim ranges

+ + {sortedRanges.length} total + +
+ {sortedRanges.length === 0 ? ( +

+ Add a trim range to start removing segments. +

+ ) : ( +
    + {sortedRanges.map((range) => ( +
  • + +
    + + + +
    +
  • + ))} +
+ )} +
+ +
+

Output summary

+
+
+ Removed + {formatSeconds(totalRemoved)} + + {normalizedRanges.length} normalized ranges + +
+
+ Output length + + {previewReady + ? formatTimestamp(outputDuration) + : '--:--.--'} + + + {previewReady && duration > 0 + ? `${Math.round((outputDuration / duration) * 100)}% kept` + : 'Load a video to calculate'} + +
+
+ Command status + {progressLabel} + + {runStatus === 'running' + ? 'ffmpeg in progress' + : 'Ready to run'} + +
+
+
+
+ +
+
+

ffmpeg command

+
+ + +
+
+

+ Use this command in your terminal, or run it here to watch progress + stream back into the UI. +

+ {commandPreview ? ( +
{commandPreview}
+ ) : ( +

+ Load a video and add at least one trim range to generate the + command. +

+ )} +
+ + {progressLabel} +
+ {runError ? ( +

{runError}

+ ) : null} +
+						{runLogs.length > 0
+							? runLogs.slice(-200).join('\n')
+							: 'ffmpeg output will appear here.'}
+					
+
+ + ) + } +} diff --git a/app/config/routes.ts b/app/config/routes.ts index 5f719f0..6c3446f 100644 --- a/app/config/routes.ts +++ b/app/config/routes.ts @@ -2,4 +2,5 @@ import { route } from 'remix/fetch-router' export default route({ index: '/', + trimPoints: '/trim-points', }) diff --git a/app/router.tsx b/app/router.tsx index 254e08a..b5a512d 100644 --- a/app/router.tsx +++ b/app/router.tsx @@ -5,6 +5,7 @@ import { Layout } from './components/layout.tsx' import routes from './config/routes.ts' import { render } from './helpers/render.ts' import indexHandlers from './routes/index.tsx' +import trimPointsHandlers from './routes/trim-points.tsx' const STATIC_CORS_HEADERS = { 'Access-Control-Allow-Origin': '*', @@ -102,5 +103,10 @@ export function createAppRouter(rootDir: string) { action: indexHandlers.loader, }) + router.map(routes.trimPoints, { + middleware: trimPointsHandlers.middleware, + action: trimPointsHandlers.loader, + }) + return router } diff --git a/app/routes/index.tsx b/app/routes/index.tsx index 030178d..470e071 100644 --- a/app/routes/index.tsx +++ b/app/routes/index.tsx @@ -18,6 +18,9 @@ const indexHandler = { Review transcript-based edits, refine cut ranges, and prepare exports.

+

Source video

diff --git a/app/routes/trim-points.tsx b/app/routes/trim-points.tsx new file mode 100644 index 0000000..a3d71e9 --- /dev/null +++ b/app/routes/trim-points.tsx @@ -0,0 +1,51 @@ +import { html } from 'remix/html-template' +import { Layout } from '../components/layout.tsx' +import { render } from '../helpers/render.ts' + +const trimPointsHandler = { + middleware: [], + loader() { + const initialVideoPath = process.env.EPREC_APP_VIDEO_PATH?.trim() + return render( + Layout({ + title: 'Trim points - Eprec Studio', + appConfig: initialVideoPath ? { initialVideoPath } : undefined, + children: html`
+
+ Eprec Studio +

Trim points

+

+ Add start and stop points, generate an ffmpeg trim command, and run + it with live progress. +

+ +
+
+

Video source

+

+ Enter a video file path once the interactive UI loads. +

+
+
+

Timeline

+

+ Add trim ranges and drag their handles to fine-tune timestamps. +

+
+
+
+

ffmpeg command

+

+ Command output and progress details appear after you load a video. +

+
Loading trim preview...
+
+
`, + }), + ) + }, +} + +export default trimPointsHandler diff --git a/app/trim-api.ts b/app/trim-api.ts new file mode 100644 index 0000000..22940e8 --- /dev/null +++ b/app/trim-api.ts @@ -0,0 +1,261 @@ +import path from 'node:path' +import { fileURLToPath } from 'node:url' +import { mkdir } from 'node:fs/promises' +import { + buildFfmpegArgs, + computeOutputDuration, + normalizeTrimRanges, + type TrimRange, +} from './trim-commands.ts' + +const TRIM_ROUTE = '/api/trim' + +type TrimRequestPayload = { + inputPath?: string + outputPath?: string + duration?: number + ranges?: TrimRange[] +} + +function expandHomePath(value: string) { + if (!value.startsWith('~/') && !value.startsWith('~\\')) { + return value + } + const home = process.env.HOME?.trim() + if (!home) return value + return path.join(home, value.slice(2)) +} + +function resolveMediaPath(rawPath: string): string | null { + const trimmed = rawPath.trim() + if (!trimmed) return null + if (trimmed.startsWith('file://')) { + try { + return fileURLToPath(trimmed) + } catch { + return null + } + } + return path.resolve(expandHomePath(trimmed)) +} + +function parseRanges(ranges: unknown): TrimRange[] { + if (!Array.isArray(ranges)) return [] + return ranges + .map((entry) => { + if (!entry || typeof entry !== 'object') return null + const candidate = entry as TrimRange + if (!Number.isFinite(candidate.start) || !Number.isFinite(candidate.end)) { + return null + } + return { start: candidate.start, end: candidate.end } + }) + .filter((range): range is TrimRange => Boolean(range)) +} + +function parseOutTimeValue(value: string) { + const parts = value.trim().split(':') + if (parts.length !== 3) return null + const [hours, minutes, seconds] = parts + const h = Number.parseFloat(hours) + const m = Number.parseFloat(minutes) + const s = Number.parseFloat(seconds) + if (!Number.isFinite(h) || !Number.isFinite(m) || !Number.isFinite(s)) { + return null + } + return h * 3600 + m * 60 + s +} + +function clamp(value: number, min: number, max: number) { + return Math.min(Math.max(value, min), max) +} + +async function readLines( + stream: ReadableStream | null, + onLine: (line: string) => void, +) { + if (!stream) return + const reader = stream.pipeThrough(new TextDecoderStream()).getReader() + let buffer = '' + while (true) { + const { value, done } = await reader.read() + if (done) break + buffer += value + const lines = buffer.split('\n') + buffer = lines.pop() ?? '' + for (const line of lines) { + const trimmed = line.trim() + if (trimmed) onLine(trimmed) + } + } + const trailing = buffer.trim() + if (trailing) onLine(trailing) +} + +export async function handleTrimRequest(request: Request): Promise { + const url = new URL(request.url) + if (url.pathname !== TRIM_ROUTE) { + return new Response('Not Found', { status: 404 }) + } + + if (request.method === 'OPTIONS') { + return new Response(null, { + status: 204, + headers: { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Methods': 'POST, OPTIONS', + 'Access-Control-Allow-Headers': 'Content-Type', + }, + }) + } + + if (request.method !== 'POST') { + return new Response('Method Not Allowed', { status: 405 }) + } + + let payload: TrimRequestPayload + try { + payload = (await request.json()) as TrimRequestPayload + } catch { + return new Response('Invalid JSON payload.', { status: 400 }) + } + + const inputRaw = payload.inputPath ?? '' + const outputRaw = payload.outputPath ?? '' + const duration = Number(payload.duration ?? 0) + if (!Number.isFinite(duration) || duration <= 0) { + return new Response('Invalid or missing duration.', { status: 400 }) + } + + const inputPath = resolveMediaPath(inputRaw) + const outputPath = resolveMediaPath(outputRaw) + if (!inputPath || !outputPath) { + return new Response('Input and output paths are required.', { + status: 400, + }) + } + + const ranges = normalizeTrimRanges(parseRanges(payload.ranges), duration) + if (ranges.length === 0) { + return new Response('No valid trim ranges provided.', { status: 400 }) + } + + const outputDuration = computeOutputDuration(duration, ranges) + if (outputDuration <= 0) { + return new Response('Trim ranges remove the full video.', { status: 400 }) + } + + const resolvedInput = path.resolve(inputPath) + const resolvedOutput = path.resolve(outputPath) + if (resolvedInput === resolvedOutput) { + return new Response('Output path must be different from input.', { + status: 400, + }) + } + + const inputFile = Bun.file(resolvedInput) + if (!(await inputFile.exists())) { + return new Response('Input file not found.', { status: 404 }) + } + + await mkdir(path.dirname(resolvedOutput), { recursive: true }) + + const args = buildFfmpegArgs({ + inputPath: resolvedInput, + outputPath: resolvedOutput, + ranges, + withProgress: true, + }) + if (args.length === 0) { + return new Response('Unable to build ffmpeg command.', { status: 400 }) + } + + const outputDurationSeconds = outputDuration + const encoder = new TextEncoder() + + const stream = new ReadableStream({ + start(controller) { + let outTimeSeconds = 0 + const send = (payload: Record) => { + try { + controller.enqueue( + encoder.encode(`${JSON.stringify(payload)}\n`), + ) + } catch { + // stream closed + } + } + const process = Bun.spawn({ + cmd: args, + stdout: 'pipe', + stderr: 'pipe', + }) + + request.signal.addEventListener('abort', () => { + try { + process.kill() + } catch { + // ignore + } + }) + + const stdoutReader = readLines(process.stdout, (line) => { + const [key, rawValue] = line.split('=') + const value = rawValue ?? '' + if (key === 'out_time_ms') { + const next = Number.parseFloat(value) + if (Number.isFinite(next)) outTimeSeconds = next / 1000 + } + if (key === 'out_time_us') { + const next = Number.parseFloat(value) + if (Number.isFinite(next)) outTimeSeconds = next / 1000000 + } + if (key === 'out_time') { + const parsed = parseOutTimeValue(value) + if (parsed !== null) outTimeSeconds = parsed + } + if (key === 'progress') { + const progress = + outputDurationSeconds > 0 + ? clamp(outTimeSeconds / outputDurationSeconds, 0, 1) + : 0 + send({ type: 'progress', progress }) + if (value === 'end') { + send({ type: 'progress', progress: 1 }) + } + } + }) + + const stderrReader = readLines(process.stderr, (line) => { + send({ type: 'log', message: line }) + }) + + Promise.all([stdoutReader, stderrReader, process.exited]) + .then(([, , exitCode]) => { + send({ + type: 'done', + success: exitCode === 0, + exitCode, + }) + }) + .catch((error) => { + send({ + type: 'done', + success: false, + error: error instanceof Error ? error.message : String(error), + }) + }) + .finally(() => { + controller.close() + }) + }, + }) + + return new Response(stream, { + headers: { + 'Content-Type': 'application/x-ndjson', + 'Cache-Control': 'no-cache', + 'Access-Control-Allow-Origin': '*', + }, + }) +} diff --git a/app/trim-commands.ts b/app/trim-commands.ts new file mode 100644 index 0000000..aa18190 --- /dev/null +++ b/app/trim-commands.ts @@ -0,0 +1,154 @@ +export type TrimRange = { + start: number + end: number +} + +const DEFAULT_MIN_RANGE = 0.05 + +function clamp(value: number, min: number, max: number) { + return Math.min(Math.max(value, min), max) +} + +function formatSecondsForCommand(value: number) { + return value.toFixed(3) +} + +export function normalizeTrimRanges( + ranges: TrimRange[], + duration: number, + minLength: number = DEFAULT_MIN_RANGE, +) { + if (!Number.isFinite(duration) || duration <= 0) return [] + const normalized = ranges + .map((range) => { + const startRaw = Number.isFinite(range.start) ? range.start : 0 + const endRaw = Number.isFinite(range.end) ? range.end : 0 + const start = clamp(Math.min(startRaw, endRaw), 0, duration) + const end = clamp(Math.max(startRaw, endRaw), 0, duration) + if (end - start < minLength) return null + return { start, end } + }) + .filter((range): range is TrimRange => Boolean(range)) + .sort((a, b) => a.start - b.start) + + const merged: TrimRange[] = [] + for (const range of normalized) { + const last = merged[merged.length - 1] + if (last && range.start <= last.end + minLength) { + last.end = Math.max(last.end, range.end) + } else { + merged.push({ ...range }) + } + } + return merged +} + +export function computeOutputDuration( + duration: number, + ranges: TrimRange[], + minLength: number = DEFAULT_MIN_RANGE, +) { + const normalized = normalizeTrimRanges(ranges, duration, minLength) + const removed = normalized.reduce( + (total, range) => total + (range.end - range.start), + 0, + ) + return Math.max(duration - removed, 0) +} + +export function buildTrimExpression(ranges: TrimRange[]) { + if (ranges.length === 0) return '' + const expressions = ranges.map( + (range) => + `between(t,${formatSecondsForCommand(range.start)},${formatSecondsForCommand(range.end)})`, + ) + return `not(${expressions.join('+')})` +} + +export function buildTrimFilters(ranges: TrimRange[]) { + const expression = buildTrimExpression(ranges) + if (!expression) { + return { + expression: '', + videoFilter: '', + audioFilter: '', + } + } + return { + expression, + videoFilter: `select='${expression}',setpts=N/FRAME_RATE/TB`, + audioFilter: `aselect='${expression}',asetpts=N/SR/TB`, + } +} + +export function buildFfmpegArgs(options: { + inputPath: string + outputPath: string + ranges: TrimRange[] + withProgress?: boolean +}) { + const filters = buildTrimFilters(options.ranges) + if (!filters.expression) return [] + const args = [ + 'ffmpeg', + '-hide_banner', + '-y', + '-i', + options.inputPath, + '-vf', + filters.videoFilter, + '-af', + filters.audioFilter, + '-map', + '0:v', + '-map', + '0:a?', + '-c:v', + 'libx264', + '-preset', + 'veryfast', + '-crf', + '18', + '-c:a', + 'aac', + '-b:a', + '192k', + '-movflags', + '+faststart', + ] + if (options.withProgress) { + args.push('-progress', 'pipe:1', '-nostats') + } + args.push(options.outputPath) + return args +} + +function quoteShellArgument(value: string) { + const escaped = value.replace(/(["\\$`])/g, '\\$1') + return `"${escaped}"` +} + +export function buildFfmpegCommandPreview(options: { + inputPath: string + outputPath: string + ranges: TrimRange[] + includeProgress?: boolean +}) { + const filters = buildTrimFilters(options.ranges) + if (!filters.expression) return '' + const lines = [ + 'ffmpeg -hide_banner -y \\', + ` -i ${quoteShellArgument(options.inputPath)} \\`, + ` -vf ${quoteShellArgument(filters.videoFilter)} \\`, + ` -af ${quoteShellArgument(filters.audioFilter)} \\`, + ' -map 0:v -map 0:a? \\', + ' -c:v libx264 -preset veryfast -crf 18 \\', + ' -c:a aac -b:a 192k \\', + ' -movflags +faststart \\', + ] + if (options.includeProgress) { + lines.push(' -progress pipe:1 -nostats \\') + } + lines.push(` ${quoteShellArgument(options.outputPath)}`) + return lines.join('\n') +} diff --git a/src/app-server.ts b/src/app-server.ts index 5db6a0d..157d1ae 100644 --- a/src/app-server.ts +++ b/src/app-server.ts @@ -4,6 +4,7 @@ import '../app/config/init-env.ts' import getPort from 'get-port' import { getEnv } from '../app/config/env.ts' import { createAppRouter } from '../app/router.tsx' +import { handleTrimRequest } from '../app/trim-api.ts' import { handleVideoRequest } from '../app/video-api.ts' import { createBundlingRoutes } from '../server/bundling.ts' import { handleProcessingQueueRequest } from '../server/processing-queue.ts' @@ -197,6 +198,9 @@ function startServer(port: number, hostname: string) { if (url.pathname === '/api/video') { return await handleVideoRequest(request) } + if (url.pathname === '/api/trim') { + return await handleTrimRequest(request) + } if (url.pathname.startsWith('/api/processing-queue')) { return await handleProcessingQueueRequest(request) }