mirror of
https://github.com/tiennm99/tiennm99bot.git
synced 2026-10-11 03:13:46 +00:00
feat(renderer): add local GIF render CLI
This commit is contained in:
1 parent
963e8087a8
commit
566b06a461
6 files changed
+258
-21
No files matched your search
+17
-21
@@ -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.
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user