Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,12 @@ WORKER_CONCURRENCY=5
# Storage Mode (stateless or s3)
STORAGE_MODE=stateless

# Stateless binary response cache (cacache)
CACHE_ENABLED=false
CACHE_DIR=/tmp/ffmpeg-rest/cache
CACHE_TTL_HOURS=2160
CACHE_MAX_SIZE_MB=1024

S3_ENDPOINT=https://s3.amazonaws.com
S3_REGION=us-east-1
S3_BUCKET=my-bucket
Expand Down
18 changes: 18 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,24 @@ Files are processed and returned directly in the HTTP response. Simple and strai

**Cost Consideration**: On Railway, stateless mode is cheaper than running S3 Mode unless you have free egress at your S3-storage provider (like Cloudflare R2). Railway charges $0.05 per GB egress vs S3's typical $0.09 per GB, but you trade off file persistence - processed files aren't stored for later retrieval.

#### Stateless Binary Cache

Stateless mode can optionally cache binary conversion outputs using `cacache` to avoid rerunning FFmpeg on identical inputs + params.

- Cache scope: binary conversion endpoints only (not `/.../url` S3 responses, not `/media/info`)
- Cache key: SHA-256 of input bytes + job type + normalized processing params
- Retention: TTL + size cap (enforced on reads/writes and startup)
- Storage: local filesystem (ephemeral by default)

**Configuration**:

```bash
CACHE_ENABLED=false # Enable/disable stateless cache
CACHE_DIR=/tmp/ffmpeg-rest/cache
CACHE_TTL_HOURS=2160 # 90 days
CACHE_MAX_SIZE_MB=1024 # 1 GiB
```

### S3 Mode

Processed files are uploaded to S3-compatible storage and a URL is returned. This mode significantly reduces egress bandwidth costs since users download the processed files directly from S3 rather than through your API server. Ideal for production deployments where bandwidth costs matter.
Expand Down
5 changes: 5 additions & 0 deletions apps/server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,16 @@
"@scalar/hono-api-reference": "^0.9.20",
"@scalar/openapi-to-markdown": "^0.2.43",
"bullmq": "^5.60.0",
"cacache": "^20.0.3",
"dotenv": "^17.2.3",
"hono": "^4.9.9",
"ioredis": "^5.8.0",
"pino": "^10.0.0",
"pino-pretty": "^13.1.1",
"safe-stable-stringify": "^2.5.0",
"zod": "^4.1.11"
},
"devDependencies": {
"@types/cacache": "^20.0.1"
}
}
12 changes: 11 additions & 1 deletion apps/server/src/config/env.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { z } from 'zod';
import path from 'path';

if (process.env['NODE_ENV'] !== 'production') {
const dotenv = await import('dotenv');
Expand All @@ -15,8 +16,17 @@ const schema = z.object({
MAX_FILE_SIZE: z.coerce.number().default(100 * 1024 * 1024),

STORAGE_MODE: z.enum(['stateless', 's3']).default('stateless'),
CACHE_ENABLED: z.stringbool().default(false),
CACHE_DIR: z.string().optional(),
CACHE_TTL_HOURS: z.coerce.number().int().positive().default(2160),
CACHE_MAX_SIZE_MB: z.coerce.number().int().positive().default(1024),

AUTH_TOKEN: z.string().optional()
});

export const env = schema.parse(process.env);
const parsedEnv = schema.parse(process.env);

export const env = {
...parsedEnv,
CACHE_DIR: parsedEnv.CACHE_DIR ?? path.join(parsedEnv.TEMP_DIR, 'cache')
};
5 changes: 5 additions & 0 deletions apps/server/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,14 @@ import { createApp } from '~/app';
import { env } from '~/config/env';
import { checkRedisHealth } from '~/config/redis';
import { logger } from '~/config/logger';
import { initCacheDir } from '~/utils/cache';

await checkRedisHealth();

if (env.CACHE_ENABLED) {
await initCacheDir();
}

const app = createApp();

serve(
Expand Down
50 changes: 50 additions & 0 deletions apps/server/src/test-utils/test-helpers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { mkdtemp, rm } from 'fs/promises';
import path from 'path';
import { tmpdir } from 'os';

interface CacheTestEnvOptions {
cacheDir: string;
tempDir: string;
cacheEnabled?: boolean;
ttlHours?: number;
maxSizeMb?: number;
}

export function createTempDirTracker() {
const createdDirs: string[] = [];

return {
async createTempDir(prefix: string): Promise<string> {
const dir = await mkdtemp(path.join(tmpdir(), prefix));
createdDirs.push(dir);
return dir;
},
async cleanupTempDirs(): Promise<void> {
await Promise.all(
createdDirs.splice(0, createdDirs.length).map((dir) =>
rm(dir, {
recursive: true,
force: true
})
)
);
}
};
}

export function setCacheTestEnv(options: CacheTestEnvOptions): void {
process.env['NODE_ENV'] = 'test';
process.env['TEMP_DIR'] = options.tempDir;
process.env['CACHE_ENABLED'] = options.cacheEnabled === false ? 'false' : 'true';
process.env['CACHE_DIR'] = options.cacheDir;
process.env['CACHE_TTL_HOURS'] = String(options.ttlHours ?? 24);
process.env['CACHE_MAX_SIZE_MB'] = String(options.maxSizeMb ?? 10);
}

export function clearCacheTestEnv(): void {
delete process.env['CACHE_ENABLED'];
delete process.env['CACHE_DIR'];
delete process.env['CACHE_TTL_HOURS'];
delete process.env['CACHE_MAX_SIZE_MB'];
delete process.env['TEMP_DIR'];
}
228 changes: 228 additions & 0 deletions apps/server/src/utils/cache.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,228 @@
import { describe, it, expect, afterEach, vi } from 'vitest';
import * as cacache from 'cacache';
import { readdir } from 'fs/promises';
import path from 'path';
import { clearCacheTestEnv, createTempDirTracker, setCacheTestEnv } from '../test-utils/test-helpers';

const { createTempDir, cleanupTempDirs } = createTempDirTracker();

async function countContentBlobs(cacheDir: string): Promise<number> {
const contentRoot = path.join(cacheDir, 'content-v2');
let count = 0;

const walk = async (dir: string): Promise<void> => {
const entries = await readdir(dir, { withFileTypes: true });

for (const entry of entries) {
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
await walk(fullPath);
} else if (entry.isFile()) {
count += 1;
}
}
};

try {
await walk(contentRoot);
return count;
} catch (error) {
const maybeErrno = error as NodeJS.ErrnoException;
if (maybeErrno.code === 'ENOENT') {
return 0;
}

throw error;
}
}

async function loadCacheModule(options?: { cacheEnabled?: boolean; ttlHours?: number; maxSizeMb?: number }) {
const cacheDir = await createTempDir('cache-utils-');
const tempDir = await createTempDir('cache-utils-temp-');

vi.resetModules();
vi.clearAllMocks();

setCacheTestEnv({
tempDir,
cacheDir,
cacheEnabled: options?.cacheEnabled,
ttlHours: options?.ttlHours,
maxSizeMb: options?.maxSizeMb
});

const mod = await import('./cache');
return { ...mod, cacheDir };
}

afterEach(async () => {
await cleanupTempDirs();
clearCacheTestEnv();
});

describe('cache utility', () => {
it('should generate deterministic cache keys for identical input', async () => {
const { computeCacheKey } = await loadCacheModule();
const input = Buffer.from('same-input');
const params = { quality: 2, mode: 'fit' };

const key1 = computeCacheKey(input, 'audio:mp3', 'mp3', params);
const key2 = computeCacheKey(input, 'audio:mp3', 'mp3', params);

expect(key1).toBe(key2);
});

it('should generate different keys for different params and job types', async () => {
const { computeCacheKey } = await loadCacheModule();
const input = Buffer.from('same-input');

const keyA = computeCacheKey(input, 'audio:mp3', 'mp3', { quality: 2 });
const keyB = computeCacheKey(input, 'audio:mp3', 'mp3', { quality: 7 });
const keyC = computeCacheKey(input, 'video:mp4', 'mp4', { quality: 2 });

expect(keyA).not.toBe(keyB);
expect(keyA).not.toBe(keyC);
});

it('should generate different keys for different input bytes', async () => {
const { computeCacheKey } = await loadCacheModule();
const params = { quality: 2, mode: 'fit' };

const keyA = computeCacheKey(Buffer.from('input-a'), 'audio:mp3', 'mp3', params);
const keyB = computeCacheKey(Buffer.from('input-b'), 'audio:mp3', 'mp3', params);

expect(keyA).not.toBe(keyB);
});

it('should generate different keys for different output extensions', async () => {
const { computeCacheKey } = await loadCacheModule();
const input = Buffer.from('same-input');
const params = { quality: 2, mode: 'fit' };

const keyA = computeCacheKey(input, 'audio:mp3', 'mp3', params);
const keyB = computeCacheKey(input, 'audio:mp3', 'wav', params);

expect(keyA).not.toBe(keyB);
});

it('should ignore runtime-only path keys in operation signature', async () => {
const { computeCacheKey } = await loadCacheModule();
const input = Buffer.from('same-input');

const keyA = computeCacheKey(input, 'audio:mp3', 'mp3', {
inputPath: '/tmp/input-a',
outputPath: '/tmp/output-a',
outputDir: '/tmp/frames-a',
jobDir: '/tmp/job-a',
uploadToS3: true,
quality: 2,
nested: {
outputPath: '/tmp/nested-a',
mode: 'fit'
}
});

const keyB = computeCacheKey(input, 'audio:mp3', 'mp3', {
inputPath: '/tmp/input-b',
outputPath: '/tmp/output-b',
outputDir: '/tmp/frames-b',
jobDir: '/tmp/job-b',
uploadToS3: false,
quality: 2,
nested: {
outputPath: '/tmp/nested-b',
mode: 'fit'
}
});

expect(keyA).toBe(keyB);
});

it('should be deterministic across different object key orders', async () => {
const { computeCacheKey } = await loadCacheModule();
const input = Buffer.from('same-input');

const keyA = computeCacheKey(input, 'audio:mp3', 'mp3', {
quality: 2,
mode: 'fit',
nested: { x: 1, y: 2 }
});

const keyB = computeCacheKey(input, 'audio:mp3', 'mp3', {
mode: 'fit',
nested: { y: 2, x: 1 },
quality: 2
});

expect(keyA).toBe(keyB);
});

it('should round-trip cached output', async () => {
const { initCacheDir, putCachedOutput, getCachedOutput } = await loadCacheModule();
await initCacheDir();

const key = 'roundtrip-key';
const output = Buffer.from('converted-output');
await putCachedOutput(key, output, { codec: 'mp3' });

const cached = await getCachedOutput(key);
expect(cached?.outputBuffer.toString()).toBe('converted-output');
expect(cached?.metadata).toEqual({ codec: 'mp3' });
});

it('should treat expired entries as cache misses', async () => {
const { initCacheDir, getCachedOutput, cacheDir } = await loadCacheModule({ ttlHours: 1 });
await initCacheDir();

const key = 'expired-key';
await cacache.put(cacheDir, key, Buffer.from('old-data'), {
metadata: {
createdAt: Date.now() - 2 * 60 * 60 * 1000,
jobType: 'audio:mp3',
outputExtension: 'mp3'
}
});

const cached = await getCachedOutput(key);
expect(cached).toBeNull();
});

it('should evict oldest entries when cache exceeds max size', async () => {
const { initCacheDir, putCachedOutput, getCachedOutput } = await loadCacheModule({ maxSizeMb: 1 });
await initCacheDir();

const keyOld = 'old-entry';
const keyNew = 'new-entry';
const payload = Buffer.alloc(700 * 1024, 1);

await putCachedOutput(keyOld, payload);
await new Promise((resolve) => setTimeout(resolve, 10));
await putCachedOutput(keyNew, payload);

const oldEntry = await getCachedOutput(keyOld);
const newEntry = await getCachedOutput(keyNew);

expect(oldEntry).toBeNull();
expect(newEntry).not.toBeNull();
});

it('should garbage collect unreferenced content after eviction', async () => {
const { initCacheDir, putCachedOutput, cacheDir } = await loadCacheModule({ maxSizeMb: 1 });
await initCacheDir();

await putCachedOutput('entry-a', Buffer.alloc(700 * 1024, 1));
await new Promise((resolve) => setTimeout(resolve, 10));
await putCachedOutput('entry-b', Buffer.alloc(700 * 1024, 2));

const contentBlobs = await countContentBlobs(cacheDir);
expect(contentBlobs).toBe(1);
});

it('should no-op when cache is disabled', async () => {
const { putCachedOutput, getCachedOutput } = await loadCacheModule({ cacheEnabled: false });
await putCachedOutput('disabled-key', Buffer.from('data'));

const cached = await getCachedOutput('disabled-key');
expect(cached).toBeNull();
});
});
Loading