feat(renderer): add local GIF render CLI

This commit is contained in:
tiennm99 committed 2026-07-15 15:50:34 +07:00
1 parent 963e8087a8
commit 566b06a461
6 files changed
+258 -21

No files matched your search

+17 -21
View File
@@ -62,37 +62,33 @@ Generate the complete fixture set at `fixtures/smoke.gif`,
pnpm render:fixtures
```
For a custom GIF, keep `pnpm dev` running in one terminal, then run one of
these commands in another.
PowerShell:
Render a custom GIF directly without starting the API server:
```powershell
$body = @{
options = @("Chiều nay uống CraneTea", "Chiều nay uống CraneTea", "Chiều nay uống CraneTea", "Chiều nay uống CraneTea")
winnerIndex = 1
durationMs = 6500
holdMs = 1200
fps = 15
size = 512
theme = "classic"
} | ConvertTo-Json
Invoke-WebRequest -Method Post -Uri http://localhost:3000/api/gif -ContentType "application/json" -Body $body -OutFile wheel.gif
pnpm render:local -- `
--output wheel.gif `
--option "Chiều nay uống CraneTea" `
--option "Chiều nay uống CraneTea" `
--option "Cà phê" `
--winner 1
```
macOS, Linux, or Git Bash:
```sh
curl --request POST http://localhost:3000/api/gif \
--header "Content-Type: application/json" \
pnpm render:local -- \
--output wheel.gif \
--data '{"options":["Chiều nay uống CraneTea","Chiều nay uống CraneTea","Chiều nay uống CraneTea","Chiều nay uống CraneTea"],"winnerIndex":1,"durationMs":6500,"holdMs":1200,"fps":15,"size":512,"theme":"classic"}'
--option "Chiều nay uống CraneTea" \
--option "Chiều nay uống CraneTea" \
--option "Cà phê" \
--winner 1
```
The local API does not need an authorization header by default. If you
configure `API_TOKEN`, add `--header "Authorization: Bearer <token>"` to the
request. Generated GIF files are git-ignored and safe to delete.
`--winner` is a zero-based index and is random when omitted. Run
`pnpm render:local -- --help` for duration, hold, FPS, size, theme, and timeout
options. The documented root `wheel.gif` and `fixtures/*.gif` outputs are
git-ignored and safe to delete; custom output paths may need their own ignore
rule.
### Verify
@@ -0,0 +1,32 @@
# Direct Local GIF Rendering
## Context
Custom wheel GIFs previously required starting the API server, while the smoke renderer only supported predefined fixtures. The goal was a direct CLI that accepts user options and renders through the existing Remotion pipeline.
## What Happened
- Added `pnpm render:local` for direct GIF rendering with no hosted or local HTTP server.
- Reused the shared wheel request schema so CLI and API validation stay aligned.
- Supported repeated `--option` flags, including duplicate Unicode text such as Vietnamese labels.
- Verified the produced file by its `GIF89a` signature, not only by command success.
- Passed all 45 tests plus lint, typecheck, and render smoke checks.
- Updated local usage docs and clarified that `wheel.gif` and `fixtures/*.gif` are ignored, while custom output paths may require their own ignore rule.
## Reflection
Routing the CLI through the production renderer kept behavior consistent and avoided a second rendering path. Repeated flags made Unicode and duplicate values easier to pass than inline JSON. The main documentation nuance was avoiding a broad GIF ignore rule that could conceal intentionally tracked assets.
## Decisions
| Decision | Rationale | Impact |
|---|---|---|
| Render directly through `renderWheelGif` | Reuse the tested production pipeline without server startup | Local generation is simpler and behavior matches API output |
| Validate with the shared schema | Prevent CLI/API rules from drifting | Limits, defaults, and errors remain consistent |
| Accept repeated `--option` flags | Preserve duplicates and shell-safe Unicode input | Custom multilingual wheels are straightforward to invoke |
| Keep output ignores narrow | Avoid accidentally ignoring legitimate GIF assets | Documented paths are safe; custom paths need deliberate ignore rules |
## Next
- Keep CLI options synchronized with future request-schema changes.
- Add new documented output locations to `.gitignore` only when they become standard project artifacts.
+1
View File
@@ -14,6 +14,7 @@
"test": "vitest run",
"browser:ensure": "node scripts/ensure-browser.js",
"api:smoke": "node scripts/api-smoke.js",
"render:local": "node scripts/render-local.js",
"render:smoke": "node scripts/render-fixtures.js --smoke",
"render:fixtures": "node scripts/render-fixtures.js"
},
+93
View File
@@ -0,0 +1,93 @@
import {randomInt} from 'node:crypto';
import {parseArgs} from 'node:util';
import {parseWheelRequest} from '../src/schemas/wheel-request.js';
const limits = {
maxOptions: 32,
maxOptionChars: 40,
};
/**
* @param {string | undefined} value
* @param {string} flag
* @returns {number | undefined}
*/
const parseInteger = (value, flag) => {
if (value === undefined) {
return undefined;
}
if (!/^\d+$/.test(value)) {
throw new Error(`--${flag} must be an integer`);
}
return Number.parseInt(value, 10);
};
/**
* @typedef {(
* {help: true, output: string, timeoutInMilliseconds: number, request: undefined} |
* {help: false, output: string, timeoutInMilliseconds: number, request: import('../src/schemas/wheel-request.js').WheelRenderRequest}
* )} RenderLocalArguments
*/
/**
* @param {string[]} args
* @returns {RenderLocalArguments}
*/
export const parseRenderLocalArgs = (args) => {
const {values} = parseArgs({
args,
allowPositionals: false,
strict: true,
options: {
help: {type: 'boolean', short: 'h'},
output: {type: 'string', short: 'o', default: 'wheel.gif'},
option: {type: 'string', multiple: true},
winner: {type: 'string'},
duration: {type: 'string'},
hold: {type: 'string'},
fps: {type: 'string'},
size: {type: 'string'},
theme: {type: 'string'},
timeout: {type: 'string', default: '30000'},
},
});
const timeoutInMilliseconds = parseInteger(values.timeout, 'timeout');
if (timeoutInMilliseconds === undefined || timeoutInMilliseconds < 7000) {
throw new Error('--timeout must be at least 7000 milliseconds');
}
if (values.help) {
return {
help: true,
output: values.output,
request: undefined,
timeoutInMilliseconds,
};
}
/** @type {Record<string, unknown>} */
const input = {options: values.option ?? []};
const winnerIndex = parseInteger(values.winner, 'winner');
const durationMs = parseInteger(values.duration, 'duration');
const holdMs = parseInteger(values.hold, 'hold');
const fps = parseInteger(values.fps, 'fps');
const size = parseInteger(values.size, 'size');
if (winnerIndex !== undefined) input.winnerIndex = winnerIndex;
if (durationMs !== undefined) input.durationMs = durationMs;
if (holdMs !== undefined) input.holdMs = holdMs;
if (fps !== undefined) input.fps = fps;
if (size !== undefined) input.size = size;
if (values.theme !== undefined) {
input.theme = values.theme;
}
return {
help: false,
output: values.output,
request: parseWheelRequest(input, limits, randomInt),
timeoutInMilliseconds,
};
};
+40
View File
@@ -0,0 +1,40 @@
import {mkdir, writeFile} from 'node:fs/promises';
import path from 'node:path';
import {renderWheelGif} from '../src/render/render-gif.js';
import {parseRenderLocalArgs} from './render-local-args.js';
const usage = `Usage:
pnpm render:local -- --option <text> --option <text> [options]
Options:
-o, --output <path> Output file (default: wheel.gif)
--option <text> Wheel option; repeat at least twice
--winner <index> Zero-based winner index (random when omitted)
--duration <ms> Spin duration, 3000-10000 (default: 6500)
--hold <ms> Winner hold, 500-2500 (default: 1200)
--fps <value> 12, 15, or 20 (default: 15)
--size <pixels> 384, 480, or 512 (default: 512)
--theme <name> classic, festival, or mono
--timeout <ms> Render timeout, minimum 7000 (default: 30000)
-h, --help Show this help`;
try {
const parsed = parseRenderLocalArgs(process.argv.slice(2));
if (parsed.help) {
console.log(usage);
process.exitCode = 0;
} else {
const output = path.resolve(parsed.output);
await mkdir(path.dirname(output), {recursive: true});
const result = await renderWheelGif(parsed.request, {
timeoutInMilliseconds: parsed.timeoutInMilliseconds,
});
await writeFile(output, result.buffer);
console.log(`${output} ${result.byteLength} bytes ${result.durationMs}ms`);
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
console.error(`Unable to render GIF: ${message}\n\n${usage}`);
process.exitCode = 1;
}
+75
View File
@@ -0,0 +1,75 @@
import {describe, expect, test} from 'vitest';
import {parseRenderLocalArgs} from '../scripts/render-local-args.js';
describe('parseRenderLocalArgs', () => {
test('preserves repeated Unicode options and parses render settings', () => {
const parsed = parseRenderLocalArgs([
'--output',
'custom.gif',
'--option',
'Chiều nay uống CraneTea',
'--option',
'Chiều nay uống CraneTea',
'--option',
'Cà phê',
'--winner',
'1',
'--duration',
'7000',
'--hold',
'1500',
'--fps',
'20',
'--size',
'480',
'--theme',
'festival',
]);
expect(parsed.output).toBe('custom.gif');
expect(parsed.request).toEqual({
options: ['Chiều nay uống CraneTea', 'Chiều nay uống CraneTea', 'Cà phê'],
winnerIndex: 1,
durationMs: 7000,
holdMs: 1500,
fps: 20,
size: 480,
theme: 'festival',
});
});
test('uses render defaults', () => {
const parsed = parseRenderLocalArgs([
'--option',
'alpha',
'--option',
'beta',
'--winner',
'0',
]);
expect(parsed.output).toBe('wheel.gif');
expect(parsed.timeoutInMilliseconds).toBe(30000);
expect(parsed.request).toMatchObject({
options: ['alpha', 'beta'],
winnerIndex: 0,
durationMs: 6500,
holdMs: 1200,
fps: 15,
size: 512,
theme: 'classic',
});
});
test('rejects missing options and invalid integer flags', () => {
expect(() => parseRenderLocalArgs(['--option', 'alpha'])).toThrow();
expect(() =>
parseRenderLocalArgs(['--option', 'alpha', '--option', 'beta', '--winner', '1.5']),
).toThrow('--winner must be an integer');
});
test('returns help without requiring options', () => {
expect(parseRenderLocalArgs(['--help'])).toMatchObject({help: true, request: undefined});
});
});