diff --git a/renderer/README.md b/renderer/README.md index 74987f3..bcf68a4 100644 --- a/renderer/README.md +++ b/renderer/README.md @@ -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 "` 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 diff --git a/renderer/docs/journals/260715-1510-direct-local-gif-rendering.md b/renderer/docs/journals/260715-1510-direct-local-gif-rendering.md new file mode 100644 index 0000000..05137fc --- /dev/null +++ b/renderer/docs/journals/260715-1510-direct-local-gif-rendering.md @@ -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. diff --git a/renderer/package.json b/renderer/package.json index fcd4518..34acab0 100644 --- a/renderer/package.json +++ b/renderer/package.json @@ -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" }, diff --git a/renderer/scripts/render-local-args.js b/renderer/scripts/render-local-args.js new file mode 100644 index 0000000..321b36d --- /dev/null +++ b/renderer/scripts/render-local-args.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} */ + 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, + }; +}; diff --git a/renderer/scripts/render-local.js b/renderer/scripts/render-local.js new file mode 100644 index 0000000..b56092c --- /dev/null +++ b/renderer/scripts/render-local.js @@ -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 --option [options] + +Options: + -o, --output Output file (default: wheel.gif) + --option Wheel option; repeat at least twice + --winner Zero-based winner index (random when omitted) + --duration Spin duration, 3000-10000 (default: 6500) + --hold Winner hold, 500-2500 (default: 1200) + --fps 12, 15, or 20 (default: 15) + --size 384, 480, or 512 (default: 512) + --theme classic, festival, or mono + --timeout 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; +} + diff --git a/renderer/test/render-local-args.test.js b/renderer/test/render-local-args.test.js new file mode 100644 index 0000000..18cc2df --- /dev/null +++ b/renderer/test/render-local-args.test.js @@ -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}); + }); +}); +