feat: Phase 42 — Wallpapers, Social, Format Conversion & Voice (12 platforms, convert pipeline, offline badge)
This commit is contained in:
parent
fc55990fde
commit
0956c31384
40 changed files with 5430 additions and 55 deletions
|
|
@ -46,10 +46,10 @@ Requirements for Content Generation milestone. Each maps to roadmap phases.
|
|||
|
||||
### Wallpapers & Visual Assets
|
||||
|
||||
- [ ] **WALL-01**: User can generate desktop and mobile wallpapers from a description
|
||||
- [ ] **WALL-02**: User can generate social media banners with correct dimensions per platform
|
||||
- [ ] **WALL-03**: User can generate Open Graph and social preview images
|
||||
- [ ] **WALL-04**: User can generate app icons and favicons in multiple sizes
|
||||
- [x] **WALL-01**: User can generate desktop and mobile wallpapers from a description
|
||||
- [x] **WALL-02**: User can generate social media banners with correct dimensions per platform
|
||||
- [x] **WALL-03**: User can generate Open Graph and social preview images
|
||||
- [x] **WALL-04**: User can generate app icons and favicons in multiple sizes
|
||||
|
||||
### Presentations & Video
|
||||
|
||||
|
|
@ -60,9 +60,9 @@ Requirements for Content Generation milestone. Each maps to roadmap phases.
|
|||
|
||||
### Social Media Content
|
||||
|
||||
- [ ] **SOCIAL-01**: User can generate platform-ready posts respecting character limits (Twitter, LinkedIn)
|
||||
- [ ] **SOCIAL-02**: User can generate Instagram carousels and thread sequences
|
||||
- [ ] **SOCIAL-03**: System suggests relevant hashtags for generated content
|
||||
- [x] **SOCIAL-01**: User can generate platform-ready posts respecting character limits (Twitter, LinkedIn)
|
||||
- [x] **SOCIAL-02**: User can generate Instagram carousels and thread sequences
|
||||
- [x] **SOCIAL-03**: System suggests relevant hashtags for generated content
|
||||
|
||||
### Branding Media Kit
|
||||
|
||||
|
|
@ -75,21 +75,21 @@ Requirements for Content Generation milestone. Each maps to roadmap phases.
|
|||
|
||||
### Format Conversion
|
||||
|
||||
- [ ] **CONV-01**: User can convert between image formats (PNG, JPG, SVG, WebP, GIF) via sharp
|
||||
- [ ] **CONV-02**: User can convert between audio/video formats via ffmpeg
|
||||
- [ ] **CONV-03**: User can convert between document formats (Markdown, HTML, PDF, DOCX) via Pandoc/LibreOffice
|
||||
- [ ] **CONV-04**: User can convert between data formats (CSV, JSON, XLSX) via direct tooling
|
||||
- [ ] **CONV-05**: User can convert between any format pair via AI-bridged conversion for semantically complex transforms
|
||||
- [ ] **CONV-06**: System provides a conversion UI with source/target format selection and drag-drop input
|
||||
- [ ] **CONV-07**: User can deep-link to specific conversion flows via URL (e.g. `/convert/png/svg`)
|
||||
- [ ] **CONV-08**: System detects available direct converters at startup and degrades gracefully — unavailable direct paths fall through to AI-bridged conversion rather than showing as blocked
|
||||
- [ ] **CONV-09**: System validates uploaded file MIME type via magic-byte detection before processing
|
||||
- [x] **CONV-01**: User can convert between image formats (PNG, JPG, SVG, WebP, GIF) via sharp
|
||||
- [x] **CONV-02**: User can convert between audio/video formats via ffmpeg
|
||||
- [x] **CONV-03**: User can convert between document formats (Markdown, HTML, PDF, DOCX) via Pandoc/LibreOffice
|
||||
- [x] **CONV-04**: User can convert between data formats (CSV, JSON, XLSX) via direct tooling
|
||||
- [x] **CONV-05**: User can convert between any format pair via AI-bridged conversion for semantically complex transforms
|
||||
- [x] **CONV-06**: System provides a conversion UI with source/target format selection and drag-drop input
|
||||
- [x] **CONV-07**: User can deep-link to specific conversion flows via URL (e.g. `/convert/png/svg`)
|
||||
- [x] **CONV-08**: System detects available direct converters at startup and degrades gracefully — unavailable direct paths fall through to AI-bridged conversion rather than showing as blocked
|
||||
- [x] **CONV-09**: System validates uploaded file MIME type via magic-byte detection before processing
|
||||
|
||||
### Whisper Web Chat
|
||||
|
||||
- [ ] **VOICE-01**: User can click a mic button in web chat to record and auto-transcribe via Whisper
|
||||
- [ ] **VOICE-02**: User can toggle between text-only, voice-input, and full-voice modes
|
||||
- [ ] **VOICE-03**: Voice input works offline with local Whisper model
|
||||
- [x] **VOICE-01**: User can click a mic button in web chat to record and auto-transcribe via Whisper
|
||||
- [x] **VOICE-02**: User can toggle between text-only, voice-input, and full-voice modes
|
||||
- [x] **VOICE-03**: Voice input works offline with local Whisper model
|
||||
|
||||
### Content as Skills
|
||||
|
||||
|
|
@ -147,25 +147,25 @@ Which phases cover which requirements. Updated during roadmap creation.
|
|||
| ICON-01 | Phase 41 | Complete |
|
||||
| ICON-02 | Phase 41 | Complete |
|
||||
| ICON-03 | Phase 41 | Complete |
|
||||
| WALL-01 | Phase 42 | Pending |
|
||||
| WALL-02 | Phase 42 | Pending |
|
||||
| WALL-03 | Phase 42 | Pending |
|
||||
| WALL-04 | Phase 42 | Pending |
|
||||
| SOCIAL-01 | Phase 42 | Pending |
|
||||
| SOCIAL-02 | Phase 42 | Pending |
|
||||
| SOCIAL-03 | Phase 42 | Pending |
|
||||
| CONV-01 | Phase 42 | Pending |
|
||||
| CONV-02 | Phase 42 | Pending |
|
||||
| CONV-03 | Phase 42 | Pending |
|
||||
| CONV-04 | Phase 42 | Pending |
|
||||
| CONV-05 | Phase 42 | Pending |
|
||||
| CONV-06 | Phase 42 | Pending |
|
||||
| CONV-07 | Phase 42 | Pending |
|
||||
| CONV-08 | Phase 42 | Pending |
|
||||
| CONV-09 | Phase 42 | Pending |
|
||||
| VOICE-01 | Phase 42 | Pending |
|
||||
| VOICE-02 | Phase 42 | Pending |
|
||||
| VOICE-03 | Phase 42 | Pending |
|
||||
| WALL-01 | Phase 42 | Complete |
|
||||
| WALL-02 | Phase 42 | Complete |
|
||||
| WALL-03 | Phase 42 | Complete |
|
||||
| WALL-04 | Phase 42 | Complete |
|
||||
| SOCIAL-01 | Phase 42 | Complete |
|
||||
| SOCIAL-02 | Phase 42 | Complete |
|
||||
| SOCIAL-03 | Phase 42 | Complete |
|
||||
| CONV-01 | Phase 42 | Complete |
|
||||
| CONV-02 | Phase 42 | Complete |
|
||||
| CONV-03 | Phase 42 | Complete |
|
||||
| CONV-04 | Phase 42 | Complete |
|
||||
| CONV-05 | Phase 42 | Complete |
|
||||
| CONV-06 | Phase 42 | Complete |
|
||||
| CONV-07 | Phase 42 | Complete |
|
||||
| CONV-08 | Phase 42 | Complete |
|
||||
| CONV-09 | Phase 42 | Complete |
|
||||
| VOICE-01 | Phase 42 | Complete |
|
||||
| VOICE-02 | Phase 42 | Complete |
|
||||
| VOICE-03 | Phase 42 | Complete |
|
||||
| DOC-01 | Phase 43 | Pending |
|
||||
| DOC-02 | Phase 43 | Pending |
|
||||
| DOC-03 | Phase 43 | Pending |
|
||||
|
|
|
|||
|
|
@ -172,7 +172,7 @@ Plans:
|
|||
|
||||
- [x] **Phase 40: Job Infrastructure** — content_jobs table, async render lifecycle, SSE progress events, namespaced storage without size limit (INFRA-01..04) (completed 2026-04-04)
|
||||
- [x] **Phase 41: Diagrams, Icons & Theme Engine** — Mermaid diagrams, SVG icon generation, OKLCH theme palette with WCAG AA and live preview (DIAG-01..05, ICON-01..03, THEME-01..07) (completed 2026-04-04)
|
||||
- [ ] **Phase 42: Wallpapers, Social, Format Conversion & Voice** — Satori image pipeline, social content, format conversion registry with AI fallback, Whisper web chat mic (WALL-01..04, SOCIAL-01..03, CONV-01..09, VOICE-01..03)
|
||||
- [x] **Phase 42: Wallpapers, Social, Format Conversion & Voice** — LLM SVG + sharp wallpapers, social content, format conversion registry with AI fallback, Whisper web chat mic (WALL-01..04, SOCIAL-01..03, CONV-01..09, VOICE-01..03) (completed 2026-04-04)
|
||||
- [ ] **Phase 43: Documents & Branding** — Playwright PDF reports and invoices, full brand identity kit with zip export (DOC-01..03, BRAND-01..06)
|
||||
- [ ] **Phase 44: Video & Presentations** — Remotion workspace package, pitch decks and demo videos, SSE render progress (PRES-01..04)
|
||||
- [ ] **Phase 45: Content as Skills** — Markdown skill files for all content types, Creative skill group on generalist agent (SKILL-01..03)
|
||||
|
|
@ -216,16 +216,24 @@ Plans:
|
|||
**UI hint**: yes
|
||||
|
||||
### Phase 42: Wallpapers, Social, Format Conversion & Voice
|
||||
**Goal**: Users can generate platform-ready images (wallpapers, OG images, social banners) via the Satori pipeline, convert between any file format pair, and record voice directly in web chat via the Whisper mic button
|
||||
**Goal**: Users can generate platform-ready images (wallpapers, OG images, social banners) via LLM SVG + sharp rasterization, convert between any file format pair, and record voice directly in web chat via the Whisper mic button
|
||||
**Depends on**: Phase 40
|
||||
**Requirements**: WALL-01, WALL-02, WALL-03, WALL-04, SOCIAL-01, SOCIAL-02, SOCIAL-03, CONV-01, CONV-02, CONV-03, CONV-04, CONV-05, CONV-06, CONV-07, CONV-08, CONV-09, VOICE-01, VOICE-02, VOICE-03
|
||||
**Success Criteria** (what must be TRUE):
|
||||
1. Requesting a desktop wallpaper returns a 2560×1440 PNG; requesting an Instagram banner returns a correctly-dimensioned image — platform dimensions are constants, not magic numbers
|
||||
1. Requesting a desktop wallpaper returns a 2560x1440 PNG; requesting an Instagram banner returns a correctly-dimensioned image — platform dimensions are constants, not magic numbers
|
||||
2. The format conversion UI allows drag-drop of a source file, selection of a target format, and download of the converted file; direct conversion pairs (image, audio/video, document, data) use native tools; any unsupported pair falls through to AI-bridged conversion rather than showing as unavailable
|
||||
3. Navigating to `/convert/png/svg` deep-links directly to the PNG→SVG conversion flow with source and target pre-selected
|
||||
3. Navigating to `/convert/png/svg` deep-links directly to the PNG->SVG conversion flow with source and target pre-selected
|
||||
4. An uploaded file is validated against its magic bytes before processing — a JPEG renamed to `.png` is rejected with a clear error, not silently misprocessed
|
||||
5. Clicking the mic button in web chat records audio, transcribes it via local Whisper, and populates the chat input — works offline with the locally cached model
|
||||
**Plans**: TBD
|
||||
**Plans**: 6 plans
|
||||
|
||||
Plans:
|
||||
- [x] 42-01-PLAN.md — Dependencies, bundle types, job-runner switch, converter capabilities probe
|
||||
- [x] 42-02-PLAN.md — Wallpaper renderer (LLM SVG + sharp) and social post renderer (LLM JSON + hashtags)
|
||||
- [x] 42-03-PLAN.md — Convert renderer (sharp/ffmpeg/xlsx/AI-bridge) and multipart upload route with MIME validation
|
||||
- [x] 42-04-PLAN.md — Voice offline badge wiring (useSystemProviders hook + ChatInput badge)
|
||||
- [x] 42-05-PLAN.md — Wallpaper/Social UI panels + ContentStudio tab extensions
|
||||
- [x] 42-06-PLAN.md — Format conversion UI page with drag-drop, format chips, deep-link routing
|
||||
**UI hint**: yes
|
||||
|
||||
### Phase 43: Documents & Branding
|
||||
|
|
@ -353,7 +361,7 @@ All 52 v1.7 requirements are mapped to exactly one phase. No orphans.
|
|||
| 39. Voice Polish | v1.6 | 1/2 | Complete | 2026-04-04 |
|
||||
| 40. Job Infrastructure | v1.7 | 2/2 | Complete | 2026-04-04 |
|
||||
| 41. Diagrams, Icons & Theme Engine | v1.7 | 6/6 | Complete | 2026-04-04 |
|
||||
| 42. Wallpapers, Social, Format Conversion & Voice | v1.7 | 0/TBD | Not started | - |
|
||||
| 42. Wallpapers, Social, Format Conversion & Voice | v1.7 | 6/6 | Complete | 2026-04-04 |
|
||||
| 43. Documents & Branding | v1.7 | 0/TBD | Not started | - |
|
||||
| 44. Video & Presentations | v1.7 | 0/TBD | Not started | - |
|
||||
| 45. Content as Skills | v1.7 | 0/TBD | Not started | - |
|
||||
|
|
|
|||
|
|
@ -3,14 +3,14 @@ gsd_state_version: 1.0
|
|||
milestone: v1.7
|
||||
milestone_name: Content Generation
|
||||
status: verifying
|
||||
stopped_at: "Completed 41-06-PLAN.md — verification: 30 server + 13 UI tests pass, THEME_META regression fixed, phase ready"
|
||||
last_updated: "2026-04-04T21:34:36.434Z"
|
||||
stopped_at: Completed 42-05-PLAN.md — Wallpaper and Social UI panels, ContentStudio extended to 5 tabs
|
||||
last_updated: "2026-04-04T22:23:10.096Z"
|
||||
last_activity: 2026-04-04
|
||||
progress:
|
||||
total_phases: 6
|
||||
completed_phases: 2
|
||||
total_plans: 8
|
||||
completed_plans: 8
|
||||
completed_phases: 3
|
||||
total_plans: 14
|
||||
completed_plans: 14
|
||||
percent: 0
|
||||
---
|
||||
|
||||
|
|
@ -21,11 +21,11 @@ progress:
|
|||
See: .planning/PROJECT.md (updated 2026-04-04)
|
||||
|
||||
**Core value:** A fresh onboard asks for ONE thing (root directory), auto-creates PM + Engineer agents, and drops you in the dashboard.
|
||||
**Current focus:** Phase 41 — diagrams-icons-theme-engine
|
||||
**Current focus:** Phase 42 — wallpapers-social-format-conversion-voice
|
||||
|
||||
## Current Position
|
||||
|
||||
Phase: 42
|
||||
Phase: 43
|
||||
Plan: Not started
|
||||
Status: Phase complete — ready for verification
|
||||
Last activity: 2026-04-04
|
||||
|
|
@ -75,6 +75,17 @@ Key constraints for v1.7:
|
|||
- [Phase 41]: @testing-library/react + jsdom added as UI devDeps — renderToStaticMarkup cannot test imperative DOM style.setProperty calls required by THEME-04
|
||||
- [Phase 41]: ThemePreviewPanel scopes CSS vars to .nexus-theme-preview container ref; applyCustomTheme() sets on document.documentElement — two distinct patterns for preview vs global apply
|
||||
- [Phase 41]: THEME_META/ORDERED_THEMES re-added to ThemeContext as backward-compat exports for light/dark/custom — Phase 41-05 worktree commit dropped these, breaking Layout.tsx/MarkdownBody.tsx/InstanceGeneralSettings.tsx
|
||||
- [Phase 42-wallpapers-social-format-conversion-voice]: execFileNoThrow utility created as server/src/utils/ helper — plan referenced as 'project standard' but did not exist; created as missing critical functionality
|
||||
- [Phase 42-wallpapers-social-format-conversion-voice]: Stub renderer pattern: exports throw 'Not implemented' to satisfy tsc module resolution — Plans 02-04 replace with real implementations
|
||||
- [Phase 42-wallpapers-social-format-conversion-voice]: useSystemProviders uses plain useState+useEffect fetch (not React Query) for a lightweight one-time capability probe
|
||||
- [Phase 42-wallpapers-social-format-conversion-voice]: [Phase 42-wallpapers-social-format-conversion-voice]: Offline badge double guard: enableVoiceInput && providers?.whisperAvailable — shows only in voice mode with confirmed local Whisper binary
|
||||
- [Phase 42-wallpapers-social-format-conversion-voice]: PLATFORM_DIMENSIONS exported as named constant (12 platforms); sharp density:300 for SVG rasterization; PLATFORM_CHAR_LIMITS with 4 platforms; carousel returns slides[] in SocialPostBundle
|
||||
- [Phase 42-wallpapers-social-format-conversion-voice]: ffmpegPath cast as unknown as string for ffmpeg-static spawn — typings declare string|null but binary always present when package installed
|
||||
- [Phase 42-wallpapers-social-format-conversion-voice]: TEXT_BASED_EXTENSIONS allowlist prevents false 422 rejections for formats with no magic bytes (CSV/JSON/SVG)
|
||||
- [Phase 42-wallpapers-social-format-conversion-voice]: Direct fetch used in submitConvertJob to inspect 422 MIME error vs 202 success before throwing — api.request() throws on !res.ok but 422 is a valid business response
|
||||
- [Phase 42-wallpapers-social-format-conversion-voice]: Direct EventSource for pre-submitted convert jobs — useContentJob.submit() cannot track an existing jobId without re-submitting via contentJobs route
|
||||
- [Phase 42-wallpapers-social-format-conversion-voice]: FORMAT_GROUPS exported from ConvertPanel so ConvertPage can import for allowlist validation without duplicating the list
|
||||
- [Phase 42-wallpapers-social-format-conversion-voice]: WallpaperBundle/AppIconBundle/SocialPostBundle types defined locally in panel component files — no content-bundles.ts addition needed since these types are only consumed by their respective components
|
||||
|
||||
### Pending Todos
|
||||
|
||||
|
|
@ -89,6 +100,6 @@ None yet.
|
|||
|
||||
## Session Continuity
|
||||
|
||||
Last session: 2026-04-04T21:27:23.222Z
|
||||
Stopped at: Completed 41-06-PLAN.md — verification: 30 server + 13 UI tests pass, THEME_META regression fixed, phase ready
|
||||
Last session: 2026-04-04T22:22:05.529Z
|
||||
Stopped at: Completed 42-05-PLAN.md — Wallpaper and Social UI panels, ContentStudio extended to 5 tabs
|
||||
Resume file: None
|
||||
|
|
|
|||
|
|
@ -0,0 +1,235 @@
|
|||
---
|
||||
phase: 42-wallpapers-social-format-conversion-voice
|
||||
plan: 01
|
||||
type: execute
|
||||
wave: 1
|
||||
depends_on: []
|
||||
files_modified:
|
||||
- server/src/services/renderers/types.ts
|
||||
- server/src/services/content-job-runner.ts
|
||||
- server/src/services/converter-capabilities.ts
|
||||
- server/src/services/renderers/wallpaper-renderer.ts
|
||||
- server/src/services/renderers/social-renderer.ts
|
||||
- server/src/services/renderers/convert-renderer.ts
|
||||
- server/package.json
|
||||
autonomous: true
|
||||
requirements: [CONV-08]
|
||||
must_haves:
|
||||
truths:
|
||||
- "New npm dependencies (file-type, xlsx, csv-parse) are installed and importable"
|
||||
- "Bundle types (WallpaperBundle, SocialPostBundle, ConvertBundle) exist with correct shapes"
|
||||
- "content-job-runner switch handles wallpaper, social-post, and convert job types"
|
||||
- "Converter capabilities service probes pandoc/libreoffice at startup and caches result"
|
||||
artifacts:
|
||||
- path: "server/src/services/renderers/types.ts"
|
||||
provides: "WallpaperBundle, SocialPostBundle, ConvertBundle type definitions"
|
||||
contains: "WallpaperBundle"
|
||||
- path: "server/src/services/content-job-runner.ts"
|
||||
provides: "wallpaper, social-post, convert cases in renderContent switch"
|
||||
contains: "case \"wallpaper\""
|
||||
- path: "server/src/services/converter-capabilities.ts"
|
||||
provides: "Startup probe for pandoc/libreoffice, cached capability map"
|
||||
contains: "converterCapabilitiesService"
|
||||
key_links:
|
||||
- from: "server/src/services/content-job-runner.ts"
|
||||
to: "server/src/services/renderers/wallpaper-renderer.ts"
|
||||
via: "dynamic import in case wallpaper"
|
||||
pattern: "wallpaper-renderer"
|
||||
- from: "server/src/services/content-job-runner.ts"
|
||||
to: "server/src/services/renderers/social-renderer.ts"
|
||||
via: "dynamic import in case social-post"
|
||||
pattern: "social-renderer"
|
||||
- from: "server/src/services/content-job-runner.ts"
|
||||
to: "server/src/services/renderers/convert-renderer.ts"
|
||||
via: "dynamic import in case convert"
|
||||
pattern: "convert-renderer"
|
||||
---
|
||||
|
||||
<objective>
|
||||
Install Phase 42 dependencies, define shared bundle types, wire content-job-runner switch for three new job types, and create the converter capabilities probe service.
|
||||
|
||||
Purpose: Every subsequent plan depends on these types, job runner cases, and dependency availability. This is the foundation layer.
|
||||
Output: Updated types.ts, content-job-runner.ts, new converter-capabilities.ts, installed packages.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
|
||||
@$HOME/.claude/get-shit-done/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/PROJECT.md
|
||||
@.planning/ROADMAP.md
|
||||
@.planning/STATE.md
|
||||
@.planning/phases/42-wallpapers-social-format-conversion-voice/42-RESEARCH.md
|
||||
|
||||
@server/src/services/renderers/types.ts
|
||||
@server/src/services/content-job-runner.ts
|
||||
@server/package.json
|
||||
</context>
|
||||
|
||||
<interfaces>
|
||||
<!-- Key types and contracts the executor needs -->
|
||||
From server/src/services/renderers/types.ts:
|
||||
```typescript
|
||||
export interface RenderResult {
|
||||
filename: string;
|
||||
contentType: string;
|
||||
buffer: Buffer;
|
||||
}
|
||||
export type ContentBundle = DiagramBundle | IconSetBundle | ThemePaletteBundle;
|
||||
```
|
||||
|
||||
From server/src/services/content-job-runner.ts:
|
||||
```typescript
|
||||
export async function renderContent(jobType: string, input: Record<string, unknown>): Promise<RenderResult> {
|
||||
switch (jobType) {
|
||||
case "diagram": { ... }
|
||||
case "icon-set": { ... }
|
||||
case "theme-palette": { ... }
|
||||
}
|
||||
}
|
||||
```
|
||||
</interfaces>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 1: Install dependencies and define bundle types</name>
|
||||
<files>server/package.json, server/src/services/renderers/types.ts</files>
|
||||
<read_first>server/src/services/renderers/types.ts, server/package.json</read_first>
|
||||
<action>
|
||||
1. Install new dependencies in server/:
|
||||
```bash
|
||||
cd /opt/nexus/server && pnpm add file-type@22.0.0 xlsx@0.18.5 csv-parse@6.2.1
|
||||
```
|
||||
Do NOT install @types/xlsx — xlsx ships its own types.
|
||||
|
||||
2. Add these new bundle types to server/src/services/renderers/types.ts AFTER the existing ThemePaletteBundle:
|
||||
|
||||
```typescript
|
||||
export interface WallpaperBundle {
|
||||
type: "wallpaper-bundle";
|
||||
platform: string;
|
||||
width: number;
|
||||
height: number;
|
||||
pngBase64: string;
|
||||
prompt: string;
|
||||
}
|
||||
|
||||
export interface AppIconBundle {
|
||||
type: "app-icon-bundle";
|
||||
sizes: Array<{ size: number; pngBase64: string }>;
|
||||
prompt: string;
|
||||
}
|
||||
|
||||
export interface SocialPostBundle {
|
||||
type: "social-post-bundle";
|
||||
platform: string;
|
||||
post: string;
|
||||
hashtags: string[];
|
||||
slides?: string[];
|
||||
charLimit: number;
|
||||
}
|
||||
|
||||
export interface ConvertBundle {
|
||||
type: "convert-bundle";
|
||||
outputFilename: string;
|
||||
outputMime: string;
|
||||
outputBase64: string;
|
||||
method: "direct" | "ai-bridge";
|
||||
}
|
||||
```
|
||||
|
||||
3. Update the ContentBundle union type to include the new bundles:
|
||||
```typescript
|
||||
export type ContentBundle = DiagramBundle | IconSetBundle | ThemePaletteBundle | WallpaperBundle | AppIconBundle | SocialPostBundle | ConvertBundle;
|
||||
```
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /opt/nexus/server && npx tsc --noEmit 2>&1 | head -20</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- grep "WallpaperBundle" server/src/services/renderers/types.ts
|
||||
- grep "SocialPostBundle" server/src/services/renderers/types.ts
|
||||
- grep "ConvertBundle" server/src/services/renderers/types.ts
|
||||
- grep "AppIconBundle" server/src/services/renderers/types.ts
|
||||
- grep "file-type" server/package.json
|
||||
- grep "xlsx" server/package.json
|
||||
- grep "csv-parse" server/package.json
|
||||
</acceptance_criteria>
|
||||
<done>All four bundle types exported from types.ts. file-type, xlsx, csv-parse installed in server/package.json.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: Wire content-job-runner switch and create converter capabilities service</name>
|
||||
<files>server/src/services/content-job-runner.ts, server/src/services/converter-capabilities.ts, server/src/services/renderers/wallpaper-renderer.ts, server/src/services/renderers/social-renderer.ts, server/src/services/renderers/convert-renderer.ts</files>
|
||||
<read_first>server/src/services/content-job-runner.ts, server/src/utils/execFileNoThrow.ts</read_first>
|
||||
<action>
|
||||
1. Add three new cases to the `renderContent` switch in content-job-runner.ts, following the exact existing pattern (dynamic import of renderer, call render function, return result). For now, create stub renderer imports that will be implemented in Plans 02-04:
|
||||
|
||||
```typescript
|
||||
case "wallpaper": {
|
||||
const { renderWallpaper } = await import("./renderers/wallpaper-renderer.js");
|
||||
return renderWallpaper(input);
|
||||
}
|
||||
case "social-post": {
|
||||
const { renderSocialPost } = await import("./renderers/social-renderer.js");
|
||||
return renderSocialPost(input);
|
||||
}
|
||||
case "convert": {
|
||||
const { renderConvert } = await import("./renderers/convert-renderer.js");
|
||||
return renderConvert(input);
|
||||
}
|
||||
```
|
||||
|
||||
2. Create stub renderer files so tsc resolves the imports (Plans 02-04 replace with real implementations):
|
||||
- server/src/services/renderers/wallpaper-renderer.ts: export async function renderWallpaper that throws "Not implemented"
|
||||
- server/src/services/renderers/social-renderer.ts: export async function renderSocialPost that throws "Not implemented"
|
||||
- server/src/services/renderers/convert-renderer.ts: export async function renderConvert that throws "Not implemented"
|
||||
Each stub must import and return RenderResult type.
|
||||
|
||||
3. Create server/src/services/converter-capabilities.ts implementing the startup probe pattern:
|
||||
- Export interface ConverterCapabilities { imageConverter: boolean; audioVideoConverter: boolean; docConverter: boolean; dataConverter: boolean }
|
||||
- Export function converterCapabilitiesService() with a get() method
|
||||
- Use execFileNoThrow from src/utils/execFileNoThrow.ts (project standard — do NOT use child_process.exec directly) to probe "pandoc" and "libreoffice" with ["--version"] argument
|
||||
- imageConverter, audioVideoConverter, dataConverter always true (npm deps)
|
||||
- docConverter true only if pandoc or libreoffice binary responds with status 0
|
||||
- Cache result after first probe in module-level variable
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /opt/nexus/server && npx tsc --noEmit 2>&1 | head -20</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- grep "case \"wallpaper\"" server/src/services/content-job-runner.ts
|
||||
- grep "case \"social-post\"" server/src/services/content-job-runner.ts
|
||||
- grep "case \"convert\"" server/src/services/content-job-runner.ts
|
||||
- grep "converterCapabilitiesService" server/src/services/converter-capabilities.ts
|
||||
- grep "ConverterCapabilities" server/src/services/converter-capabilities.ts
|
||||
- grep "execFileNoThrow" server/src/services/converter-capabilities.ts
|
||||
- grep "renderWallpaper" server/src/services/renderers/wallpaper-renderer.ts
|
||||
- grep "renderSocialPost" server/src/services/renderers/social-renderer.ts
|
||||
- grep "renderConvert" server/src/services/renderers/convert-renderer.ts
|
||||
</acceptance_criteria>
|
||||
<done>content-job-runner handles wallpaper, social-post, convert job types. Stub renderers satisfy tsc. Converter capabilities service probes and caches binary availability using execFileNoThrow.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<verification>
|
||||
- `cd /opt/nexus/server && npx tsc --noEmit` passes with zero errors
|
||||
- All new types are exported from types.ts
|
||||
- Three new cases exist in content-job-runner switch
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- Three new bundle type interfaces exported from types.ts
|
||||
- Three new job type cases in content-job-runner.ts switch
|
||||
- Stub renderer files exist for wallpaper, social, convert
|
||||
- converter-capabilities.ts probes pandoc/libreoffice using execFileNoThrow and caches
|
||||
- tsc compiles cleanly
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
After completion, create `.planning/phases/42-wallpapers-social-format-conversion-voice/42-01-SUMMARY.md`
|
||||
</output>
|
||||
|
|
@ -0,0 +1,135 @@
|
|||
---
|
||||
phase: 42-wallpapers-social-format-conversion-voice
|
||||
plan: 01
|
||||
subsystem: api
|
||||
tags: [file-type, xlsx, csv-parse, pandoc, libreoffice, content-jobs, renderers, typescript]
|
||||
|
||||
# Dependency graph
|
||||
requires:
|
||||
- phase: 40-content-job-infra
|
||||
provides: content_jobs table, renderContent switch stub, async job pattern
|
||||
provides:
|
||||
- WallpaperBundle, AppIconBundle, SocialPostBundle, ConvertBundle type definitions in types.ts
|
||||
- wallpaper, social-post, convert cases in renderContent switch
|
||||
- converter-capabilities.ts startup probe for pandoc/libreoffice with cached result
|
||||
- execFileNoThrow utility for safe binary execution
|
||||
- Stub renderers for wallpaper, social-post, convert (Plans 02-04 replace)
|
||||
affects:
|
||||
- 42-02 (wallpaper renderer uses WallpaperBundle type)
|
||||
- 42-03 (social renderer uses SocialPostBundle type)
|
||||
- 42-04 (convert renderer uses ConvertBundle type, converter-capabilities)
|
||||
- 42-05 (voice renderer)
|
||||
- 42-06 (UI wiring)
|
||||
|
||||
# Tech tracking
|
||||
tech-stack:
|
||||
added: [file-type@22.0.0, xlsx@0.18.5, csv-parse@6.2.1]
|
||||
patterns:
|
||||
- execFileNoThrow pattern for safe binary probing (no throw on exit code !=0)
|
||||
- Startup probe with module-level cache for binary availability
|
||||
|
||||
key-files:
|
||||
created:
|
||||
- server/src/services/converter-capabilities.ts
|
||||
- server/src/services/renderers/wallpaper-renderer.ts
|
||||
- server/src/services/renderers/social-renderer.ts
|
||||
- server/src/services/renderers/convert-renderer.ts
|
||||
- server/src/utils/execFileNoThrow.ts
|
||||
modified:
|
||||
- server/src/services/renderers/types.ts
|
||||
- server/src/services/content-job-runner.ts
|
||||
- server/package.json
|
||||
- pnpm-lock.yaml
|
||||
|
||||
key-decisions:
|
||||
- "execFileNoThrow utility created as new server/src/utils/ helper — plan referenced it as 'project standard' but it did not exist yet; created as Rule 2 (missing critical functionality)"
|
||||
- "Stub renderers import and return RenderResult type to satisfy tsc module resolution — Plans 02-04 replace with real implementations"
|
||||
- "converter-capabilities.ts caches capabilities in module-level variable after first probe"
|
||||
|
||||
patterns-established:
|
||||
- "execFileNoThrow: safe binary probe returning status 0|1, never throwing — use for all optional binary checks"
|
||||
- "Startup probe pattern: probe() called lazily on first get(), result cached in module-level variable"
|
||||
- "Stub renderer pattern: export async function that throws 'Not implemented', satisfies tsc"
|
||||
|
||||
requirements-completed: [CONV-08]
|
||||
|
||||
# Metrics
|
||||
duration: 3min
|
||||
completed: 2026-04-04
|
||||
---
|
||||
|
||||
# Phase 42 Plan 01: Foundation — Bundle Types, Job Runner Switch, Converter Capabilities
|
||||
|
||||
**file-type/xlsx/csv-parse installed, four bundle types added to types.ts, wallpaper/social-post/convert cases wired in content-job-runner, and converter-capabilities service probing pandoc/libreoffice via new execFileNoThrow utility**
|
||||
|
||||
## Performance
|
||||
|
||||
- **Duration:** 3 min
|
||||
- **Started:** 2026-04-04T22:06:42Z
|
||||
- **Completed:** 2026-04-04T22:09:28Z
|
||||
- **Tasks:** 2
|
||||
- **Files modified:** 9 (3 modified, 5 created, 1 lockfile)
|
||||
|
||||
## Accomplishments
|
||||
- Installed file-type@22.0.0, xlsx@0.18.5, csv-parse@6.2.1 in server/ workspace
|
||||
- Added WallpaperBundle, AppIconBundle, SocialPostBundle, ConvertBundle interfaces to types.ts; updated ContentBundle union
|
||||
- Wired wallpaper, social-post, convert cases in renderContent switch with dynamic imports of stub renderers
|
||||
- Created converter-capabilities.ts probing pandoc/libreoffice at startup, caching result for all subsequent calls
|
||||
- Created execFileNoThrow utility for safe binary probing without exceptions
|
||||
|
||||
## Task Commits
|
||||
|
||||
Each task was committed atomically:
|
||||
|
||||
1. **Task 1: Install dependencies and define bundle types** - `e809b706` (feat)
|
||||
2. **Task 2: Wire content-job-runner switch and create converter capabilities service** - `3ba09889` (feat)
|
||||
|
||||
**Plan metadata:** (docs commit — see below)
|
||||
|
||||
## Files Created/Modified
|
||||
- `server/src/services/renderers/types.ts` - Added WallpaperBundle, AppIconBundle, SocialPostBundle, ConvertBundle; updated ContentBundle union
|
||||
- `server/src/services/content-job-runner.ts` - Added wallpaper, social-post, convert cases to renderContent switch
|
||||
- `server/src/services/converter-capabilities.ts` - Startup probe for pandoc/libreoffice, cached capability map
|
||||
- `server/src/utils/execFileNoThrow.ts` - Safe binary execution utility (no throw on non-zero exit)
|
||||
- `server/src/services/renderers/wallpaper-renderer.ts` - Stub renderer (throws "Not implemented")
|
||||
- `server/src/services/renderers/social-renderer.ts` - Stub renderer (throws "Not implemented")
|
||||
- `server/src/services/renderers/convert-renderer.ts` - Stub renderer (throws "Not implemented")
|
||||
- `server/package.json` - Added file-type, xlsx, csv-parse dependencies
|
||||
- `pnpm-lock.yaml` - Updated lockfile
|
||||
|
||||
## Decisions Made
|
||||
- Created `execFileNoThrow` utility in `server/src/utils/` — the plan referenced it as "project standard" but it did not exist. Created it as a Rule 2 auto-fix (missing critical functionality for the converter-capabilities probe).
|
||||
- Stub renderers import `RenderResult` type but use `_input` naming convention for unused parameter to satisfy TypeScript strict mode.
|
||||
|
||||
## Deviations from Plan
|
||||
|
||||
### Auto-fixed Issues
|
||||
|
||||
**1. [Rule 2 - Missing Critical] Created execFileNoThrow utility**
|
||||
- **Found during:** Task 2 (converter capabilities service)
|
||||
- **Issue:** Plan referenced `src/utils/execFileNoThrow.ts` as "project standard" but the file did not exist in the codebase
|
||||
- **Fix:** Created `server/src/utils/execFileNoThrow.ts` implementing promisify(execFile) wrapper that returns `{ stdout, stderr, status: 0|1 }` without throwing
|
||||
- **Files modified:** server/src/utils/execFileNoThrow.ts (created)
|
||||
- **Verification:** tsc --noEmit passes with zero errors; converter-capabilities.ts imports and uses it correctly
|
||||
- **Committed in:** 3ba09889 (Task 2 commit)
|
||||
|
||||
---
|
||||
|
||||
**Total deviations:** 1 auto-fixed (1 missing critical)
|
||||
**Impact on plan:** Auto-fix necessary for the converter-capabilities probe to compile and run. No scope creep.
|
||||
|
||||
## Issues Encountered
|
||||
None beyond the missing execFileNoThrow utility (handled above).
|
||||
|
||||
## User Setup Required
|
||||
None - no external service configuration required.
|
||||
|
||||
## Next Phase Readiness
|
||||
- All foundation types available for Plans 02-04
|
||||
- content-job-runner dispatches wallpaper/social-post/convert jobs to stub renderers
|
||||
- Plans 02 (wallpaper), 03 (social), 04 (convert) can now proceed in parallel
|
||||
- execFileNoThrow utility available for any future binary-probe needs
|
||||
|
||||
---
|
||||
*Phase: 42-wallpapers-social-format-conversion-voice*
|
||||
*Completed: 2026-04-04*
|
||||
|
|
@ -0,0 +1,205 @@
|
|||
---
|
||||
phase: 42-wallpapers-social-format-conversion-voice
|
||||
plan: 02
|
||||
type: execute
|
||||
wave: 2
|
||||
depends_on: [42-01]
|
||||
files_modified:
|
||||
- server/src/services/renderers/wallpaper-renderer.ts
|
||||
- server/src/services/renderers/social-renderer.ts
|
||||
autonomous: true
|
||||
requirements: [WALL-01, WALL-02, WALL-03, WALL-04, SOCIAL-01, SOCIAL-02, SOCIAL-03]
|
||||
must_haves:
|
||||
truths:
|
||||
- "Wallpaper renderer generates SVG via LLM then rasterizes to exact platform dimensions via sharp"
|
||||
- "PLATFORM_DIMENSIONS is an exported constant, not magic numbers anywhere"
|
||||
- "App icon/favicon request produces multi-size bundle (1024, 512, 256, 64, 32)"
|
||||
- "Social renderer generates post text + hashtags as JSON via LLM"
|
||||
- "Instagram carousel returns slides array with per-slide character limit"
|
||||
- "LLM JSON output is parsed with markdown fence stripping"
|
||||
artifacts:
|
||||
- path: "server/src/services/renderers/wallpaper-renderer.ts"
|
||||
provides: "renderWallpaper function + PLATFORM_DIMENSIONS constant"
|
||||
exports: ["renderWallpaper", "PLATFORM_DIMENSIONS"]
|
||||
- path: "server/src/services/renderers/social-renderer.ts"
|
||||
provides: "renderSocialPost function + PLATFORM_CHAR_LIMITS constant"
|
||||
exports: ["renderSocialPost", "PLATFORM_CHAR_LIMITS"]
|
||||
key_links:
|
||||
- from: "server/src/services/renderers/wallpaper-renderer.ts"
|
||||
to: "server/src/services/puter-inference.ts"
|
||||
via: "puterChatComplete for SVG generation"
|
||||
pattern: "puterChatComplete"
|
||||
- from: "server/src/services/renderers/wallpaper-renderer.ts"
|
||||
to: "sharp"
|
||||
via: "SVG to PNG rasterization at target dimensions"
|
||||
pattern: "sharp.*density.*300"
|
||||
- from: "server/src/services/renderers/social-renderer.ts"
|
||||
to: "server/src/services/puter-inference.ts"
|
||||
via: "puterChatComplete for post generation"
|
||||
pattern: "puterChatComplete"
|
||||
---
|
||||
|
||||
<objective>
|
||||
Implement the wallpaper renderer (LLM SVG + sharp rasterization at platform dimensions) and social post renderer (LLM JSON with hashtags and carousel support).
|
||||
|
||||
Purpose: These renderers fulfill all WALL and SOCIAL requirements. The UI panels in Plan 05 depend on these renderers being functional.
|
||||
Output: Two working renderer files replacing the Plan 01 stubs.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
|
||||
@$HOME/.claude/get-shit-done/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/PROJECT.md
|
||||
@.planning/ROADMAP.md
|
||||
@.planning/phases/42-wallpapers-social-format-conversion-voice/42-RESEARCH.md
|
||||
@.planning/phases/42-wallpapers-social-format-conversion-voice/42-01-SUMMARY.md
|
||||
|
||||
@server/src/services/renderers/types.ts
|
||||
@server/src/services/renderers/icon-renderer.ts
|
||||
@server/src/services/puter-inference.ts
|
||||
</context>
|
||||
|
||||
<interfaces>
|
||||
From server/src/services/renderers/types.ts (after Plan 01):
|
||||
```typescript
|
||||
export interface WallpaperBundle {
|
||||
type: "wallpaper-bundle";
|
||||
platform: string;
|
||||
width: number;
|
||||
height: number;
|
||||
pngBase64: string;
|
||||
prompt: string;
|
||||
}
|
||||
export interface AppIconBundle {
|
||||
type: "app-icon-bundle";
|
||||
sizes: Array<{ size: number; pngBase64: string }>;
|
||||
prompt: string;
|
||||
}
|
||||
export interface SocialPostBundle {
|
||||
type: "social-post-bundle";
|
||||
platform: string;
|
||||
post: string;
|
||||
hashtags: string[];
|
||||
slides?: string[];
|
||||
charLimit: number;
|
||||
}
|
||||
```
|
||||
|
||||
From server/src/services/puter-inference.ts:
|
||||
```typescript
|
||||
export async function puterChatComplete(prompt: string, systemPrompt?: string): Promise<string>;
|
||||
```
|
||||
</interfaces>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 1: Implement wallpaper renderer with PLATFORM_DIMENSIONS</name>
|
||||
<files>server/src/services/renderers/wallpaper-renderer.ts</files>
|
||||
<read_first>server/src/services/renderers/icon-renderer.ts, server/src/services/puter-inference.ts, server/src/services/renderers/types.ts</read_first>
|
||||
<action>
|
||||
Replace the stub wallpaper-renderer.ts with a full implementation:
|
||||
|
||||
1. Export `PLATFORM_DIMENSIONS` constant as Record<string, { width: number; height: number; label: string }> with all 12 platform entries from research:
|
||||
- desktop-hd (2560x1440), desktop-fhd (1920x1080), desktop-4k (3840x2160)
|
||||
- mobile-portrait (1080x1920), mobile-landscape (1920x1080)
|
||||
- og-image (1200x630), twitter-card (1200x628), instagram-post (1080x1080), instagram-banner (1080x566), linkedin-banner (1584x396)
|
||||
- app-icon (1024x1024), favicon (32x32)
|
||||
|
||||
2. Export `APP_ICON_SIZES = [1024, 512, 256, 64, 32] as const`.
|
||||
|
||||
3. Export `async function renderWallpaper(input: Record<string, unknown>): Promise<RenderResult>`:
|
||||
- Extract `prompt` (string) and `platform` (string) from input
|
||||
- Look up dimensions from PLATFORM_DIMENSIONS[platform]; throw if not found
|
||||
- Call puterChatComplete with a system prompt instructing the LLM to generate an SVG artwork matching the requested aspect ratio. The system prompt must specify:
|
||||
- Output ONLY valid SVG (no markdown fences, no explanation)
|
||||
- Use viewBox="0 0 {width} {height}" matching target dimensions
|
||||
- Create a visually rich scene based on the user prompt
|
||||
- Use gradients, shapes, patterns — no text elements
|
||||
- Strip markdown fences from response (same pattern as icon-renderer.ts)
|
||||
- For app-icon or favicon platform: render multi-size bundle using sharp at each APP_ICON_SIZES, return AppIconBundle with sizes array
|
||||
- For all other platforms: rasterize SVG to PNG using `sharp(Buffer.from(svgString), { density: 300 }).resize(width, height, { fit: 'fill' }).png({ compressionLevel: 9 }).toBuffer()`
|
||||
- Return RenderResult with filename `wallpaper-{platform}.png`, contentType `image/png`
|
||||
- Store bundle in job (WallpaperBundle or AppIconBundle) by setting it on the result
|
||||
|
||||
CRITICAL: Always pass `{ density: 300 }` to sharp when loading SVG to avoid blurry rasterization at large dimensions (Pitfall 1 from research).
|
||||
|
||||
Follow the exact pattern from icon-renderer.ts for LLM prompt structure and SVG extraction.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /opt/nexus/server && npx tsc --noEmit 2>&1 | head -20</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- grep "PLATFORM_DIMENSIONS" server/src/services/renderers/wallpaper-renderer.ts
|
||||
- grep "APP_ICON_SIZES" server/src/services/renderers/wallpaper-renderer.ts
|
||||
- grep "density: 300" server/src/services/renderers/wallpaper-renderer.ts
|
||||
- grep "puterChatComplete" server/src/services/renderers/wallpaper-renderer.ts
|
||||
- grep "renderWallpaper" server/src/services/renderers/wallpaper-renderer.ts
|
||||
- grep "2560" server/src/services/renderers/wallpaper-renderer.ts
|
||||
- grep "og-image" server/src/services/renderers/wallpaper-renderer.ts
|
||||
</acceptance_criteria>
|
||||
<done>Wallpaper renderer generates LLM SVG and rasterizes at exact platform dimensions. PLATFORM_DIMENSIONS exported as constant. App icon generates multi-size bundle.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: Implement social post renderer with hashtags and carousel</name>
|
||||
<files>server/src/services/renderers/social-renderer.ts</files>
|
||||
<read_first>server/src/services/renderers/icon-renderer.ts, server/src/services/puter-inference.ts</read_first>
|
||||
<action>
|
||||
Replace the stub social-renderer.ts with a full implementation:
|
||||
|
||||
1. Export `PLATFORM_CHAR_LIMITS` constant as Record<string, number>:
|
||||
- "twitter-x": 280
|
||||
- "linkedin": 3000
|
||||
- "instagram-caption": 2200
|
||||
- "instagram-carousel": 300 (per slide)
|
||||
|
||||
2. Export `async function renderSocialPost(input: Record<string, unknown>): Promise<RenderResult>`:
|
||||
- Extract `prompt` (string) and `platform` (string) from input
|
||||
- Look up charLimit from PLATFORM_CHAR_LIMITS[platform]; throw if not found
|
||||
- Call puterChatComplete with a system prompt:
|
||||
- For non-carousel platforms: "Generate a {platform} post under {charLimit} characters. Also suggest 3-5 relevant hashtags. Return JSON only: { \"post\": \"...\", \"hashtags\": [\"#tag1\", \"#tag2\"] }"
|
||||
- For instagram-carousel: "Generate an Instagram carousel with 5-10 slides, each under {charLimit} characters. Also suggest 3-5 relevant hashtags. Return JSON only: { \"post\": \"intro caption\", \"slides\": [\"slide 1 text\", \"slide 2 text\"], \"hashtags\": [\"#tag1\"] }"
|
||||
- Parse LLM response: strip markdown fences (```json ... ```) before JSON.parse. Use robust extraction: `const match = raw.match(/```json\s*([\s\S]*?)\s*```/) || raw.match(/(\{[\s\S]*\})/); JSON.parse(match ? match[1] : raw)` (Pitfall 5 from research)
|
||||
- Build SocialPostBundle from parsed JSON
|
||||
- Return RenderResult with filename `social-{platform}.json`, contentType `application/json`, buffer from JSON.stringify of bundle
|
||||
|
||||
Follow the SVG extraction pattern from icon-renderer.ts for robustness.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /opt/nexus/server && npx tsc --noEmit 2>&1 | head -20</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- grep "PLATFORM_CHAR_LIMITS" server/src/services/renderers/social-renderer.ts
|
||||
- grep "renderSocialPost" server/src/services/renderers/social-renderer.ts
|
||||
- grep "puterChatComplete" server/src/services/renderers/social-renderer.ts
|
||||
- grep "twitter-x" server/src/services/renderers/social-renderer.ts
|
||||
- grep "instagram-carousel" server/src/services/renderers/social-renderer.ts
|
||||
- grep "hashtags" server/src/services/renderers/social-renderer.ts
|
||||
- grep "slides" server/src/services/renderers/social-renderer.ts
|
||||
</acceptance_criteria>
|
||||
<done>Social renderer generates platform-aware posts with hashtag suggestions and carousel support via LLM. PLATFORM_CHAR_LIMITS exported as constant.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<verification>
|
||||
- `cd /opt/nexus/server && npx tsc --noEmit` passes with zero errors
|
||||
- PLATFORM_DIMENSIONS has all 12 platform entries
|
||||
- PLATFORM_CHAR_LIMITS has all 4 platform entries
|
||||
- Both renderers use puterChatComplete and strip markdown fences from LLM output
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- Wallpaper renderer rasterizes LLM-generated SVG at exact platform dimensions
|
||||
- Social renderer generates posts with hashtags as JSON via LLM
|
||||
- All dimensions are constants, never magic numbers
|
||||
- tsc compiles cleanly
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
After completion, create `.planning/phases/42-wallpapers-social-format-conversion-voice/42-02-SUMMARY.md`
|
||||
</output>
|
||||
|
|
@ -0,0 +1,129 @@
|
|||
---
|
||||
phase: 42-wallpapers-social-format-conversion-voice
|
||||
plan: 02
|
||||
subsystem: api
|
||||
tags: [sharp, svg, llm, puter-inference, wallpaper, social-post, hashtags, carousel, renderers, typescript]
|
||||
|
||||
# Dependency graph
|
||||
requires:
|
||||
- phase: 42-wallpapers-social-format-conversion-voice
|
||||
plan: 01
|
||||
provides: WallpaperBundle, AppIconBundle, SocialPostBundle types in types.ts; stub wallpaper-renderer.ts and social-renderer.ts
|
||||
- phase: 41-diagrams-icons-theme-engine
|
||||
provides: puterChatComplete helper, icon-renderer.ts as implementation reference, sharp rasterization patterns
|
||||
provides:
|
||||
- renderWallpaper function + PLATFORM_DIMENSIONS constant in wallpaper-renderer.ts
|
||||
- renderSocialPost function + PLATFORM_CHAR_LIMITS constant in social-renderer.ts
|
||||
- LLM SVG generation via puterChatComplete with system prompt enforcing exact viewBox dimensions
|
||||
- Sharp rasterization at exact platform dimensions with density: 300
|
||||
- Multi-size app icon bundle (1024/512/256/64/32px)
|
||||
- Instagram carousel with per-slide character limits and hashtag suggestions
|
||||
affects:
|
||||
- 42-05 (UI panels for wallpaper and social-post depend on these renderers)
|
||||
- 42-06 (UI wiring connects job runner to rendered results)
|
||||
|
||||
# Tech tracking
|
||||
tech-stack:
|
||||
added: []
|
||||
patterns:
|
||||
- "System prompt + user message pattern for LLM: role:system with exact output instructions, role:user with creative prompt"
|
||||
- "density: 300 when loading SVG into sharp — prevents blur at large wallpaper dimensions (pitfall from research)"
|
||||
- "Robust markdown fence stripping: /```json([\s\S]*?)```/ with fallback to bare /(\{[\s\S]*\})/ extraction"
|
||||
- "Conditional bundle type: same renderer returns AppIconBundle for app-icon/favicon, WallpaperBundle for all other platforms"
|
||||
|
||||
key-files:
|
||||
created: []
|
||||
modified:
|
||||
- server/src/services/renderers/wallpaper-renderer.ts
|
||||
- server/src/services/renderers/social-renderer.ts
|
||||
|
||||
key-decisions:
|
||||
- "PLATFORM_DIMENSIONS exported as named constant (not magic numbers) — all 12 platforms in one lookup table"
|
||||
- "APP_ICON_SIZES = [1024, 512, 256, 64, 32] as const — multi-size bundle returned when platform is app-icon or favicon"
|
||||
- "PLATFORM_CHAR_LIMITS exported as named constant — 4 platforms: twitter-x(280), linkedin(3000), instagram-caption(2200), instagram-carousel(300)"
|
||||
- "Carousel uses slides[] in SocialPostBundle; all other platforms omit slides field (spread operator conditional)"
|
||||
|
||||
patterns-established:
|
||||
- "PLATFORM_DIMENSIONS lookup pattern: look up dimensions at render time, throw with available-keys list if not found"
|
||||
- "Instagram carousel detection: platform === 'instagram-carousel' switches system prompt to request slides array"
|
||||
- "LLM JSON parse: try direct regex extraction before full JSON.parse — handles both fenced and bare object responses"
|
||||
|
||||
requirements-completed: [WALL-01, WALL-02, WALL-03, WALL-04, SOCIAL-01, SOCIAL-02, SOCIAL-03]
|
||||
|
||||
# Metrics
|
||||
duration: 5min
|
||||
completed: 2026-04-04
|
||||
---
|
||||
|
||||
# Phase 42 Plan 02: Wallpaper Renderer and Social Post Renderer
|
||||
|
||||
**LLM SVG wallpaper generation rasterized via sharp at 12 platform dimensions, plus platform-aware social post JSON renderer with hashtags and Instagram carousel support**
|
||||
|
||||
## Performance
|
||||
|
||||
- **Duration:** ~5 min
|
||||
- **Started:** 2026-04-04T22:11:00Z
|
||||
- **Completed:** 2026-04-04T22:14:15Z
|
||||
- **Tasks:** 2
|
||||
- **Files modified:** 2
|
||||
|
||||
## Accomplishments
|
||||
- Replaced wallpaper-renderer.ts stub with full implementation: PLATFORM_DIMENSIONS constant (12 platforms), APP_ICON_SIZES (5 sizes), LLM SVG generation via puterChatComplete with system prompt, sharp rasterization at density: 300
|
||||
- App icon/favicon path returns AppIconBundle with 5-size array; all other platforms return WallpaperBundle with single PNG
|
||||
- Replaced social-renderer.ts stub with full implementation: PLATFORM_CHAR_LIMITS constant (4 platforms), LLM JSON generation, robust markdown fence stripping, Instagram carousel with slides array
|
||||
- TypeScript compiles cleanly with zero errors across both files
|
||||
|
||||
## Task Commits
|
||||
|
||||
Each task was committed atomically:
|
||||
|
||||
1. **Task 1: Implement wallpaper renderer with PLATFORM_DIMENSIONS** - `f0a3666a` (feat)
|
||||
2. **Task 2: Implement social post renderer with hashtags and carousel** - `9e39a9ef` (feat)
|
||||
|
||||
**Plan metadata:** (docs commit — see below)
|
||||
|
||||
## Files Created/Modified
|
||||
- `server/src/services/renderers/wallpaper-renderer.ts` - Full implementation replacing stub: PLATFORM_DIMENSIONS, APP_ICON_SIZES, renderWallpaper with LLM SVG + sharp rasterization
|
||||
- `server/src/services/renderers/social-renderer.ts` - Full implementation replacing stub: PLATFORM_CHAR_LIMITS, renderSocialPost with LLM JSON + hashtags/carousel
|
||||
|
||||
## Decisions Made
|
||||
- Removed a dead `raw` variable (unused first LLM call) that was accidentally left in during writing — cleaned before commit as Rule 1 (bug: unnecessary extra LLM call that's discarded)
|
||||
- Chose spread operator conditional `...(parsed.slides !== undefined ? { slides: parsed.slides } : {})` for optional slides field — keeps SocialPostBundle type correct without `slides: undefined` in JSON output
|
||||
|
||||
## Deviations from Plan
|
||||
|
||||
### Auto-fixed Issues
|
||||
|
||||
**1. [Rule 1 - Bug] Removed dead LLM call in wallpaper-renderer**
|
||||
- **Found during:** Task 1 (review before commit)
|
||||
- **Issue:** Initial draft had an unused `const raw = await puterChatComplete(...)` call that was discarded — an unnecessary extra API call
|
||||
- **Fix:** Removed the dead `generateWallpaperSvg` helper function entirely; the single `puterChatComplete` call in `renderWallpaper` is sufficient
|
||||
- **Files modified:** server/src/services/renderers/wallpaper-renderer.ts
|
||||
- **Verification:** tsc --noEmit passes; logic unchanged
|
||||
- **Committed in:** f0a3666a (part of Task 1 commit)
|
||||
|
||||
---
|
||||
|
||||
**Total deviations:** 1 auto-fixed (1 bug)
|
||||
**Impact on plan:** Removed dead code before commit. No scope creep.
|
||||
|
||||
## Issues Encountered
|
||||
None.
|
||||
|
||||
## User Setup Required
|
||||
None - no external service configuration required.
|
||||
|
||||
## Next Phase Readiness
|
||||
- wallpaper-renderer.ts and social-renderer.ts fully implemented; Plans 42-05 (UI panels) and 42-06 (UI wiring) can proceed
|
||||
- PLATFORM_DIMENSIONS and PLATFORM_CHAR_LIMITS are exported constants ready for UI import
|
||||
- Plans 42-03 (convert renderer) and 42-04 (voice) are independent and can proceed in parallel
|
||||
|
||||
## Self-Check: PASSED
|
||||
- `server/src/services/renderers/wallpaper-renderer.ts` — FOUND
|
||||
- `server/src/services/renderers/social-renderer.ts` — FOUND
|
||||
- Commit `f0a3666a` — FOUND
|
||||
- Commit `9e39a9ef` — FOUND
|
||||
|
||||
---
|
||||
*Phase: 42-wallpapers-social-format-conversion-voice*
|
||||
*Completed: 2026-04-04*
|
||||
|
|
@ -0,0 +1,245 @@
|
|||
---
|
||||
phase: 42-wallpapers-social-format-conversion-voice
|
||||
plan: 03
|
||||
type: execute
|
||||
wave: 2
|
||||
depends_on: [42-01]
|
||||
files_modified:
|
||||
- server/src/services/renderers/convert-renderer.ts
|
||||
- server/src/routes/convert.ts
|
||||
- server/src/app.ts
|
||||
autonomous: true
|
||||
requirements: [CONV-01, CONV-02, CONV-03, CONV-04, CONV-05, CONV-06, CONV-07, CONV-09]
|
||||
must_haves:
|
||||
truths:
|
||||
- "Image format conversion (PNG, JPG, SVG, WebP, GIF) works via sharp"
|
||||
- "Audio/video format conversion works via ffmpeg-static"
|
||||
- "Data format conversion (CSV, JSON, XLSX) works via xlsx + csv-parse"
|
||||
- "Unsupported format pairs fall through to AI-bridge via puterChatComplete"
|
||||
- "Uploaded files are validated via magic-byte detection before processing"
|
||||
- "POST /api/companies/:companyId/convert accepts multipart upload and returns 202"
|
||||
- "GET /api/system/converters returns capability map"
|
||||
artifacts:
|
||||
- path: "server/src/services/renderers/convert-renderer.ts"
|
||||
provides: "Format conversion router: sharp, ffmpeg, xlsx/csv, AI-bridge"
|
||||
exports: ["renderConvert"]
|
||||
- path: "server/src/routes/convert.ts"
|
||||
provides: "Multipart upload route + capability endpoint"
|
||||
contains: "fileTypeFromBuffer"
|
||||
- path: "server/src/app.ts"
|
||||
provides: "Mount point for convert routes"
|
||||
contains: "convertRoutes"
|
||||
key_links:
|
||||
- from: "server/src/routes/convert.ts"
|
||||
to: "server/src/services/renderers/convert-renderer.ts"
|
||||
via: "content job dispatch with convert type"
|
||||
pattern: "jobType.*convert"
|
||||
- from: "server/src/routes/convert.ts"
|
||||
to: "file-type"
|
||||
via: "magic-byte MIME validation"
|
||||
pattern: "fileTypeFromBuffer"
|
||||
- from: "server/src/services/renderers/convert-renderer.ts"
|
||||
to: "sharp"
|
||||
via: "image format conversion"
|
||||
pattern: "sharp"
|
||||
- from: "server/src/services/renderers/convert-renderer.ts"
|
||||
to: "ffmpeg-static"
|
||||
via: "audio/video conversion"
|
||||
pattern: "ffmpegPath"
|
||||
---
|
||||
|
||||
<objective>
|
||||
Implement the format conversion renderer (routing to sharp/ffmpeg/xlsx/AI-bridge by format pair) and the multipart upload route with magic-byte MIME validation.
|
||||
|
||||
Purpose: This plan fulfills all CONV requirements on the server side. The conversion UI in Plan 06 depends on these endpoints.
|
||||
Output: Working convert-renderer.ts, convert route with MIME validation, capability endpoint.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
|
||||
@$HOME/.claude/get-shit-done/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/PROJECT.md
|
||||
@.planning/ROADMAP.md
|
||||
@.planning/phases/42-wallpapers-social-format-conversion-voice/42-RESEARCH.md
|
||||
@.planning/phases/42-wallpapers-social-format-conversion-voice/42-01-SUMMARY.md
|
||||
|
||||
@server/src/services/renderers/types.ts
|
||||
@server/src/services/converter-capabilities.ts
|
||||
@server/src/routes/chat-files.ts
|
||||
@server/src/routes/content-jobs.ts
|
||||
@server/src/app.ts
|
||||
@server/src/services/puter-inference.ts
|
||||
</context>
|
||||
|
||||
<interfaces>
|
||||
From server/src/services/renderers/types.ts (after Plan 01):
|
||||
```typescript
|
||||
export interface ConvertBundle {
|
||||
type: "convert-bundle";
|
||||
outputFilename: string;
|
||||
outputMime: string;
|
||||
outputBase64: string;
|
||||
method: "direct" | "ai-bridge";
|
||||
}
|
||||
export interface RenderResult {
|
||||
filename: string;
|
||||
contentType: string;
|
||||
buffer: Buffer;
|
||||
}
|
||||
```
|
||||
|
||||
From server/src/services/converter-capabilities.ts (Plan 01):
|
||||
```typescript
|
||||
export interface ConverterCapabilities {
|
||||
imageConverter: boolean;
|
||||
audioVideoConverter: boolean;
|
||||
docConverter: boolean;
|
||||
dataConverter: boolean;
|
||||
}
|
||||
export function converterCapabilitiesService(): { get(): Promise<ConverterCapabilities> };
|
||||
```
|
||||
|
||||
From server/src/routes/chat-files.ts (multer pattern):
|
||||
```typescript
|
||||
const fileUpload = multer({ storage: multer.memoryStorage() });
|
||||
```
|
||||
</interfaces>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 1: Implement convert renderer with format routing</name>
|
||||
<files>server/src/services/renderers/convert-renderer.ts</files>
|
||||
<read_first>server/src/services/renderers/icon-renderer.ts, server/src/services/renderers/types.ts, server/src/services/puter-inference.ts</read_first>
|
||||
<action>
|
||||
Replace the stub convert-renderer.ts with a full implementation:
|
||||
|
||||
1. Export `async function renderConvert(input: Record<string, unknown>): Promise<RenderResult>`:
|
||||
- Extract fileBase64 (string), sourceMime (string), targetFormat (string), originalFilename (string) from input
|
||||
- Decode fileBase64 to Buffer
|
||||
- Route by format category using helper functions:
|
||||
|
||||
2. Image conversion (isImagePair): use sharp
|
||||
- `sharp(fileBuffer).toFormat(targetFormat as keyof sharp.FormatEnum).toBuffer()`
|
||||
- Supported: png, jpg/jpeg, webp, gif, svg (SVG input handled by sharp with density: 300)
|
||||
- Return ConvertBundle with method: "direct"
|
||||
|
||||
3. Audio/video conversion (isAVPair): use ffmpeg-static
|
||||
- Copy the ffmpeg spawn pattern from research (Pitfall 3: use `ffmpegPath as unknown as string`)
|
||||
- Spawn ffmpeg with `-f {sourceFormat} -i pipe:0 -f {targetFormat} pipe:1`
|
||||
- stdin.write(fileBuffer), stdin.end(), collect stdout chunks
|
||||
- Return ConvertBundle with method: "direct"
|
||||
|
||||
4. Data conversion (isDataPair): use xlsx + csv-parse
|
||||
- CSV to JSON: parse CSV with csv-parse, return JSON array
|
||||
- JSON to CSV: read JSON array, generate CSV with headers from first object keys
|
||||
- XLSX to JSON: xlsx.read(buffer), first sheet to json
|
||||
- JSON to XLSX: build worksheet from JSON array, write xlsx buffer
|
||||
- CSV to XLSX: parse CSV first, then build xlsx
|
||||
- XLSX to CSV: read xlsx to JSON, then serialize CSV
|
||||
- Return ConvertBundle with method: "direct"
|
||||
|
||||
5. AI-bridge fallback (all other pairs): use puterChatComplete
|
||||
- System prompt: "Convert the following {sourceMime} content to {targetFormat} format. Return ONLY the converted content, no explanation."
|
||||
- For text-based formats: pass file content as string
|
||||
- For binary formats: describe the conversion needed and return best-effort result
|
||||
- Return ConvertBundle with method: "ai-bridge"
|
||||
|
||||
6. Helper functions (not exported):
|
||||
- `isImageFormat(mime: string): boolean` — checks for image/png, image/jpeg, image/webp, image/gif, image/svg+xml
|
||||
- `isAVFormat(mime: string): boolean` — checks for audio/* and video/*
|
||||
- `isDataFormat(mime: string): boolean` — checks for text/csv, application/json, application/vnd.openxmlformats-officedocument.spreadsheetml.sheet
|
||||
- `getTargetMime(targetFormat: string): string` — maps format string to MIME type
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /opt/nexus/server && npx tsc --noEmit 2>&1 | head -20</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- grep "renderConvert" server/src/services/renderers/convert-renderer.ts
|
||||
- grep "sharp" server/src/services/renderers/convert-renderer.ts
|
||||
- grep "ffmpegPath" server/src/services/renderers/convert-renderer.ts
|
||||
- grep "xlsx" server/src/services/renderers/convert-renderer.ts
|
||||
- grep "csv-parse" server/src/services/renderers/convert-renderer.ts
|
||||
- grep "puterChatComplete" server/src/services/renderers/convert-renderer.ts
|
||||
- grep "ai-bridge" server/src/services/renderers/convert-renderer.ts
|
||||
- grep "isImageFormat" server/src/services/renderers/convert-renderer.ts
|
||||
</acceptance_criteria>
|
||||
<done>Convert renderer routes to sharp (images), ffmpeg (audio/video), xlsx/csv-parse (data), and AI-bridge (fallback) based on format pair.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: Create multipart convert route with MIME validation and wire to app.ts</name>
|
||||
<files>server/src/routes/convert.ts, server/src/app.ts</files>
|
||||
<read_first>server/src/routes/chat-files.ts, server/src/routes/content-jobs.ts, server/src/app.ts</read_first>
|
||||
<action>
|
||||
1. Create server/src/routes/convert.ts following the chat-files.ts multer pattern:
|
||||
|
||||
```typescript
|
||||
import { Router } from "express";
|
||||
import multer from "multer";
|
||||
import { fileTypeFromBuffer } from "file-type";
|
||||
```
|
||||
|
||||
POST /api/companies/:companyId/convert:
|
||||
- multer.memoryStorage() with single("file") upload
|
||||
- Extract targetFormat from req.body.targetFormat
|
||||
- Run fileTypeFromBuffer(file.buffer) for magic-byte detection
|
||||
- Build a map of extension-to-expected-MIME for validation:
|
||||
png→image/png, jpg/jpeg→image/jpeg, gif→image/gif, webp→image/webp, svg→image/svg+xml, mp3→audio/mpeg, mp4→video/mp4, wav→audio/wav, ogg→audio/ogg, csv→text/csv, json→application/json, xlsx→application/vnd.openxmlformats-officedocument.spreadsheetml.sheet
|
||||
- If fileTypeFromBuffer returns a result AND the detected MIME differs from the extension-implied MIME, return 422 with { error, actualMime, claimedMime } (CONV-09)
|
||||
- Note: text-based files (CSV, JSON, SVG, MD, HTML) may return null from fileTypeFromBuffer — allow these through (they have no magic bytes)
|
||||
- Create content job via contentJobStore (follow content-jobs.ts pattern): jobType "convert", input { fileBase64: buffer.toString('base64'), sourceMime, targetFormat, originalFilename }
|
||||
- Dispatch via contentJobRunner
|
||||
- Return 202 with { jobId, status: "queued" }
|
||||
|
||||
GET /api/system/converters:
|
||||
- Import converterCapabilitiesService from converter-capabilities.ts
|
||||
- Return JSON of capabilities
|
||||
|
||||
2. Wire in server/src/app.ts:
|
||||
- Import convertRoutes from "./routes/convert.js"
|
||||
- Mount on the api router alongside existing content-jobs routes
|
||||
- Place AFTER the body-parser middleware (multer needs raw body access)
|
||||
|
||||
CRITICAL: Use file-type ESM named import `{ fileTypeFromBuffer }` — NOT default import (Pitfall 2 from research).
|
||||
CRITICAL: Do NOT validate text-based files that return null from fileTypeFromBuffer — they have no magic bytes.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /opt/nexus/server && npx tsc --noEmit 2>&1 | head -20</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- grep "fileTypeFromBuffer" server/src/routes/convert.ts
|
||||
- grep "multer" server/src/routes/convert.ts
|
||||
- grep "targetFormat" server/src/routes/convert.ts
|
||||
- grep "422" server/src/routes/convert.ts
|
||||
- grep "actualMime" server/src/routes/convert.ts
|
||||
- grep "system/converters" server/src/routes/convert.ts
|
||||
- grep "convertRoutes" server/src/app.ts
|
||||
- grep "202" server/src/routes/convert.ts
|
||||
</acceptance_criteria>
|
||||
<done>Multipart convert route validates MIME via magic bytes, dispatches async job, returns 202. Capability endpoint exposes converter availability. Route mounted in app.ts.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<verification>
|
||||
- `cd /opt/nexus/server && npx tsc --noEmit` passes with zero errors
|
||||
- Convert route handles multipart upload with MIME validation
|
||||
- GET /api/system/converters endpoint exists
|
||||
- All format categories (image, AV, data, AI-bridge) have conversion paths
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- Convert renderer routes all format pairs to appropriate converter or AI fallback
|
||||
- Multipart upload route validates MIME via magic bytes (CONV-09)
|
||||
- Route returns 202 with job ID (async pattern)
|
||||
- Capability endpoint returns converter availability map (CONV-08)
|
||||
- tsc compiles cleanly
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
After completion, create `.planning/phases/42-wallpapers-social-format-conversion-voice/42-03-SUMMARY.md`
|
||||
</output>
|
||||
|
|
@ -0,0 +1,108 @@
|
|||
---
|
||||
phase: 42-wallpapers-social-format-conversion-voice
|
||||
plan: 03
|
||||
subsystem: api
|
||||
tags: [sharp, ffmpeg-static, xlsx, csv-parse, file-type, convert-renderer, multipart, magic-byte, content-jobs, typescript]
|
||||
|
||||
# Dependency graph
|
||||
requires:
|
||||
- phase: 42-wallpapers-social-format-conversion-voice
|
||||
plan: 01
|
||||
provides: ConvertBundle type, convert case in renderContent switch, converter-capabilities service
|
||||
- phase: 40-content-job-infra
|
||||
provides: contentJobStore, contentJobRunner, async job pattern
|
||||
provides:
|
||||
- server/src/services/renderers/convert-renderer.ts — full format conversion router (sharp/ffmpeg/xlsx/AI-bridge)
|
||||
- server/src/routes/convert.ts — POST /api/companies/:companyId/convert (multipart, 202)
|
||||
- server/src/routes/convert.ts — GET /api/system/converters (capability map)
|
||||
- convert route mounted in app.ts
|
||||
affects:
|
||||
- 42-06 (UI wiring — ConvertPanel calls these endpoints)
|
||||
|
||||
# Tech tracking
|
||||
tech-stack:
|
||||
added: []
|
||||
patterns:
|
||||
- ffmpeg-static stdin/stdout pipe pattern for audio/video conversion
|
||||
- csv-parse/sync for synchronous CSV parsing with column headers
|
||||
- xlsx.read/write for XLSX ↔ JSON ↔ CSV round-trips
|
||||
- fileTypeFromBuffer magic-byte MIME validation with text-based extension allowlist
|
||||
- multer memoryStorage + runSingleFileUpload wrapper (follows chat-files.ts pattern)
|
||||
|
||||
key-files:
|
||||
created:
|
||||
- server/src/routes/convert.ts
|
||||
modified:
|
||||
- server/src/services/renderers/convert-renderer.ts
|
||||
- server/src/app.ts
|
||||
|
||||
key-decisions:
|
||||
- "ffmpegPath cast as unknown as string required — ffmpeg-static typings return string|null but binary is always present; matches documented Pitfall 3 from research"
|
||||
- "TEXT_BASED_EXTENSIONS allowlist prevents false 422 rejections for CSV/JSON/SVG which have no magic bytes"
|
||||
- "sourceMime resolved from extension map rather than multer claim to avoid browser MIME inconsistencies"
|
||||
- "StorageService passed through to convertRoutes for future asset storage but not used in job dispatch path (job runner handles storage)"
|
||||
|
||||
# Metrics
|
||||
duration: 3min
|
||||
completed: 2026-04-04
|
||||
---
|
||||
|
||||
# Phase 42 Plan 03: Format Conversion Renderer and Multipart Route
|
||||
|
||||
**Full convert-renderer.ts routing sharp/ffmpeg/xlsx/AI-bridge by format pair, multipart upload route with magic-byte MIME validation returning 202, and GET /api/system/converters capability endpoint**
|
||||
|
||||
## Performance
|
||||
|
||||
- **Duration:** 3 min
|
||||
- **Started:** 2026-04-04T22:12:21Z
|
||||
- **Completed:** 2026-04-04T22:15:16Z
|
||||
- **Tasks:** 2
|
||||
- **Files modified:** 3 (1 replaced, 1 created, 1 modified)
|
||||
|
||||
## Accomplishments
|
||||
|
||||
- Replaced stub convert-renderer.ts with full implementation routing four format categories:
|
||||
- Image pairs (PNG/JPG/WebP/GIF/SVG) → sharp with density:300 for SVG input
|
||||
- Audio/video pairs → ffmpeg-static via stdin/stdout pipe (`ffmpegPath as unknown as string`)
|
||||
- Data pairs (CSV/JSON/XLSX) → csv-parse/sync + xlsx for all six conversion combinations
|
||||
- All other pairs → AI-bridge via puterChatComplete with system prompt
|
||||
- Created convert.ts route with multer memoryStorage, fileTypeFromBuffer magic-byte validation, and 202 job dispatch
|
||||
- Implemented MIME mismatch detection: returns 422 with `{ error, actualMime, claimedMime }` for misnamed binary files
|
||||
- Text-based formats (CSV, JSON, SVG, TXT, HTML, MD, XML) allowed through without magic-byte check (they return null)
|
||||
- Wired GET /api/system/converters endpoint returning converterCapabilitiesService result
|
||||
- Mounted convertRoutes in app.ts after contentJobRoutes
|
||||
|
||||
## Task Commits
|
||||
|
||||
Each task was committed atomically:
|
||||
|
||||
1. **Task 1: Implement convert renderer with format routing** - `d5f7586d` (feat)
|
||||
2. **Task 2: Create multipart convert route with MIME validation and wire to app.ts** - `84f97a43` (feat)
|
||||
|
||||
## Files Created/Modified
|
||||
|
||||
- `server/src/services/renderers/convert-renderer.ts` - Full implementation: isImageFormat/isAVFormat/isDataFormat helpers, convertImage/convertAV/convertData/convertViaAiBridge functions, renderConvert entry point
|
||||
- `server/src/routes/convert.ts` - Multipart upload route: multer, fileTypeFromBuffer MIME validation, content job dispatch, converter capability endpoint
|
||||
- `server/src/app.ts` - Import and mount convertRoutes after contentJobRoutes
|
||||
|
||||
## Decisions Made
|
||||
|
||||
- `ffmpegPath as unknown as string` cast used in spawn — ffmpeg-static typings declare `string | null` but the binary is always present when the package is installed; matches Pitfall 3 documented in 42-RESEARCH.md
|
||||
- `TEXT_BASED_EXTENSIONS` allowlist prevents false 422 rejection for text-based formats whose `fileTypeFromBuffer` returns null (no magic bytes)
|
||||
- Source MIME resolved from extension map rather than multer's `file.mimetype` to avoid browser-reported MIME inconsistencies (e.g. Chrome reports `text/csv` but Safari may report `application/csv`)
|
||||
|
||||
## Deviations from Plan
|
||||
|
||||
None — plan executed exactly as written.
|
||||
|
||||
## Known Stubs
|
||||
|
||||
None — all conversion paths are fully implemented.
|
||||
|
||||
## Self-Check: PASSED
|
||||
|
||||
- `server/src/services/renderers/convert-renderer.ts` — FOUND
|
||||
- `server/src/routes/convert.ts` — FOUND
|
||||
- Task 1 commit `d5f7586d` — FOUND
|
||||
- Task 2 commit `84f97a43` — FOUND
|
||||
- `tsc --noEmit` — zero errors
|
||||
|
|
@ -0,0 +1,172 @@
|
|||
---
|
||||
phase: 42-wallpapers-social-format-conversion-voice
|
||||
plan: 04
|
||||
type: execute
|
||||
wave: 2
|
||||
depends_on: [42-01]
|
||||
files_modified:
|
||||
- ui/src/components/ChatInput.tsx
|
||||
- ui/src/hooks/useSystemProviders.ts
|
||||
autonomous: true
|
||||
requirements: [VOICE-01, VOICE-02, VOICE-03]
|
||||
must_haves:
|
||||
truths:
|
||||
- "VoiceMicButton renders in ChatInput when voice mode is voice_input or full_voice"
|
||||
- "Offline badge shows next to mic button when whisperAvailable is true"
|
||||
- "Voice mode toggle allows switching between text-only, voice-input, and full-voice"
|
||||
- "Voice input works with local Whisper model"
|
||||
artifacts:
|
||||
- path: "ui/src/hooks/useSystemProviders.ts"
|
||||
provides: "Hook that fetches /api/system/providers and returns whisperAvailable"
|
||||
exports: ["useSystemProviders"]
|
||||
- path: "ui/src/components/ChatInput.tsx"
|
||||
provides: "Offline badge next to VoiceMicButton"
|
||||
contains: "Offline"
|
||||
key_links:
|
||||
- from: "ui/src/components/ChatInput.tsx"
|
||||
to: "ui/src/hooks/useSystemProviders.ts"
|
||||
via: "useSystemProviders hook for whisperAvailable"
|
||||
pattern: "useSystemProviders"
|
||||
- from: "ui/src/hooks/useSystemProviders.ts"
|
||||
to: "/api/system/providers"
|
||||
via: "fetch on mount"
|
||||
pattern: "system/providers"
|
||||
---
|
||||
|
||||
<objective>
|
||||
Wire the voice offline badge in ChatInput and create a useSystemProviders hook to surface Whisper availability. Verify existing VoiceMicButton/VoiceModeToggle integration is correct.
|
||||
|
||||
Purpose: Fulfills VOICE-01..03 by ensuring the existing voice pipeline components are properly wired and the offline capability is surfaced in the UI.
|
||||
Output: New useSystemProviders hook, updated ChatInput with offline badge.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
|
||||
@$HOME/.claude/get-shit-done/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/PROJECT.md
|
||||
@.planning/ROADMAP.md
|
||||
@.planning/phases/42-wallpapers-social-format-conversion-voice/42-RESEARCH.md
|
||||
@.planning/phases/42-wallpapers-social-format-conversion-voice/42-UI-SPEC.md
|
||||
|
||||
@ui/src/components/ChatInput.tsx
|
||||
@ui/src/components/VoiceMicButton.tsx
|
||||
@ui/src/components/VoiceModeToggle.tsx
|
||||
@server/src/routes/hardware.ts
|
||||
</context>
|
||||
|
||||
<interfaces>
|
||||
From server/src/routes/hardware.ts:
|
||||
```typescript
|
||||
router.get("/system/providers", async (_req, res) => {
|
||||
// Returns { whisperAvailable: boolean, piperAvailable: boolean, ... }
|
||||
});
|
||||
```
|
||||
|
||||
From ui/src/components/ChatInput.tsx:
|
||||
```typescript
|
||||
// Props include enableVoiceInput?: boolean
|
||||
// VoiceMicButton already renders when enableVoiceInput=true
|
||||
// VoiceModeToggle already renders when enableVoiceInput=true
|
||||
```
|
||||
</interfaces>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 1: Create useSystemProviders hook</name>
|
||||
<files>ui/src/hooks/useSystemProviders.ts</files>
|
||||
<read_first>ui/src/api/hardware.ts, ui/src/hooks/useContentJob.ts, server/src/routes/hardware.ts</read_first>
|
||||
<action>
|
||||
Create ui/src/hooks/useSystemProviders.ts:
|
||||
|
||||
1. Export interface SystemProviders { whisperAvailable: boolean; piperAvailable: boolean } (match server response shape)
|
||||
|
||||
2. Export function useSystemProviders():
|
||||
- Use useState for providers (SystemProviders | null, initially null)
|
||||
- Use useEffect to fetch GET /api/system/providers on mount (one-time fetch)
|
||||
- Use the same fetch/API client pattern used in existing hooks (check useContentJob or api/hardware.ts for the project's fetch pattern — likely uses the api client from ui/src/api/client.ts)
|
||||
- Return { providers, loading: providers === null }
|
||||
- On error: return null providers, do not throw (graceful degradation — badge simply won't show)
|
||||
|
||||
This is a simple data-fetching hook. Do NOT over-engineer with caching or SWR.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /opt/nexus/ui && npx tsc --noEmit 2>&1 | head -20</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- grep "useSystemProviders" ui/src/hooks/useSystemProviders.ts
|
||||
- grep "whisperAvailable" ui/src/hooks/useSystemProviders.ts
|
||||
- grep "system/providers" ui/src/hooks/useSystemProviders.ts
|
||||
</acceptance_criteria>
|
||||
<done>useSystemProviders hook fetches /api/system/providers and exposes whisperAvailable boolean.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: Add offline badge next to VoiceMicButton in ChatInput</name>
|
||||
<files>ui/src/components/ChatInput.tsx</files>
|
||||
<read_first>ui/src/components/ChatInput.tsx, ui/src/components/VoiceMicButton.tsx</read_first>
|
||||
<action>
|
||||
Update ChatInput.tsx to show an "Offline" badge when Whisper is locally available:
|
||||
|
||||
1. Import useSystemProviders from "../hooks/useSystemProviders"
|
||||
2. Import WifiOff from lucide-react (icon library per UI spec)
|
||||
3. Inside the ChatInput component, call useSystemProviders() to get { providers }
|
||||
4. Next to the VoiceMicButton (in the same flex container where it renders when enableVoiceInput=true), add the offline badge:
|
||||
```tsx
|
||||
{enableVoiceInput && providers?.whisperAvailable && (
|
||||
<span
|
||||
className="text-xs text-muted-foreground flex items-center gap-1"
|
||||
aria-label="Voice input is offline (local model)"
|
||||
>
|
||||
<WifiOff className="h-3 w-3" />
|
||||
Offline
|
||||
</span>
|
||||
)}
|
||||
```
|
||||
5. The badge renders ONLY when:
|
||||
- enableVoiceInput is true (voice mode is active)
|
||||
- providers?.whisperAvailable is true (local Whisper binary detected)
|
||||
|
||||
6. Verify that VoiceMicButton and VoiceModeToggle are already rendering correctly:
|
||||
- VoiceMicButton shows when enableVoiceInput=true
|
||||
- VoiceModeToggle shows when enableVoiceInput=true
|
||||
- If either is NOT rendering, wire them following the existing pattern
|
||||
|
||||
Do NOT change VoiceMicButton or VoiceModeToggle components — they are already correct from Phase 37.
|
||||
|
||||
Per Pitfall 7 from research: badge shows when whisperAvailable===true (binary detected), NOT based on an env var.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /opt/nexus/ui && npx tsc --noEmit 2>&1 | head -20</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- grep "useSystemProviders" ui/src/components/ChatInput.tsx
|
||||
- grep "WifiOff" ui/src/components/ChatInput.tsx
|
||||
- grep "Offline" ui/src/components/ChatInput.tsx
|
||||
- grep "whisperAvailable" ui/src/components/ChatInput.tsx
|
||||
- grep "aria-label" ui/src/components/ChatInput.tsx
|
||||
</acceptance_criteria>
|
||||
<done>Offline badge shows next to VoiceMicButton when local Whisper is detected. Existing voice components verified working.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<verification>
|
||||
- `cd /opt/nexus/ui && npx tsc --noEmit` passes
|
||||
- Offline badge only renders when whisperAvailable is true
|
||||
- Voice mic button and mode toggle render when enableVoiceInput=true
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- useSystemProviders hook fetches whisperAvailable from /api/system/providers
|
||||
- Offline badge renders with WifiOff icon and correct aria-label
|
||||
- Existing VoiceMicButton/VoiceModeToggle integration unchanged and working
|
||||
- tsc compiles cleanly
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
After completion, create `.planning/phases/42-wallpapers-social-format-conversion-voice/42-04-SUMMARY.md`
|
||||
</output>
|
||||
|
|
@ -0,0 +1,94 @@
|
|||
---
|
||||
phase: 42-wallpapers-social-format-conversion-voice
|
||||
plan: 04
|
||||
subsystem: ui
|
||||
tags: [voice, whisper, offline-badge, react-hook, lucide-react, typescript]
|
||||
|
||||
# Dependency graph
|
||||
requires:
|
||||
- phase: 42-wallpapers-social-format-conversion-voice
|
||||
provides: Plan 01 foundation types and server hardware route at /api/system/providers
|
||||
affects:
|
||||
- 42-06 (UI wiring — voice offline badge now visible in ChatInput)
|
||||
|
||||
provides:
|
||||
- useSystemProviders hook fetching /api/system/providers and exposing whisperAvailable/piperAvailable
|
||||
- Offline badge in ChatInput (WifiOff icon + "Offline" label) when local Whisper binary detected
|
||||
|
||||
# Tech tracking
|
||||
tech-stack:
|
||||
added: []
|
||||
patterns:
|
||||
- Simple one-shot fetch hook (no SWR/React Query) for UI capability flags
|
||||
- Graceful degradation: badge absent when providers null (fetch error or Whisper not installed)
|
||||
|
||||
key-files:
|
||||
created:
|
||||
- ui/src/hooks/useSystemProviders.ts
|
||||
modified:
|
||||
- ui/src/components/ChatInput.tsx
|
||||
|
||||
key-decisions:
|
||||
- "useSystemProviders uses plain useState+useEffect fetch (not React Query) — matches plan spec and keeps hook lightweight for a one-time capability probe"
|
||||
- "Hook maps voiceCapability from HardwareInfo response shape — /api/system/providers returns full HardwareInfo, whisperAvailable lives in voiceCapability sub-object"
|
||||
|
||||
patterns-established:
|
||||
- "Offline badge pattern: enableVoiceInput && providers?.whisperAvailable — double guard ensures badge only shows in voice mode with confirmed local model"
|
||||
|
||||
requirements-completed: [VOICE-01, VOICE-02, VOICE-03]
|
||||
|
||||
# Metrics
|
||||
duration: 2min
|
||||
completed: 2026-04-04
|
||||
---
|
||||
|
||||
# Phase 42 Plan 04: Voice Offline Badge and useSystemProviders Hook
|
||||
|
||||
**useSystemProviders hook wires /api/system/providers to ChatInput offline badge, surfacing local Whisper availability via WifiOff icon when voice mode is active**
|
||||
|
||||
## Performance
|
||||
|
||||
- **Duration:** 2 min
|
||||
- **Started:** 2026-04-04T22:12:11Z
|
||||
- **Completed:** 2026-04-04T22:14:00Z
|
||||
- **Tasks:** 2
|
||||
- **Files modified:** 2 (1 created, 1 modified)
|
||||
|
||||
## Accomplishments
|
||||
- Created useSystemProviders hook that fetches /api/system/providers once on mount, extracts voiceCapability.whisperAvailable, and returns null providers on error for graceful degradation
|
||||
- Added WifiOff icon offline badge to ChatInput — renders only when enableVoiceInput=true AND providers.whisperAvailable=true
|
||||
- Verified VoiceMicButton and VoiceModeToggle already rendering correctly from prior phase work (no changes needed)
|
||||
|
||||
## Task Commits
|
||||
|
||||
Each task was committed atomically:
|
||||
|
||||
1. **Task 1: Create useSystemProviders hook** - `768f62fa` (feat)
|
||||
2. **Task 2: Add offline badge to ChatInput** - `69ae0a00` (feat)
|
||||
|
||||
## Files Created/Modified
|
||||
- `ui/src/hooks/useSystemProviders.ts` - One-shot fetch hook for system providers; exports SystemProviders interface and useSystemProviders function
|
||||
- `ui/src/components/ChatInput.tsx` - Added WifiOff import, useSystemProviders call, and offline badge JSX next to VoiceMicButton
|
||||
|
||||
## Decisions Made
|
||||
- Used plain useState+useEffect pattern (not React Query / useHardwareInfo) as the plan specified — the hook is intentionally lightweight for a one-time capability probe
|
||||
- Mapped voiceCapability sub-object from HardwareInfo since the /api/system/providers endpoint returns the full hardware info shape; whisperAvailable lives at `data.voiceCapability.whisperAvailable`
|
||||
|
||||
## Deviations from Plan
|
||||
|
||||
None - plan executed exactly as written.
|
||||
|
||||
## Issues Encountered
|
||||
None.
|
||||
|
||||
## User Setup Required
|
||||
None - no external service configuration required.
|
||||
|
||||
## Next Phase Readiness
|
||||
- Offline badge available in ChatInput; voice UI feature surface is now complete
|
||||
- Plan 06 (UI wiring) can wire enableVoiceInput prop from user settings/mode state
|
||||
- Voice mode toggle and mic button confirmed rendering correctly when enableVoiceInput=true
|
||||
|
||||
---
|
||||
*Phase: 42-wallpapers-social-format-conversion-voice*
|
||||
*Completed: 2026-04-04*
|
||||
|
|
@ -0,0 +1,237 @@
|
|||
---
|
||||
phase: 42-wallpapers-social-format-conversion-voice
|
||||
plan: 05
|
||||
type: execute
|
||||
wave: 3
|
||||
depends_on: [42-02]
|
||||
files_modified:
|
||||
- ui/src/components/WallpaperGeneratePanel.tsx
|
||||
- ui/src/components/WallpaperPreview.tsx
|
||||
- ui/src/components/SocialPostPanel.tsx
|
||||
- ui/src/components/SocialPostResult.tsx
|
||||
- ui/src/pages/ContentStudio.tsx
|
||||
autonomous: true
|
||||
requirements: [WALL-01, WALL-02, WALL-03, WALL-04, SOCIAL-01, SOCIAL-02, SOCIAL-03]
|
||||
must_haves:
|
||||
truths:
|
||||
- "User can type a prompt, select a platform, and generate a wallpaper/social image"
|
||||
- "Platform selector shows all 12 platform options grouped by Desktop/Mobile/Social/App"
|
||||
- "Wallpaper preview shows the image with download button after job completes"
|
||||
- "App icon/favicon shows multi-size grid with individual download links"
|
||||
- "Social post panel shows character count that turns red over limit"
|
||||
- "Generated post shows copy button and clickable hashtag chips"
|
||||
- "Instagram carousel shows numbered collapsible slide sections"
|
||||
- "ContentStudio has Wallpapers and Social tabs"
|
||||
artifacts:
|
||||
- path: "ui/src/components/WallpaperGeneratePanel.tsx"
|
||||
provides: "Prompt input + platform selector + generate button"
|
||||
contains: "PLATFORM_DIMENSIONS"
|
||||
- path: "ui/src/components/WallpaperPreview.tsx"
|
||||
provides: "Image display + download button + multi-size grid for icons"
|
||||
contains: "Download PNG"
|
||||
- path: "ui/src/components/SocialPostPanel.tsx"
|
||||
provides: "Prompt input + platform selector + character count"
|
||||
contains: "PLATFORM_CHAR_LIMITS"
|
||||
- path: "ui/src/components/SocialPostResult.tsx"
|
||||
provides: "Post display + copy button + hashtag chips + carousel slides"
|
||||
contains: "Copy Post"
|
||||
- path: "ui/src/pages/ContentStudio.tsx"
|
||||
provides: "Wallpapers and Social tabs added to TabsList"
|
||||
contains: "wallpapers"
|
||||
key_links:
|
||||
- from: "ui/src/components/WallpaperGeneratePanel.tsx"
|
||||
to: "ui/src/hooks/useContentJob.ts"
|
||||
via: "useContentJob hook for SSE job tracking"
|
||||
pattern: "useContentJob"
|
||||
- from: "ui/src/components/SocialPostPanel.tsx"
|
||||
to: "ui/src/hooks/useContentJob.ts"
|
||||
via: "useContentJob hook for SSE job tracking"
|
||||
pattern: "useContentJob"
|
||||
- from: "ui/src/pages/ContentStudio.tsx"
|
||||
to: "ui/src/components/WallpaperGeneratePanel.tsx"
|
||||
via: "TabsContent for wallpapers"
|
||||
pattern: "WallpaperGeneratePanel"
|
||||
---
|
||||
|
||||
<objective>
|
||||
Build the Wallpaper and Social UI panels and extend ContentStudio with new tabs.
|
||||
|
||||
Purpose: This surfaces the wallpaper and social renderers (Plan 02) to users through the ContentStudio interface.
|
||||
Output: Four new UI components + updated ContentStudio tabs.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
|
||||
@$HOME/.claude/get-shit-done/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/PROJECT.md
|
||||
@.planning/ROADMAP.md
|
||||
@.planning/phases/42-wallpapers-social-format-conversion-voice/42-RESEARCH.md
|
||||
@.planning/phases/42-wallpapers-social-format-conversion-voice/42-UI-SPEC.md
|
||||
@.planning/phases/42-wallpapers-social-format-conversion-voice/42-02-SUMMARY.md
|
||||
|
||||
@ui/src/pages/ContentStudio.tsx
|
||||
@ui/src/components/DiagramGeneratePanel.tsx
|
||||
@ui/src/components/IconGeneratePanel.tsx
|
||||
@ui/src/hooks/useContentJob.ts
|
||||
@ui/src/api/contentJobs.ts
|
||||
</context>
|
||||
|
||||
<interfaces>
|
||||
From server/src/services/renderers/wallpaper-renderer.ts (Plan 02):
|
||||
```typescript
|
||||
export const PLATFORM_DIMENSIONS: Record<string, { width: number; height: number; label: string }>;
|
||||
export const APP_ICON_SIZES: readonly [1024, 512, 256, 64, 32];
|
||||
```
|
||||
|
||||
From server/src/services/renderers/social-renderer.ts (Plan 02):
|
||||
```typescript
|
||||
export const PLATFORM_CHAR_LIMITS: Record<string, number>;
|
||||
// "twitter-x": 280, "linkedin": 3000, "instagram-caption": 2200, "instagram-carousel": 300
|
||||
```
|
||||
|
||||
From ui/src/hooks/useContentJob.ts:
|
||||
```typescript
|
||||
export function useContentJob(companyId: string | null): {
|
||||
job: ContentJob | null;
|
||||
submit: (jobType: string, input: Record<string, unknown>) => Promise<void>;
|
||||
status: string;
|
||||
};
|
||||
```
|
||||
|
||||
Bundle types (job.bundle after status === 'done'):
|
||||
```typescript
|
||||
interface WallpaperBundle { type: "wallpaper-bundle"; platform: string; width: number; height: number; pngBase64: string; prompt: string; }
|
||||
interface AppIconBundle { type: "app-icon-bundle"; sizes: Array<{ size: number; pngBase64: string }>; prompt: string; }
|
||||
interface SocialPostBundle { type: "social-post-bundle"; platform: string; post: string; hashtags: string[]; slides?: string[]; charLimit: number; }
|
||||
```
|
||||
</interfaces>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 1: Create WallpaperGeneratePanel and WallpaperPreview components</name>
|
||||
<files>ui/src/components/WallpaperGeneratePanel.tsx, ui/src/components/WallpaperPreview.tsx</files>
|
||||
<read_first>ui/src/components/DiagramGeneratePanel.tsx, ui/src/components/IconGeneratePanel.tsx, ui/src/hooks/useContentJob.ts, ui/src/api/contentJobs.ts</read_first>
|
||||
<action>
|
||||
1. Create WallpaperGeneratePanel.tsx following the DiagramGeneratePanel pattern:
|
||||
- Props: { companyId: string }
|
||||
- Define PLATFORM_DIMENSIONS as a local constant (same values as server — 12 entries). Group into categories: Desktop, Mobile, Social, App.
|
||||
- State: prompt (string), platform (string, default "desktop-hd")
|
||||
- Use useContentJob(companyId) hook for job submission and tracking
|
||||
- Render a Card with:
|
||||
- Textarea (4 rows, placeholder: "Describe the scene, mood, or concept...")
|
||||
- Select component for platform. Use optgroup-style grouping (shadcn Select supports groups via SelectGroup + SelectLabel). Each option shows the label with dimensions in text-xs font-mono text-muted-foreground.
|
||||
- "Generate Wallpaper" Button (primary, full-width). Disabled while job is in progress. Shows spinner + "Generating..." when active.
|
||||
- Progress bar (shadcn progress) below button — driven by job.progress from SSE
|
||||
- On submit: call job.submit("wallpaper", { prompt, platform })
|
||||
- When job.status === "done": render WallpaperPreview with job.bundle
|
||||
|
||||
2. Create WallpaperPreview.tsx:
|
||||
- Props: { bundle: WallpaperBundle | AppIconBundle }
|
||||
- For WallpaperBundle (type === "wallpaper-bundle"):
|
||||
- Render image in `max-h-80 object-contain` container
|
||||
- Image alt text: prompt truncated to 100 chars
|
||||
- Below image: "Download PNG" Button (primary) + resolution badge (text-xs font-mono text-muted-foreground showing "{width} x {height}")
|
||||
- Download: create blob URL from base64, trigger download via anchor click
|
||||
- For AppIconBundle (type === "app-icon-bundle"):
|
||||
- Render a grid of size entries. Each cell: size label (e.g. "32 x 32") + Download link
|
||||
- Grid uses responsive columns: 2 cols on mobile, 3 on md, 5 on lg
|
||||
- Empty state: heading "No image yet", body "Describe a scene or mood and pick a platform size to generate a ready-to-use image."
|
||||
|
||||
Per UI spec: all copy matches the Copywriting Contract exactly.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /opt/nexus/ui && npx tsc --noEmit 2>&1 | head -20</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- grep "PLATFORM_DIMENSIONS" ui/src/components/WallpaperGeneratePanel.tsx
|
||||
- grep "Generate Wallpaper" ui/src/components/WallpaperGeneratePanel.tsx
|
||||
- grep "useContentJob" ui/src/components/WallpaperGeneratePanel.tsx
|
||||
- grep "Download PNG" ui/src/components/WallpaperPreview.tsx
|
||||
- grep "app-icon-bundle" ui/src/components/WallpaperPreview.tsx
|
||||
- grep "No image yet" ui/src/components/WallpaperPreview.tsx
|
||||
- grep "2560" ui/src/components/WallpaperGeneratePanel.tsx
|
||||
</acceptance_criteria>
|
||||
<done>Wallpaper panel allows prompt + platform selection, shows progress, displays result with download. App icon shows multi-size grid.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: Create SocialPostPanel, SocialPostResult, and extend ContentStudio tabs</name>
|
||||
<files>ui/src/components/SocialPostPanel.tsx, ui/src/components/SocialPostResult.tsx, ui/src/pages/ContentStudio.tsx</files>
|
||||
<read_first>ui/src/pages/ContentStudio.tsx, ui/src/components/DiagramGeneratePanel.tsx</read_first>
|
||||
<action>
|
||||
1. Create SocialPostPanel.tsx following DiagramGeneratePanel pattern:
|
||||
- Props: { companyId: string }
|
||||
- Define PLATFORM_CHAR_LIMITS as local constant: { "twitter-x": 280, "linkedin": 3000, "instagram-caption": 2200, "instagram-carousel": 300 }
|
||||
- State: prompt (string), platform (string, default "twitter-x"), charCount (number tracking textarea length)
|
||||
- Use useContentJob(companyId) hook
|
||||
- Render Card with:
|
||||
- Textarea (4 rows, placeholder: "Describe the topic or paste existing content to adapt...")
|
||||
- onChange: update prompt and charCount
|
||||
- Platform selector (Select): "Twitter/X", "LinkedIn", "Instagram Caption", "Instagram Carousel"
|
||||
- Character count below textarea, right-aligned: `text-xs text-muted-foreground`. Format: "{N} / {limit}". When over limit: add `text-destructive` class and append " - over limit"
|
||||
- Character count has `aria-live="polite"` for screen readers
|
||||
- "Generate Post" Button (primary). Enabled even when over limit (AI may trim). Shows spinner + "Generating..." when active.
|
||||
- Progress bar below button
|
||||
- On submit: call job.submit("social-post", { prompt, platform })
|
||||
- When job.status === "done": render SocialPostResult with job.bundle
|
||||
|
||||
2. Create SocialPostResult.tsx:
|
||||
- Props: { bundle: SocialPostBundle }
|
||||
- Post text in read-only Card (bg-card, rounded-lg, p-4)
|
||||
- Character count badge inline (text-xs font-mono text-muted-foreground)
|
||||
- "Copy Post" Button (secondary, full-width). On click: copy bundle.post to clipboard. Change text to "Copied!" for 2 seconds then revert.
|
||||
- Hashtag section below copy button: render bundle.hashtags as chips in rounded-full badges (bg-muted text-muted-foreground). Each chip has role="button" and aria-label="Copy hashtag {tag}". On click: copy tag to clipboard. Show CheckCheck icon (12px) for 1.5s then revert to text.
|
||||
- For carousel (bundle.slides exists and length > 0): render numbered collapsible sections using shadcn Collapsible. "Slide 1", "Slide 2" etc. Each section shows the slide text.
|
||||
- Empty state: heading "No post yet", body "Describe your topic and choose a platform to generate a ready-to-publish post."
|
||||
|
||||
3. Update ContentStudio.tsx:
|
||||
- Add two new TabsTrigger entries to the existing TabsList: "Wallpapers" (value: wallpapers), "Social" (value: social)
|
||||
- Add two new TabsContent sections:
|
||||
- wallpapers: renders WallpaperGeneratePanel with companyId
|
||||
- social: renders SocialPostPanel with companyId
|
||||
- Import WallpaperGeneratePanel and SocialPostPanel
|
||||
- Follow the exact same pattern as the existing diagrams/icons/themes tabs
|
||||
|
||||
Per UI spec copywriting contract: match ALL copy strings exactly.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /opt/nexus/ui && npx tsc --noEmit 2>&1 | head -20</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- grep "PLATFORM_CHAR_LIMITS" ui/src/components/SocialPostPanel.tsx
|
||||
- grep "Generate Post" ui/src/components/SocialPostPanel.tsx
|
||||
- grep "aria-live" ui/src/components/SocialPostPanel.tsx
|
||||
- grep "Copy Post" ui/src/components/SocialPostResult.tsx
|
||||
- grep "hashtags" ui/src/components/SocialPostResult.tsx
|
||||
- grep "Collapsible" ui/src/components/SocialPostResult.tsx
|
||||
- grep "wallpapers" ui/src/pages/ContentStudio.tsx
|
||||
- grep "social" ui/src/pages/ContentStudio.tsx
|
||||
- grep "WallpaperGeneratePanel" ui/src/pages/ContentStudio.tsx
|
||||
- grep "SocialPostPanel" ui/src/pages/ContentStudio.tsx
|
||||
</acceptance_criteria>
|
||||
<done>Social panel with character count, hashtag chips, carousel support. ContentStudio extended with Wallpapers and Social tabs.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<verification>
|
||||
- `cd /opt/nexus/ui && npx tsc --noEmit` passes
|
||||
- ContentStudio shows 5 tabs: Diagrams, Icons, Themes, Wallpapers, Social
|
||||
- All UI copy matches the UI spec copywriting contract
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- Wallpaper panel: prompt + 12-platform selector + generate + download
|
||||
- Social panel: prompt + 4-platform selector + live character count + generate
|
||||
- Social result: copy post + hashtag chips + carousel collapsibles
|
||||
- ContentStudio has Wallpapers and Social tabs
|
||||
- tsc compiles cleanly
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
After completion, create `.planning/phases/42-wallpapers-social-format-conversion-voice/42-05-SUMMARY.md`
|
||||
</output>
|
||||
|
|
@ -0,0 +1,125 @@
|
|||
---
|
||||
phase: 42-wallpapers-social-format-conversion-voice
|
||||
plan: 05
|
||||
subsystem: ui
|
||||
tags: [react, shadcn, wallpaper, social-post, content-studio, tabs, select, collapsible, progress, sse]
|
||||
|
||||
# Dependency graph
|
||||
requires:
|
||||
- phase: 42-wallpapers-social-format-conversion-voice
|
||||
plan: 02
|
||||
provides: renderWallpaper + PLATFORM_DIMENSIONS in wallpaper-renderer.ts; renderSocialPost + PLATFORM_CHAR_LIMITS in social-renderer.ts; WallpaperBundle, AppIconBundle, SocialPostBundle types
|
||||
- phase: 41-diagrams-icons-theme-engine
|
||||
provides: ContentStudio.tsx with Diagrams/Icons/Themes tabs; useContentJob hook; DiagramGeneratePanel pattern; shadcn component set
|
||||
|
||||
provides:
|
||||
- WallpaperGeneratePanel component with 12-platform Select (grouped Desktop/Mobile/Social/App), PLATFORM_DIMENSIONS constant, SSE job tracking
|
||||
- WallpaperPreview component handling both wallpaper-bundle (Download PNG) and app-icon-bundle (multi-size grid)
|
||||
- SocialPostPanel component with 4-platform Select, live character count (text-destructive over limit), PLATFORM_CHAR_LIMITS constant
|
||||
- SocialPostResult component with copy button, hashtag chips with inline CheckCheck feedback, numbered Collapsible carousel slides
|
||||
- ContentStudio.tsx extended with Wallpapers and Social tabs (5 tabs total)
|
||||
|
||||
affects:
|
||||
- 42-06 (Convert tab to be added to ContentStudio following same pattern)
|
||||
|
||||
# Tech tracking
|
||||
tech-stack:
|
||||
added: []
|
||||
patterns:
|
||||
- "SelectGroup + SelectLabel for grouped platform options in shadcn Select"
|
||||
- "Collapsible per-slide pattern for Instagram carousel with open/close state per index"
|
||||
- "Inline chip copy-feedback: setState(tag) for 1.5s then null — no toast required"
|
||||
- "2-second copy-confirmed state for primary Copy button (setCopied + setTimeout)"
|
||||
|
||||
key-files:
|
||||
created:
|
||||
- ui/src/components/WallpaperGeneratePanel.tsx
|
||||
- ui/src/components/WallpaperPreview.tsx
|
||||
- ui/src/components/SocialPostPanel.tsx
|
||||
- ui/src/components/SocialPostResult.tsx
|
||||
modified:
|
||||
- ui/src/pages/ContentStudio.tsx
|
||||
|
||||
key-decisions:
|
||||
- "WallpaperBundle and AppIconBundle types defined locally in WallpaperGeneratePanel.tsx — no need for content-bundles.ts addition since these are consumed only by Wallpaper components"
|
||||
- "SocialPostBundle type defined locally in SocialPostPanel.tsx — same reasoning"
|
||||
- "ContentStudio.tsx created from scratch in this worktree (file exists on phase-42 base branch but not yet in worktree HEAD) — copied base then extended with Wallpapers + Social tabs"
|
||||
|
||||
patterns-established:
|
||||
- "Wallpaper/social panel pattern: same Card structure as DiagramGeneratePanel — textarea, select, button with spinner, progress bar, result or empty state"
|
||||
- "Hashtag chip copy pattern: copiedTag state tracks which chip is showing CheckCheck, auto-reverts after 1.5s"
|
||||
- "Carousel collapsible pattern: openSlides Record<number, boolean> with toggleSlide helper"
|
||||
|
||||
requirements-completed: [WALL-01, WALL-02, WALL-03, WALL-04, SOCIAL-01, SOCIAL-02, SOCIAL-03]
|
||||
|
||||
# Metrics
|
||||
duration: 3min
|
||||
completed: 2026-04-04
|
||||
---
|
||||
|
||||
# Phase 42 Plan 05: Wallpaper and Social UI Panels Summary
|
||||
|
||||
**Four React components + extended ContentStudio tabs: wallpaper generator with 12-platform grouped selector and multi-size app icon grid, social post generator with live character count and hashtag chip copy, carousel collapsible slides**
|
||||
|
||||
## Performance
|
||||
|
||||
- **Duration:** ~3 min
|
||||
- **Started:** 2026-04-04T22:17:52Z
|
||||
- **Completed:** 2026-04-04T22:21:00Z
|
||||
- **Tasks:** 2
|
||||
- **Files modified:** 5 (4 created, 1 modified)
|
||||
|
||||
## Accomplishments
|
||||
- Created WallpaperGeneratePanel with PLATFORM_DIMENSIONS (12 entries) grouped into Desktop/Mobile/Social/App SelectGroups, SSE job tracking via useContentJob, and inline progress bar
|
||||
- Created WallpaperPreview handling both wallpaper-bundle (image + Download PNG + resolution badge) and app-icon-bundle (responsive 2/3/5 col grid per size with individual download links)
|
||||
- Created SocialPostPanel with PLATFORM_CHAR_LIMITS (4 platforms), live char count turning text-destructive over limit with aria-live="polite", Generate Post button
|
||||
- Created SocialPostResult with read-only post card, Copy Post button (2s "Copied!" feedback), clickable hashtag chips (1.5s CheckCheck inline feedback), numbered Collapsible sections for Instagram carousel slides
|
||||
- Extended ContentStudio.tsx from 3 tabs to 5: added Wallpapers and Social TabsTrigger + TabsContent following the exact existing pattern
|
||||
|
||||
## Task Commits
|
||||
|
||||
Each task was committed atomically:
|
||||
|
||||
1. **Task 1: Create WallpaperGeneratePanel and WallpaperPreview** - `5e0e024d` (feat)
|
||||
2. **Task 2: Create SocialPostPanel, SocialPostResult, extend ContentStudio** - `07e15dc7` (feat)
|
||||
|
||||
**Plan metadata:** (docs commit — see below)
|
||||
|
||||
## Files Created/Modified
|
||||
- `ui/src/components/WallpaperGeneratePanel.tsx` - Prompt + 12-platform grouped Select + Generate Wallpaper button + SSE job + PLATFORM_DIMENSIONS constant + WallpaperBundle/AppIconBundle type exports
|
||||
- `ui/src/components/WallpaperPreview.tsx` - Image display with Download PNG, multi-size app icon grid with per-size download
|
||||
- `ui/src/components/SocialPostPanel.tsx` - Prompt + 4-platform Select + live character count + Generate Post button + SSE job + PLATFORM_CHAR_LIMITS + SocialPostBundle type export
|
||||
- `ui/src/components/SocialPostResult.tsx` - Post card + Copy Post + hashtag chips + Collapsible carousel slides
|
||||
- `ui/src/pages/ContentStudio.tsx` - Added Wallpapers and Social tabs (5 total)
|
||||
|
||||
## Decisions Made
|
||||
- Bundle types (WallpaperBundle, AppIconBundle, SocialPostBundle) defined locally in the panel component files rather than adding to content-bundles.ts. These types are only consumed by their respective panel/preview components — no cross-component sharing needed.
|
||||
- ContentStudio.tsx written from the phase-42 base version (exists on gsd/phase-42 branch but not yet in this worktree's HEAD) with the two new tabs added.
|
||||
|
||||
## Deviations from Plan
|
||||
|
||||
None - plan executed exactly as written. All components match the UI spec copywriting contract. tsc compiles cleanly.
|
||||
|
||||
## Issues Encountered
|
||||
None.
|
||||
|
||||
## User Setup Required
|
||||
None - no external service configuration required.
|
||||
|
||||
## Next Phase Readiness
|
||||
- All wallpaper and social UI components complete; ready for end-to-end testing once server-side renderers are live
|
||||
- ContentStudio now has 5 tabs; Plan 42-06 (if it adds Convert tab) can follow the same TabsTrigger/TabsContent pattern
|
||||
- WallpaperBundle, AppIconBundle, SocialPostBundle type exports available from component files if needed elsewhere
|
||||
|
||||
## Self-Check: PASSED
|
||||
- `ui/src/components/WallpaperGeneratePanel.tsx` — FOUND
|
||||
- `ui/src/components/WallpaperPreview.tsx` — FOUND
|
||||
- `ui/src/components/SocialPostPanel.tsx` — FOUND
|
||||
- `ui/src/components/SocialPostResult.tsx` — FOUND
|
||||
- `ui/src/pages/ContentStudio.tsx` — FOUND
|
||||
- Commit `5e0e024d` — FOUND
|
||||
- Commit `07e15dc7` — FOUND
|
||||
|
||||
---
|
||||
*Phase: 42-wallpapers-social-format-conversion-voice*
|
||||
*Completed: 2026-04-04*
|
||||
|
|
@ -0,0 +1,262 @@
|
|||
---
|
||||
phase: 42-wallpapers-social-format-conversion-voice
|
||||
plan: 06
|
||||
type: execute
|
||||
wave: 3
|
||||
depends_on: [42-03]
|
||||
files_modified:
|
||||
- ui/src/pages/ConvertPage.tsx
|
||||
- ui/src/components/ConvertPanel.tsx
|
||||
- ui/src/api/convert.ts
|
||||
- ui/src/App.tsx
|
||||
autonomous: true
|
||||
requirements: [CONV-01, CONV-02, CONV-03, CONV-04, CONV-05, CONV-06, CONV-07, CONV-08, CONV-09]
|
||||
must_haves:
|
||||
truths:
|
||||
- "User can drag-drop a file, select a target format, and download the converted file"
|
||||
- "Format chips are grouped by Images, Audio/Video, Documents, Data"
|
||||
- "All format pairs are selectable — unavailable direct converters show AI fallback notice"
|
||||
- "Deep-link /convert/png/svg pre-selects PNG source and SVG target on mount"
|
||||
- "MIME validation error shows inline when magic bytes mismatch extension"
|
||||
- "Drag-drop zone shows correct states (idle, dragover, error)"
|
||||
- "Convert button is disabled until both source file and target format are selected"
|
||||
artifacts:
|
||||
- path: "ui/src/pages/ConvertPage.tsx"
|
||||
provides: "Standalone /convert page with deep-link routing"
|
||||
contains: "sourceFormat"
|
||||
- path: "ui/src/components/ConvertPanel.tsx"
|
||||
provides: "Drag-drop zone + format chips + convert action bar"
|
||||
contains: "ConvertSourceZone"
|
||||
- path: "ui/src/api/convert.ts"
|
||||
provides: "submitConvertJob (multipart), getConverterCapabilities"
|
||||
exports: ["submitConvertJob", "getConverterCapabilities"]
|
||||
- path: "ui/src/App.tsx"
|
||||
provides: "Routes for /convert, /convert/:sourceFormat, /convert/:sourceFormat/:targetFormat"
|
||||
contains: "ConvertPage"
|
||||
key_links:
|
||||
- from: "ui/src/pages/ConvertPage.tsx"
|
||||
to: "ui/src/components/ConvertPanel.tsx"
|
||||
via: "renders ConvertPanel with route params"
|
||||
pattern: "ConvertPanel"
|
||||
- from: "ui/src/components/ConvertPanel.tsx"
|
||||
to: "ui/src/api/convert.ts"
|
||||
via: "submitConvertJob for multipart upload"
|
||||
pattern: "submitConvertJob"
|
||||
- from: "ui/src/api/convert.ts"
|
||||
to: "/api/companies/:companyId/convert"
|
||||
via: "FormData multipart POST"
|
||||
pattern: "FormData"
|
||||
- from: "ui/src/App.tsx"
|
||||
to: "ui/src/pages/ConvertPage.tsx"
|
||||
via: "Route path convert"
|
||||
pattern: "convert"
|
||||
---
|
||||
|
||||
<objective>
|
||||
Build the format conversion UI page with drag-drop upload, grouped format chips, AI fallback indicator, deep-link routing, and inline MIME validation errors.
|
||||
|
||||
Purpose: This surfaces the conversion renderer (Plan 03) to users. It fulfills the UI side of all CONV requirements.
|
||||
Output: ConvertPage, ConvertPanel, convert API client, routes in App.tsx.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
|
||||
@$HOME/.claude/get-shit-done/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/PROJECT.md
|
||||
@.planning/ROADMAP.md
|
||||
@.planning/phases/42-wallpapers-social-format-conversion-voice/42-RESEARCH.md
|
||||
@.planning/phases/42-wallpapers-social-format-conversion-voice/42-UI-SPEC.md
|
||||
@.planning/phases/42-wallpapers-social-format-conversion-voice/42-03-SUMMARY.md
|
||||
|
||||
@ui/src/App.tsx
|
||||
@ui/src/api/contentJobs.ts
|
||||
@ui/src/api/client.ts
|
||||
@ui/src/hooks/useContentJob.ts
|
||||
@ui/src/components/ChatFileDropZone.tsx
|
||||
</context>
|
||||
|
||||
<interfaces>
|
||||
From server/src/routes/convert.ts (Plan 03):
|
||||
```
|
||||
POST /api/companies/:companyId/convert — multipart/form-data with fields: file, targetFormat
|
||||
Returns: 202 { jobId, status: "queued" }
|
||||
Error: 422 { error, actualMime, claimedMime }
|
||||
|
||||
GET /api/system/converters
|
||||
Returns: { imageConverter: boolean, audioVideoConverter: boolean, docConverter: boolean, dataConverter: boolean }
|
||||
```
|
||||
|
||||
From server/src/services/renderers/types.ts:
|
||||
```typescript
|
||||
interface ConvertBundle {
|
||||
type: "convert-bundle";
|
||||
outputFilename: string;
|
||||
outputMime: string;
|
||||
outputBase64: string;
|
||||
method: "direct" | "ai-bridge";
|
||||
}
|
||||
```
|
||||
</interfaces>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 1: Create convert API client and ConvertPanel component</name>
|
||||
<files>ui/src/api/convert.ts, ui/src/components/ConvertPanel.tsx</files>
|
||||
<read_first>ui/src/api/contentJobs.ts, ui/src/api/client.ts, ui/src/components/ChatFileDropZone.tsx, ui/src/hooks/useContentJob.ts</read_first>
|
||||
<action>
|
||||
1. Create ui/src/api/convert.ts:
|
||||
- Export async function submitConvertJob(companyId: string, file: File, targetFormat: string): Promise<{ jobId: string; status: string } | { error: string; actualMime: string; claimedMime: string }>
|
||||
- Build FormData with file and targetFormat
|
||||
- POST to /api/companies/{companyId}/convert with FormData (do NOT set Content-Type header — browser sets multipart boundary)
|
||||
- If response status === 422: return the MIME validation error body
|
||||
- If response status === 202: return { jobId, status }
|
||||
- Throw on other errors
|
||||
- Export async function getConverterCapabilities(): Promise<{ imageConverter: boolean; audioVideoConverter: boolean; docConverter: boolean; dataConverter: boolean }>
|
||||
- GET /api/system/converters
|
||||
- Return JSON body
|
||||
|
||||
2. Create ui/src/components/ConvertPanel.tsx — this is a large component containing three visual zones:
|
||||
|
||||
**FORMAT_GROUPS constant** (define at top of file):
|
||||
```typescript
|
||||
const FORMAT_GROUPS = {
|
||||
Images: ["png", "jpg", "svg", "webp", "gif"],
|
||||
"Audio/Video": ["mp3", "mp4", "wav", "ogg", "webm"],
|
||||
Documents: ["md", "html", "pdf", "docx"],
|
||||
Data: ["csv", "json", "xlsx"],
|
||||
};
|
||||
```
|
||||
|
||||
**Props:** { initialSourceFormat?: string; initialTargetFormat?: string; companyId: string }
|
||||
|
||||
**State:**
|
||||
- file: File | null
|
||||
- targetFormat: string | null (selected chip)
|
||||
- mimeError: { actualMime: string; claimedMime: string } | null
|
||||
- dragOver: boolean
|
||||
- capabilities: from getConverterCapabilities() (fetch on mount)
|
||||
- Use useContentJob(companyId) for job tracking after submit
|
||||
|
||||
**ConvertSourceZone (left column):**
|
||||
- Drag-drop zone: min-h-[120px], p-6, dashed border (2px dashed var(--border))
|
||||
- role="button", tabIndex={0}, aria-label="Upload file - drop here or press Enter to browse"
|
||||
- onKeyDown: Enter/Space triggers hidden file input click
|
||||
- onDragOver: set dragOver=true, prevent default
|
||||
- onDragLeave: set dragOver=false
|
||||
- onDrop: extract file, set dragOver=false
|
||||
- Idle state: bg-secondary border, copy "Drop a file here or click to browse" (or "Drop a {FORMAT} file here or click to browse" if initialSourceFormat provided)
|
||||
- Dragover state: bg-accent/30, border-primary, copy "Release to select"
|
||||
- After file selected: show filename (text-sm font-medium), file size (text-xs text-muted-foreground), detected MIME (text-xs font-mono text-muted-foreground)
|
||||
- MIME error state: bg-destructive/10, border-destructive, error copy: "File extension does not match content. Got {actualMime}, expected {claimedMime}."
|
||||
- Click anywhere in zone to re-select file
|
||||
|
||||
**ConvertTargetSelector (right column):**
|
||||
- Render FORMAT_GROUPS as sections. Each group has a label (text-sm font-medium) and a row of chips
|
||||
- Each chip: rounded-full px-3 py-1 text-xs font-medium
|
||||
- Idle: bg-muted text-muted-foreground. Selected: bg-primary text-primary-foreground
|
||||
- All chips are always selectable — never disabled (CONV-08: unavailable direct paths fall to AI bridge)
|
||||
- role="radiogroup" on container, role="radio" + aria-checked on each chip
|
||||
- When selected pair has no direct converter (check capabilities: e.g. Documents group when docConverter===false), show AI fallback notice below chips:
|
||||
"No direct converter for this pair - AI bridge will be used." (text-xs text-muted-foreground, Info icon 14px, bg-secondary rounded-md p-3)
|
||||
|
||||
**ConvertActionBar (full width below):**
|
||||
- "Convert File" Button (primary, disabled until file !== null AND targetFormat !== null)
|
||||
- On click: call submitConvertJob. If result has "error" key: set mimeError. If result has "jobId": track via useContentJob SSE.
|
||||
- Progress bar below button (same SSE pattern)
|
||||
- After job done with ConvertBundle: "Download {outputFilename}" Button (primary) — create blob from outputBase64, trigger download
|
||||
- Error state: "Conversion failed - {detail}. Try again."
|
||||
- Empty state: heading "No conversion yet", body "Upload a file and choose a target format to convert."
|
||||
|
||||
**Layout:** Two-column on desktop (grid grid-cols-1 md:grid-cols-2 gap-6), single column on mobile.
|
||||
|
||||
**prefers-reduced-motion:** Disable drag-drop zone color transition.
|
||||
|
||||
**Initial format pre-selection:** If initialSourceFormat or initialTargetFormat props provided, pre-select the corresponding chip (case-insensitive normalize per Pitfall 6).
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /opt/nexus/ui && npx tsc --noEmit 2>&1 | head -20</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- grep "submitConvertJob" ui/src/api/convert.ts
|
||||
- grep "getConverterCapabilities" ui/src/api/convert.ts
|
||||
- grep "FormData" ui/src/api/convert.ts
|
||||
- grep "FORMAT_GROUPS" ui/src/components/ConvertPanel.tsx
|
||||
- grep "ConvertSourceZone\|drag" ui/src/components/ConvertPanel.tsx
|
||||
- grep "role=\"radiogroup\"" ui/src/components/ConvertPanel.tsx
|
||||
- grep "AI bridge" ui/src/components/ConvertPanel.tsx
|
||||
- grep "Convert File" ui/src/components/ConvertPanel.tsx
|
||||
- grep "actualMime" ui/src/components/ConvertPanel.tsx
|
||||
- grep "initialSourceFormat" ui/src/components/ConvertPanel.tsx
|
||||
</acceptance_criteria>
|
||||
<done>Convert API client handles multipart upload and MIME errors. ConvertPanel has drag-drop zone, grouped format chips, AI fallback notice, and download.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: Create ConvertPage and wire routes in App.tsx</name>
|
||||
<files>ui/src/pages/ConvertPage.tsx, ui/src/App.tsx</files>
|
||||
<read_first>ui/src/App.tsx, ui/src/pages/ContentStudio.tsx</read_first>
|
||||
<action>
|
||||
1. Create ui/src/pages/ConvertPage.tsx:
|
||||
- Read sourceFormat and targetFormat from URL params using useParams()
|
||||
- Normalize params to lowercase (Pitfall 6: case-insensitive)
|
||||
- If params are invalid format strings not in FORMAT_GROUPS values, silently ignore (render default state)
|
||||
- Get companyId from context (same pattern as ContentStudio.tsx)
|
||||
- Render page heading "Convert File" (h1, heading style from UI spec)
|
||||
- Render ConvertPanel with { initialSourceFormat, initialTargetFormat, companyId }
|
||||
|
||||
2. Update ui/src/App.tsx:
|
||||
- Add lazy import for ConvertPage:
|
||||
```typescript
|
||||
const ConvertPage = lazy(() => import("./pages/ConvertPage").then(m => ({ default: m.ConvertPage })));
|
||||
```
|
||||
- Add three routes inside boardRoutes() function, after the content-studio route:
|
||||
```tsx
|
||||
<Route path="convert" element={<ConvertPage />} />
|
||||
<Route path="convert/:sourceFormat" element={<ConvertPage />} />
|
||||
<Route path="convert/:sourceFormat/:targetFormat" element={<ConvertPage />} />
|
||||
```
|
||||
|
||||
This gives deep-link support: /convert/png/svg pre-selects PNG as source filter and SVG as target chip.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /opt/nexus/ui && npx tsc --noEmit 2>&1 | head -20</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- grep "ConvertPage" ui/src/pages/ConvertPage.tsx
|
||||
- grep "sourceFormat" ui/src/pages/ConvertPage.tsx
|
||||
- grep "targetFormat" ui/src/pages/ConvertPage.tsx
|
||||
- grep "toLowerCase" ui/src/pages/ConvertPage.tsx
|
||||
- grep "ConvertPage" ui/src/App.tsx
|
||||
- grep "convert/:sourceFormat/:targetFormat" ui/src/App.tsx
|
||||
- grep "convert/:sourceFormat" ui/src/App.tsx
|
||||
</acceptance_criteria>
|
||||
<done>ConvertPage reads URL params for deep-link pre-selection. Three route variants wired in App.tsx. Format params normalized case-insensitively.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<verification>
|
||||
- `cd /opt/nexus/ui && npx tsc --noEmit` passes
|
||||
- /convert route renders ConvertPage with ConvertPanel
|
||||
- /convert/png/svg pre-selects PNG and SVG
|
||||
- All format chips are always selectable (never disabled)
|
||||
- AI fallback notice shows for pairs without direct converter
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- Drag-drop upload with correct idle/dragover/error states
|
||||
- Format chips grouped by category, all selectable
|
||||
- AI fallback notice for unavailable direct paths
|
||||
- Deep-link routing with case-insensitive format params
|
||||
- MIME validation error shown inline on magic-byte mismatch
|
||||
- Download button after successful conversion
|
||||
- tsc compiles cleanly
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
After completion, create `.planning/phases/42-wallpapers-social-format-conversion-voice/42-06-SUMMARY.md`
|
||||
</output>
|
||||
|
|
@ -0,0 +1,130 @@
|
|||
---
|
||||
phase: 42-wallpapers-social-format-conversion-voice
|
||||
plan: 06
|
||||
subsystem: ui
|
||||
tags: [react, typescript, drag-drop, format-conversion, deep-link, SSE, FormData, multipart, shadcn, lucide]
|
||||
|
||||
# Dependency graph
|
||||
requires:
|
||||
- phase: 42-wallpapers-social-format-conversion-voice
|
||||
plan: 03
|
||||
provides: POST /api/companies/:companyId/convert (multipart), GET /api/system/converters, ConvertBundle type
|
||||
- phase: 40-content-job-infra
|
||||
provides: useContentJob hook, getContentJobAsset, SSE job tracking pattern
|
||||
|
||||
provides:
|
||||
- ui/src/api/convert.ts — submitConvertJob (multipart) and getConverterCapabilities API client
|
||||
- ui/src/components/ConvertPanel.tsx — drag-drop + grouped format chips + AI fallback notice + download
|
||||
- ui/src/pages/ConvertPage.tsx — /convert route with deep-link URL param reading
|
||||
- ui/src/App.tsx — three route variants /convert, /convert/:src, /convert/:src/:tgt
|
||||
|
||||
affects:
|
||||
- Any future phase adding nav links to /convert
|
||||
|
||||
# Tech tracking
|
||||
tech-stack:
|
||||
added: []
|
||||
patterns:
|
||||
- Direct fetch (not api.post) for multipart submissions that need per-status-code inspection (422 vs 202)
|
||||
- Direct EventSource connection for pre-submitted jobs (bypasses useContentJob.submit which re-submits)
|
||||
- FORMAT_GROUPS constant exported from ConvertPanel for reuse in ConvertPage validation
|
||||
- normalizeFormatParam helper: toLowerCase + allowlist check, silently returns undefined for invalid params
|
||||
|
||||
key-files:
|
||||
created:
|
||||
- ui/src/api/convert.ts
|
||||
- ui/src/components/ConvertPanel.tsx
|
||||
- ui/src/pages/ConvertPage.tsx
|
||||
modified:
|
||||
- ui/src/App.tsx
|
||||
|
||||
key-decisions:
|
||||
- "Direct fetch used in submitConvertJob (not api.postForm) to inspect 422 vs 202 status before throwing — api.request() throws on !res.ok, but 422 is a valid business response with MIME error body"
|
||||
- "Direct EventSource in ConvertPanel for already-submitted jobs — useContentJob.submit() calls submitContentJob internally and cannot track an existing jobId without re-submitting"
|
||||
- "FORMAT_GROUPS exported from ConvertPanel so ConvertPage can import the same constant for allowlist validation without duplicating the list"
|
||||
- "AI fallback notice shown per target format group (Images/AV/Docs/Data) based on capabilities, not per individual format pair — matches CONV-08 spec that all paths fall to AI bridge when direct converter unavailable"
|
||||
|
||||
patterns-established:
|
||||
- "Format chip role=radio within role=radiogroup — matches accessibility spec from 42-UI-SPEC.md"
|
||||
- "Drop zone uses motion-safe: Tailwind variant to disable transition for prefers-reduced-motion"
|
||||
|
||||
requirements-completed: [CONV-01, CONV-02, CONV-03, CONV-04, CONV-05, CONV-06, CONV-07, CONV-08, CONV-09]
|
||||
|
||||
# Metrics
|
||||
duration: 12min
|
||||
completed: 2026-04-04
|
||||
---
|
||||
|
||||
# Phase 42 Plan 06: Format Conversion UI Summary
|
||||
|
||||
**Drag-drop ConvertPanel with grouped format chips, AI fallback notice, MIME error display, SSE job tracking, and deep-link /convert/:src/:tgt routing**
|
||||
|
||||
## Performance
|
||||
|
||||
- **Duration:** 12 min
|
||||
- **Started:** 2026-04-04T22:17:33Z
|
||||
- **Completed:** 2026-04-04T22:29:00Z
|
||||
- **Tasks:** 2
|
||||
- **Files modified:** 4 (3 created, 1 modified)
|
||||
|
||||
## Accomplishments
|
||||
|
||||
- Created convert.ts API client: submitConvertJob with direct fetch for 422/202 status differentiation; getConverterCapabilities for startup capability probe
|
||||
- Built ConvertPanel with all three visual zones: ConvertSourceZone (drag-drop with idle/dragover/error states), ConvertTargetSelector (grouped chips with AI fallback notice), ConvertActionBar (disabled until ready, download after done)
|
||||
- FORMAT_GROUPS constant drives chip rendering and is exported for ConvertPage format validation
|
||||
- ConvertPage reads URL params, normalizes to lowercase, silently ignores invalid format strings
|
||||
- Three route variants wired in App.tsx: /convert, /convert/:sourceFormat, /convert/:sourceFormat/:targetFormat
|
||||
|
||||
## Task Commits
|
||||
|
||||
1. **Task 1: Create convert API client and ConvertPanel component** - `c0040f66` (feat)
|
||||
2. **Task 2: Create ConvertPage and wire routes in App.tsx** - `b5587c03` (feat)
|
||||
|
||||
## Files Created/Modified
|
||||
|
||||
- `ui/src/api/convert.ts` - submitConvertJob (multipart POST, 422/202 handling), getConverterCapabilities
|
||||
- `ui/src/components/ConvertPanel.tsx` - Full ConvertPanel: drag-drop zone, FORMAT_GROUPS chips, AI fallback notice, SSE progress, download
|
||||
- `ui/src/pages/ConvertPage.tsx` - Route page reading sourceFormat/targetFormat URL params with case-insensitive normalization
|
||||
- `ui/src/App.tsx` - Added ConvertPage lazy import and three /convert route variants inside boardRoutes()
|
||||
|
||||
## Decisions Made
|
||||
|
||||
- **Direct fetch in submitConvertJob** — api.request() throws on any !res.ok response, but 422 is a valid business response carrying the MIME mismatch error body. Using raw fetch lets us return it as a ConvertMimeError value instead of catching and re-parsing an ApiError.
|
||||
- **Direct EventSource for pre-submitted jobs** — useContentJob.submit() calls submitContentJob internally; it has no "track existing job" API. Since submitConvertJob already dispatched the convert job via the multipart route (not contentJobs route), a standalone EventSource connection is used to track progress.
|
||||
- **FORMAT_GROUPS exported from ConvertPanel** — ConvertPage needs the same allowlist for normalizeFormatParam validation. Exporting from ConvertPanel avoids duplication and keeps them in sync.
|
||||
- **AI fallback notice per group** — The capabilities object returns converter availability per category (imageConverter, audioVideoConverter, docConverter, dataConverter), not per format pair. Showing the notice at group level matches the granularity available from /api/system/converters.
|
||||
|
||||
## Deviations from Plan
|
||||
|
||||
None — plan executed exactly as written.
|
||||
|
||||
## Known Stubs
|
||||
|
||||
None — all conversion flows are fully wired to real backend endpoints.
|
||||
|
||||
## Issues Encountered
|
||||
|
||||
None — tsc compiles cleanly for all new/modified files. 17 pre-existing errors in unrelated files (AgentConfigForm, useKeyboardShortcuts, useNexusMode, usePiperTts, useVadRecorder, ContentStudio, PersonalAssistant) were present before this plan and not introduced here.
|
||||
|
||||
## User Setup Required
|
||||
|
||||
None — no external service configuration required.
|
||||
|
||||
## Next Phase Readiness
|
||||
|
||||
- All CONV requirements fulfilled (CONV-01 through CONV-09)
|
||||
- /convert, /convert/:src, and /convert/:src/:tgt routes live and navigable
|
||||
- Phase 42 is complete — all 6 plans executed
|
||||
|
||||
---
|
||||
*Phase: 42-wallpapers-social-format-conversion-voice*
|
||||
*Completed: 2026-04-04*
|
||||
|
||||
## Self-Check: PASSED
|
||||
|
||||
- `ui/src/api/convert.ts` — FOUND
|
||||
- `ui/src/components/ConvertPanel.tsx` — FOUND
|
||||
- `ui/src/pages/ConvertPage.tsx` — FOUND
|
||||
- `.planning/phases/42-wallpapers-social-format-conversion-voice/42-06-SUMMARY.md` — FOUND
|
||||
- Task 1 commit `c0040f66` — FOUND
|
||||
- Task 2 commit `b5587c03` — FOUND
|
||||
|
|
@ -0,0 +1,41 @@
|
|||
# Phase 42: Wallpapers, Social, Format Conversion & Voice - Context
|
||||
|
||||
**Gathered:** 2026-04-04
|
||||
**Status:** Ready for planning
|
||||
**Mode:** Auto-generated (discuss skipped via workflow.skip_discuss)
|
||||
|
||||
<domain>
|
||||
## Phase Boundary
|
||||
|
||||
Users can generate platform-ready images (wallpapers, OG images, social banners) via the Satori pipeline, convert between any file format pair, and record voice directly in web chat via the Whisper mic button
|
||||
|
||||
</domain>
|
||||
|
||||
<decisions>
|
||||
## Implementation Decisions
|
||||
|
||||
### Claude's Discretion
|
||||
All implementation choices are at Claude's discretion — discuss phase was skipped per user setting. Use ROADMAP phase goal, success criteria, and codebase conventions to guide decisions.
|
||||
|
||||
</decisions>
|
||||
|
||||
<code_context>
|
||||
## Existing Code Insights
|
||||
|
||||
Codebase context will be gathered during plan-phase research.
|
||||
|
||||
</code_context>
|
||||
|
||||
<specifics>
|
||||
## Specific Ideas
|
||||
|
||||
No specific requirements — discuss phase skipped. Refer to ROADMAP phase description and success criteria.
|
||||
|
||||
</specifics>
|
||||
|
||||
<deferred>
|
||||
## Deferred Ideas
|
||||
|
||||
None — discuss phase skipped.
|
||||
|
||||
</deferred>
|
||||
|
|
@ -0,0 +1,658 @@
|
|||
# Phase 42: Wallpapers, Social, Format Conversion & Voice — Research
|
||||
|
||||
**Researched:** 2026-04-04
|
||||
**Domain:** Image generation (sharp/SVG), format conversion (sharp/ffmpeg-static/AI-bridge), social text generation (LLM), voice transcription (Whisper)
|
||||
**Confidence:** HIGH
|
||||
|
||||
---
|
||||
|
||||
<user_constraints>
|
||||
## User Constraints (from CONTEXT.md)
|
||||
|
||||
### Locked Decisions
|
||||
All implementation choices are at Claude's discretion — discuss phase was skipped per user setting.
|
||||
|
||||
### Claude's Discretion
|
||||
All implementation choices are at Claude's discretion. Use ROADMAP phase goal, success criteria, and codebase conventions to guide decisions.
|
||||
|
||||
### Deferred Ideas (OUT OF SCOPE)
|
||||
None — discuss phase skipped.
|
||||
</user_constraints>
|
||||
|
||||
---
|
||||
|
||||
<phase_requirements>
|
||||
## Phase Requirements
|
||||
|
||||
| ID | Description | Research Support |
|
||||
|----|-------------|------------------|
|
||||
| WALL-01 | User can generate desktop and mobile wallpapers from a description | SVG-via-LLM + sharp rasterize at target dimensions; PLATFORM_DIMENSIONS constants in renderer |
|
||||
| WALL-02 | User can generate social media banners with correct dimensions per platform | Same renderer; platform map covers OG Image, Twitter Card, Instagram, LinkedIn |
|
||||
| WALL-03 | User can generate Open Graph and social preview images | Same renderer; OG Image = 1200×630 constant |
|
||||
| WALL-04 | User can generate app icons and favicons in multiple sizes | Renderer returns multi-size bundle (1024, 512, 256, 64, 32); WallpaperPreview renders grid |
|
||||
| SOCIAL-01 | User can generate platform-ready posts respecting character limits (Twitter, LinkedIn) | LLM prompt with platform limit injected; character count UI enforced per-platform constants |
|
||||
| SOCIAL-02 | User can generate Instagram carousels and thread sequences | LLM returns JSON with slides array; carousel rendered as numbered collapsible sections |
|
||||
| SOCIAL-03 | System suggests relevant hashtags for generated content | LLM prompt requests hashtag suggestions as JSON array alongside post text |
|
||||
| CONV-01 | User can convert between image formats (PNG, JPG, SVG, WebP, GIF) via sharp | sharp 0.34.5 already installed; supports all listed formats |
|
||||
| CONV-02 | User can convert between audio/video formats via ffmpeg | ffmpeg-static 7.0.2 already installed and verified working |
|
||||
| CONV-03 | User can convert between document formats via Pandoc/LibreOffice | pandoc/libreoffice NOT installed → falls to AI-bridge per CONV-08 |
|
||||
| CONV-04 | User can convert between data formats (CSV, JSON, XLSX) | xlsx + csv-parse packages needed; pure-Node.js conversion |
|
||||
| CONV-05 | User can convert between any format pair via AI-bridged conversion | puterChatComplete already established; handles unsupported pairs |
|
||||
| CONV-06 | System provides conversion UI with source/target format selection and drag-drop | Standalone /convert page; ConvertPanel as described in UI spec |
|
||||
| CONV-07 | User can deep-link to specific conversion flows via URL | /convert/:sourceFormat?/:targetFormat? route in App.tsx; pre-select chips on mount |
|
||||
| CONV-08 | System detects available direct converters at startup | Startup probe service; GET /api/system/converters endpoint |
|
||||
| CONV-09 | System validates uploaded file MIME type via magic-byte detection | file-type@22.0.0 (ESM, ships own types); validate at convert route before job dispatch |
|
||||
| VOICE-01 | User can click mic button in web chat to record and auto-transcribe via Whisper | VoiceMicButton already in ChatInput when enableVoiceInput=true; already wired |
|
||||
| VOICE-02 | User can toggle between text-only, voice-input, and full-voice modes | VoiceModeToggle already exists; already wired in ChatInput; Phase 42 verifies correctness |
|
||||
| VOICE-03 | Voice input works offline with local Whisper model | voice-pipeline.ts already probes whisper-cpp → openai-whisper; WHISPER_MODEL env var + offline badge |
|
||||
</phase_requirements>
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
Phase 42 extends the Phase 41 content generation system with four new capabilities: platform-aware image generation (wallpapers, OG images, social banners, app icons), LLM-driven social post generation with hashtag suggestions, a full-featured file format conversion pipeline, and offline voice input via Whisper.
|
||||
|
||||
The server already has all critical dependencies for images (sharp@0.34.5, @resvg/resvg-js@2.6.2) and audio/video (ffmpeg-static@7.0.2 — verified working at /opt/nexus/node_modules/.pnpm/ffmpeg-static@5.3.0). Three packages need to be added: `file-type@22.0.0` (magic-byte MIME detection), `xlsx@0.18.5` (XLSX data conversion), and `csv-parse@6.2.1` (CSV parsing). Document conversion (pandoc/libreoffice) is not available on this system and will fall through to AI-bridge per CONV-08 — no installation needed.
|
||||
|
||||
The voice pipeline (`voice-pipeline.ts`) already handles Whisper probe and transcription. Phase 42's voice work is: (1) add `WHISPER_MODEL=local` env var support to signal offline capability, (2) expose whisper availability to the UI via the existing `/api/system/providers` endpoint (already returns `whisperAvailable`), (3) render the "Offline" badge in `ChatInput` alongside `VoiceMicButton`. The VoiceMicButton, VoiceModeToggle, and `enableVoiceInput=true` wiring already exist in `ChatPanel.tsx`.
|
||||
|
||||
**Primary recommendation:** Follow the established Phase 41 renderer pattern: add four new `jobType` cases to `content-job-runner.ts` (`wallpaper`, `social-post`, `convert`), create one renderer file per job type in `server/src/services/renderers/`, and wire three new ContentStudio tabs + one standalone `/convert` page in the UI.
|
||||
|
||||
---
|
||||
|
||||
## Standard Stack
|
||||
|
||||
### Core (all verified installed in server)
|
||||
|
||||
| Library | Version | Purpose | Why Standard |
|
||||
|---------|---------|---------|--------------|
|
||||
| sharp | 0.34.5 | Image format conversion + SVG rasterization at target dimensions | Already installed; used by icon-renderer and org-chart-svg |
|
||||
| @resvg/resvg-js | 2.6.2 | High-fidelity SVG→PNG rasterization with fitTo dimensions | Already installed; used by diagram-renderer |
|
||||
| ffmpeg-static | 5.3.0 (bin: 7.0.2) | Bundled ffmpeg binary for audio/video conversion | Already installed; used by voice-pipeline and telegram |
|
||||
| culori | 4.0.2 | OKLCH color math (not directly needed but available) | Already installed |
|
||||
| puterChatComplete | (internal) | LLM inference for wallpaper SVG generation, social posts, AI-bridge conversion | Established pattern in Phase 41 renderers |
|
||||
|
||||
### New Dependencies (needs `pnpm add` in server)
|
||||
|
||||
| Library | Version | Purpose | Why Standard |
|
||||
|---------|---------|---------|--------------|
|
||||
| file-type | 22.0.0 | Magic-byte MIME type detection for CONV-09 | ESM-native, ships own types, well-maintained |
|
||||
| xlsx | 0.18.5 | XLSX read/write for data conversion CONV-04 | Most-used Excel library for Node.js |
|
||||
| csv-parse | 6.2.1 | CSV parsing for data conversion CONV-04 | De-facto standard, streaming API |
|
||||
| @types/xlsx | 0.0.36 | TypeScript types for xlsx | xlsx ships types/index.d.ts but @types available |
|
||||
|
||||
### Alternatives Considered
|
||||
|
||||
| Instead of | Could Use | Tradeoff |
|
||||
|------------|-----------|----------|
|
||||
| file-type@22 (ESM) | mmmagic or mime-magic | file-type is pure JS, no native binding, ships own types; server is type:module so ESM is fine |
|
||||
| xlsx | exceljs | xlsx is simpler API for read/write; exceljs has streaming but more complex |
|
||||
| sharp for SVG rasterization | Playwright (like diagram-renderer) | sharp+resvg is faster for simple SVG → PNG; Playwright only needed for JavaScript-rendered content |
|
||||
|
||||
**Installation:**
|
||||
```bash
|
||||
# Run from /opt/nexus/server
|
||||
pnpm add file-type@22.0.0 xlsx@0.18.5 csv-parse@6.2.1
|
||||
pnpm add -D @types/xlsx@0.0.36
|
||||
```
|
||||
|
||||
**Version verification (run before installing):**
|
||||
```bash
|
||||
npm view file-type version # → 22.0.0
|
||||
npm view xlsx version # → 0.18.5
|
||||
npm view csv-parse version # → 6.2.1
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Architecture Patterns
|
||||
|
||||
### Established Renderer Pattern (from Phase 41)
|
||||
|
||||
Every new capability follows this exact structure:
|
||||
|
||||
1. **Renderer file:** `server/src/services/renderers/{name}-renderer.ts` exports `async function render{Name}(input: Record<string, unknown>): Promise<RenderResult>`
|
||||
2. **Job runner switch:** Add `case '{jobtype}':` to `renderContent()` in `content-job-runner.ts`
|
||||
3. **Bundle type (if needed):** Add `interface {Name}Bundle` to `types.ts`
|
||||
4. **API route:** Submit via existing `POST /api/companies/:id/content-jobs` with `{ jobType, input }`
|
||||
5. **UI hook:** `useContentJob(companyId)` already handles all SSE + state management
|
||||
6. **UI component:** Panel reads `job.bundle` after `status === 'done'`
|
||||
|
||||
The format conversion job is the only exception — it requires a separate multipart upload route because the file binary cannot be passed as JSON input via the standard content-jobs endpoint.
|
||||
|
||||
### Recommended Project Structure (new files)
|
||||
|
||||
```
|
||||
server/src/
|
||||
├── services/renderers/
|
||||
│ ├── types.ts # ADD: WallpaperBundle, SocialPostBundle, ConvertBundle
|
||||
│ ├── wallpaper-renderer.ts # NEW
|
||||
│ ├── social-renderer.ts # NEW
|
||||
│ └── convert-renderer.ts # NEW
|
||||
├── services/
|
||||
│ └── converter-capabilities.ts # NEW: startup probe + cache
|
||||
└── routes/
|
||||
└── convert.ts # NEW: POST /api/companies/:id/convert (multipart)
|
||||
# GET /api/system/converters
|
||||
|
||||
ui/src/
|
||||
├── pages/
|
||||
│ └── ConvertPage.tsx # NEW: standalone /convert page
|
||||
├── components/
|
||||
│ ├── WallpaperGeneratePanel.tsx # NEW
|
||||
│ ├── WallpaperPreview.tsx # NEW
|
||||
│ ├── SocialPostPanel.tsx # NEW
|
||||
│ ├── SocialPostResult.tsx # NEW
|
||||
│ └── ConvertPanel.tsx # NEW (contains ConvertSourceZone + ConvertTargetSelector + ConvertActionBar)
|
||||
└── api/
|
||||
└── convert.ts # NEW: submitConvertJob (multipart), getConverterCapabilities
|
||||
```
|
||||
|
||||
### Pattern 1: Wallpaper Generation (WALL-01 to WALL-04)
|
||||
|
||||
**What:** LLM generates an SVG at a conceptual level, then sharp rasterizes it to exact pixel dimensions for the requested platform.
|
||||
**When to use:** Any fixed-dimension image asset (wallpaper, OG image, social banner, app icon).
|
||||
|
||||
```typescript
|
||||
// Source: established pattern from icon-renderer.ts + sharp resize
|
||||
// server/src/services/renderers/wallpaper-renderer.ts
|
||||
|
||||
export const PLATFORM_DIMENSIONS: Record<string, { width: number; height: number; label: string }> = {
|
||||
"desktop-hd": { width: 2560, height: 1440, label: "Desktop HD (2560 × 1440)" },
|
||||
"desktop-fhd": { width: 1920, height: 1080, label: "Desktop FHD (1920 × 1080)" },
|
||||
"desktop-4k": { width: 3840, height: 2160, label: "Desktop 4K (3840 × 2160)" },
|
||||
"mobile-portrait": { width: 1080, height: 1920, label: "Mobile Portrait (1080 × 1920)" },
|
||||
"mobile-landscape": { width: 1920, height: 1080, label: "Mobile Landscape (1920 × 1080)" },
|
||||
"og-image": { width: 1200, height: 630, label: "OG Image (1200 × 630)" },
|
||||
"twitter-card": { width: 1200, height: 628, label: "Twitter Card (1200 × 628)" },
|
||||
"instagram-post": { width: 1080, height: 1080, label: "Instagram Post (1080 × 1080)" },
|
||||
"instagram-banner": { width: 1080, height: 566, label: "Instagram Banner (1080 × 566)" },
|
||||
"linkedin-banner": { width: 1584, height: 396, label: "LinkedIn Banner (1584 × 396)" },
|
||||
"app-icon": { width: 1024, height: 1024, label: "App Icon (1024 × 1024)" },
|
||||
"favicon": { width: 32, height: 32, label: "Favicon (32 × 32)" },
|
||||
};
|
||||
|
||||
// App icon + favicon: render multiple sizes from one SVG
|
||||
const APP_ICON_SIZES = [1024, 512, 256, 64, 32] as const;
|
||||
|
||||
// Render flow:
|
||||
// 1. puterChatComplete → SVG string (LLM generates SVG matching aspect ratio)
|
||||
// 2. sharp(svgBuffer).resize(width, height, { fit: 'fill' }).png() → PNG buffer
|
||||
// 3. Return WallpaperBundle with pngBase64 + dimensions
|
||||
```
|
||||
|
||||
**Critical constraint:** Platform dimensions MUST be constants, never magic numbers (success criterion 1). Export `PLATFORM_DIMENSIONS` from the renderer and re-export to the UI API client so the UI's Select options derive from the same source.
|
||||
|
||||
### Pattern 2: Format Conversion Architecture (CONV-01 to CONV-09)
|
||||
|
||||
**What:** Multipart upload endpoint validates MIME, stores base64 in job input, dispatch to converter renderer which routes to sharp/ffmpeg/xlsx/AI-bridge based on format pair.
|
||||
**Why separate route:** Content-jobs POST accepts JSON; file binary needs multipart handling.
|
||||
|
||||
```typescript
|
||||
// server/src/routes/convert.ts — new multipart route
|
||||
// POST /api/companies/:companyId/convert
|
||||
|
||||
import multer from "multer";
|
||||
import { fileTypeFromBuffer } from "file-type";
|
||||
|
||||
router.post("/companies/:companyId/convert", async (req, res) => {
|
||||
// 1. multer.memoryStorage() upload (limit: MAX_ATTACHMENT_BYTES)
|
||||
// 2. fileTypeFromBuffer(file.buffer) → detected MIME
|
||||
// 3. Compare detected MIME against file extension claim
|
||||
// 4. If mismatch: res.status(422).json({ error: "...", actualMime, claimedMime })
|
||||
// 5. job input: { fileBase64: buffer.toString('base64'), sourceMime, targetFormat, originalFilename }
|
||||
// 6. contentJobStore.create + contentJobRunner.dispatch
|
||||
// 7. res.status(202).json({ jobId, status })
|
||||
});
|
||||
|
||||
// GET /api/system/converters — capability map for UI
|
||||
router.get("/system/converters", async (_req, res) => {
|
||||
const caps = await converterCapabilitiesService().get();
|
||||
res.json(caps);
|
||||
// Returns: { imageConverter: true, audioVideoConverter: true, docConverter: false, dataConverter: true }
|
||||
});
|
||||
```
|
||||
|
||||
```typescript
|
||||
// server/src/services/renderers/convert-renderer.ts
|
||||
|
||||
async function renderConvert(input: Record<string, unknown>): Promise<RenderResult> {
|
||||
const { fileBase64, sourceMime, targetFormat } = input;
|
||||
const fileBuffer = Buffer.from(fileBase64 as string, "base64");
|
||||
|
||||
// Route by format category:
|
||||
if (isImageFormat(sourceMime) && isImageFormat(targetFormat)) {
|
||||
return convertImageViaSharp(fileBuffer, sourceMime, targetFormat);
|
||||
}
|
||||
if (isAudioVideoFormat(sourceMime) || isAudioVideoFormat(targetFormat)) {
|
||||
return convertAVViaFfmpeg(fileBuffer, sourceMime, targetFormat);
|
||||
}
|
||||
if (isDataFormat(sourceMime) || isDataFormat(targetFormat)) {
|
||||
return convertDataFormat(fileBuffer, sourceMime, targetFormat);
|
||||
}
|
||||
// All other pairs: AI bridge (CONV-05)
|
||||
return convertViaAiBridge(fileBuffer, sourceMime, targetFormat);
|
||||
}
|
||||
```
|
||||
|
||||
### Pattern 3: Converter Capability Probe (CONV-08)
|
||||
|
||||
```typescript
|
||||
// server/src/services/converter-capabilities.ts
|
||||
// Probe at startup, cache result (same pattern as hardwareService)
|
||||
|
||||
let cache: ConverterCapabilities | null = null;
|
||||
|
||||
export interface ConverterCapabilities {
|
||||
imageConverter: boolean; // sharp — always true (npm dep)
|
||||
audioVideoConverter: boolean; // ffmpeg-static — always true (npm dep)
|
||||
docConverter: boolean; // pandoc or libreoffice — probe at startup
|
||||
dataConverter: boolean; // xlsx + csv-parse — always true (npm dep)
|
||||
}
|
||||
|
||||
export function converterCapabilitiesService() {
|
||||
async function get(): Promise<ConverterCapabilities> {
|
||||
if (cache) return cache;
|
||||
let docConverter = false;
|
||||
try {
|
||||
await execFileAsync("pandoc", ["--version"], { timeout: 2000 });
|
||||
docConverter = true;
|
||||
} catch {
|
||||
try {
|
||||
await execFileAsync("libreoffice", ["--version"], { timeout: 2000 });
|
||||
docConverter = true;
|
||||
} catch { /* not available */ }
|
||||
}
|
||||
cache = { imageConverter: true, audioVideoConverter: true, docConverter, dataConverter: true };
|
||||
return cache;
|
||||
}
|
||||
return { get };
|
||||
}
|
||||
```
|
||||
|
||||
### Pattern 4: Social Post Generation (SOCIAL-01 to SOCIAL-03)
|
||||
|
||||
```typescript
|
||||
// server/src/services/renderers/social-renderer.ts
|
||||
|
||||
export const PLATFORM_CHAR_LIMITS: Record<string, number> = {
|
||||
"twitter-x": 280,
|
||||
"linkedin": 3000,
|
||||
"instagram-caption": 2200,
|
||||
"instagram-carousel": 300, // per slide
|
||||
};
|
||||
|
||||
// LLM prompt asks for JSON: { post: string, hashtags: string[], slides?: string[] }
|
||||
// For carousel: slides array, each under 300 chars
|
||||
// puterChatComplete returns JSON; renderer parses + validates
|
||||
```
|
||||
|
||||
### Pattern 5: Voice Offline Badge (VOICE-03)
|
||||
|
||||
The voice pipeline already handles Whisper detection. Phase 42 adds two things:
|
||||
|
||||
1. **Server:** `WHISPER_MODEL` env var read in `voice-pipeline.ts` — when set to `"local"`, include `"local"` in nexus-settings response or expose via `GET /api/system/providers` (already returns `whisperAvailable` from `hardwareService().detect()`).
|
||||
|
||||
2. **UI:** In `ChatInput.tsx`, read `whisperAvailable` from a `useConverterCapabilities()` or `useSystemProviders()` hook. Show `<span aria-label="Voice input is offline (local model)">Offline</span>` next to `VoiceMicButton` when `whisperAvailable === true`.
|
||||
|
||||
**IMPORTANT:** The existing `GET /api/system/providers` already returns `{ whisperAvailable: boolean, piperAvailable: boolean, ... }` — no new endpoint needed. Create a `useSystemProviders()` hook that calls this endpoint once on mount.
|
||||
|
||||
### Pattern 6: ContentStudio Tab Extension + Standalone Convert Page
|
||||
|
||||
```typescript
|
||||
// ui/src/pages/ContentStudio.tsx — extend TabsList
|
||||
// Add three TabsTriggers: "Wallpapers", "Social", "Convert"
|
||||
// "Convert" tab value triggers navigate() to /convert (standalone page)
|
||||
// TabsContent for wallpapers and social are normal panel components
|
||||
// TabsContent for convert is NOT a content panel — the tab click navigates away
|
||||
|
||||
// ui/src/App.tsx — add new routes in boardRoutes()
|
||||
<Route path="content-studio" element={<ContentStudio />} />
|
||||
<Route path="convert" element={<ConvertPage />} />
|
||||
<Route path="convert/:sourceFormat" element={<ConvertPage />} />
|
||||
<Route path="convert/:sourceFormat/:targetFormat" element={<ConvertPage />} />
|
||||
```
|
||||
|
||||
### Anti-Patterns to Avoid
|
||||
|
||||
- **Magic number dimensions:** Never hardcode `2560` or `1440` in component code — always read from `PLATFORM_DIMENSIONS` constant exported from renderer or a shared types file.
|
||||
- **Passing file buffer as base64 in SSE-triggered jobs with >10MB files:** The 10MB multer limit prevents oversized uploads; document this clearly in the convert route.
|
||||
- **Blocking HTTP on render:** All conversion dispatched fire-and-forget via `contentJobRunner.dispatch()`. The POST /convert route returns 202 immediately.
|
||||
- **Showing format pairs as "unavailable":** Per CONV-08, all format pairs are selectable in the UI. Unavailable direct converters show the AI fallback notice, never a disabled/grey chip.
|
||||
- **Creating a separate `/api/convert/validate` endpoint:** Validate at job submit time in the convert route (simpler, fewer round trips). The UI spec notes this as an OR condition.
|
||||
- **Satori for wallpaper generation:** Satori is NOT installed. Use the established pattern: LLM generates SVG → sharp rasterizes to exact dimensions. Satori would require JSX rendering infrastructure not needed here.
|
||||
|
||||
---
|
||||
|
||||
## Don't Hand-Roll
|
||||
|
||||
| Problem | Don't Build | Use Instead | Why |
|
||||
|---------|-------------|-------------|-----|
|
||||
| MIME type detection from file bytes | Custom magic-byte reader | `file-type@22.0.0` | Handles 500+ MIME types, handles edge cases like truncated files, streaming API |
|
||||
| XLSX read/write | Custom binary parser | `xlsx@0.18.5` | XLSX format is complex binary (OOXML); hand-rolling is weeks of work |
|
||||
| CSV parsing | String.split() | `csv-parse@6.2.1` | Handles quoted fields, escaped commas, multiline values, BOM |
|
||||
| Image format conversion | Native buffer manipulation | `sharp@0.34.5` | Already installed; handles color spaces, ICC profiles, transparency |
|
||||
| Audio/video conversion | Custom codec wrappers | `ffmpeg-static@7.0.2` | Already installed; handles all codec negotiation |
|
||||
| SVG rasterization | canvas/Playwright | `@resvg/resvg-js@2.6.2` | Already installed; faster than Playwright for static SVG |
|
||||
| LLM inference | New HTTP client | `puterChatComplete()` | Already implemented in Phase 41; puter-inference.ts is the project standard |
|
||||
|
||||
**Key insight:** All heavy-lifting tools are already installed. Phase 42 is primarily wiring (new renderers + routes + UI panels) rather than infrastructure.
|
||||
|
||||
---
|
||||
|
||||
## Common Pitfalls
|
||||
|
||||
### Pitfall 1: Sharp SVG Input at Large Dimensions
|
||||
**What goes wrong:** `sharp(svgBuffer).resize(2560, 1440)` produces a blurry image when the SVG has a small implicit pixel density.
|
||||
**Why it happens:** Sharp defaults to 72 DPI for SVG input; scaling up produces raster artifacts before the resize step.
|
||||
**How to avoid:** Always pass `{ density: 300 }` option when loading SVG into sharp: `sharp(svgBuffer, { density: 300 }).resize(width, height, { fit: 'fill' }).png()`. Alternatively, ask the LLM to generate an SVG with `viewBox="0 0 {width} {height}"` matching the target dimensions, then use Resvg with `fitTo: { mode: 'width', value: width }`.
|
||||
**Warning signs:** Generated wallpapers look pixelated or blurry at edges.
|
||||
|
||||
### Pitfall 2: file-type v22 Import Syntax
|
||||
**What goes wrong:** `import FileType from 'file-type'` fails with "does not provide an export named 'default'".
|
||||
**Why it happens:** file-type v22 is pure ESM with named exports only.
|
||||
**How to avoid:** Use named import: `import { fileTypeFromBuffer } from 'file-type'`. Server is `type: module` with `module: NodeNext` — ESM imports work directly.
|
||||
**Warning signs:** TypeScript error TS2613 or runtime "is not a function" errors.
|
||||
|
||||
### Pitfall 3: ffmpeg-static Path Resolution
|
||||
**What goes wrong:** `spawn(ffmpegPath, ...)` throws ENOENT even though ffmpeg-static is installed.
|
||||
**Why it happens:** `ffmpegPath` from `import ffmpegPath from 'ffmpeg-static'` is the binary path string, but it needs `as unknown as string` cast due to TS type mismatch. The actual binary is at `/opt/nexus/node_modules/.pnpm/ffmpeg-static@5.3.0/node_modules/ffmpeg-static/ffmpeg`.
|
||||
**How to avoid:** Copy the existing pattern from `voice-pipeline.ts` exactly: `if (!ffmpegPath) throw new Error("ffmpeg-static binary not found"); const ffmpegBin = ffmpegPath as unknown as string;`.
|
||||
**Warning signs:** `ffmpegBin` is null/undefined; ENOENT on spawn.
|
||||
|
||||
### Pitfall 4: Content-Job Input Size for Conversion
|
||||
**What goes wrong:** Submitting a 10MB file as base64 in job input stores ~13.3MB of base64 in the `content_jobs.input` JSONB column per submission.
|
||||
**Why it happens:** base64 adds ~33% overhead. For a 10MB file (MAX_ATTACHMENT_BYTES), this is ~13.3MB per job row.
|
||||
**How to avoid:** This is acceptable for the single-user case (success criteria assume one conversion at a time). Document the max file size clearly in the UI (the multer limit enforces it). If this becomes a problem in future, change the renderer to accept storage object keys (requires extending content-job-runner signature).
|
||||
**Warning signs:** Postgres table growth visible in db metrics after many conversions.
|
||||
|
||||
### Pitfall 5: Social Carousel JSON Parsing from LLM
|
||||
**What goes wrong:** LLM returns markdown-fenced JSON or adds explanation text, causing `JSON.parse()` to throw.
|
||||
**Why it happens:** LLMs sometimes wrap JSON in ````json ... ```` fences.
|
||||
**How to avoid:** Post-process LLM output to strip markdown fences before JSON.parse(). Use a robust extraction pattern: `const match = raw.match(/```json\s*([\s\S]*?)\s*```/) || raw.match(/({[\s\S]*})/); JSON.parse(match ? match[1] : raw)`. Apply the same fix pattern used by icon-renderer.ts SVG validation.
|
||||
**Warning signs:** SocialPostResult shows "Generation failed" after seemingly valid LLM output.
|
||||
|
||||
### Pitfall 6: Deep-Link Route Parameter Case
|
||||
**What goes wrong:** `/convert/PNG/SVG` doesn't pre-select chips because the component does a case-sensitive compare against format names.
|
||||
**Why it happens:** URL params are case-sensitive; format chips may be stored as uppercase.
|
||||
**How to avoid:** Normalize URL params to lowercase on read: `params.sourceFormat?.toLowerCase()`. Match against chip identifiers using `formatId.toLowerCase() === param.toLowerCase()`.
|
||||
**Warning signs:** Deep-link URL works in one case but not when user types different casing.
|
||||
|
||||
### Pitfall 7: Voice Offline Badge Always Showing
|
||||
**What goes wrong:** The "Offline" badge shows even when whisper is not installed (whisperAvailable: false).
|
||||
**Why it happens:** Misreading the UI spec: badge shows when `whisperAvailable === true` (local model detected), not when `WHISPER_MODEL=local` env var is set (which is confusing naming).
|
||||
**How to avoid:** Read `whisperAvailable` from `GET /api/system/providers`. Show badge if `whisperAvailable === true`. The "offline capability" is proven by the binary being detected, not by an env var. The `WHISPER_MODEL` env var mentioned in the UI spec is a future extension point for model selection — do not implement it unless the spec is explicitly required. Per VOICE-03, "works offline with locally cached model" means the whisper-cpp binary + base model are present.
|
||||
**Warning signs:** Badge shows on machines where whisper is not installed.
|
||||
|
||||
---
|
||||
|
||||
## Code Examples
|
||||
|
||||
### Wallpaper Renderer: Sharp at Target Dimensions
|
||||
|
||||
```typescript
|
||||
// Source: icon-renderer.ts pattern + sharp resize extension
|
||||
// server/src/services/renderers/wallpaper-renderer.ts
|
||||
|
||||
import sharp from "sharp";
|
||||
import { puterChatComplete } from "../puter-inference.js";
|
||||
import type { RenderResult } from "./types.js";
|
||||
|
||||
async function renderSvgToWallpaper(svgString: string, width: number, height: number): Promise<Buffer> {
|
||||
return sharp(Buffer.from(svgString), { density: 300 })
|
||||
.resize(width, height, { fit: "fill" })
|
||||
.png({ compressionLevel: 9 })
|
||||
.toBuffer();
|
||||
}
|
||||
```
|
||||
|
||||
### Magic-Byte MIME Validation
|
||||
|
||||
```typescript
|
||||
// Source: file-type@22 documentation — ESM named import
|
||||
import { fileTypeFromBuffer } from "file-type";
|
||||
|
||||
async function validateMime(buffer: Buffer, claimedExtension: string): Promise<{ ok: boolean; actualMime?: string; claimedMime?: string }> {
|
||||
const detected = await fileTypeFromBuffer(buffer);
|
||||
if (!detected) return { ok: true }; // unknown type, allow (SVG/text files have no magic bytes)
|
||||
const mimeForExtension = extensionToMime(claimedExtension); // lookup table
|
||||
if (mimeForExtension && detected.mime !== mimeForExtension) {
|
||||
return { ok: false, actualMime: detected.mime, claimedMime: mimeForExtension };
|
||||
}
|
||||
return { ok: true };
|
||||
}
|
||||
```
|
||||
|
||||
### ffmpeg-static Conversion (audio/video)
|
||||
|
||||
```typescript
|
||||
// Source: voice-pipeline.ts pattern (established in Phase 36)
|
||||
import ffmpegPath from "ffmpeg-static";
|
||||
import { spawn } from "node:child_process";
|
||||
|
||||
if (!ffmpegPath) throw new Error("ffmpeg-static binary not found");
|
||||
const ffmpegBin = ffmpegPath as unknown as string;
|
||||
|
||||
function convertAVViaFfmpeg(inputBuffer: Buffer, sourceFormat: string, targetFormat: string): Promise<Buffer> {
|
||||
return new Promise<Buffer>((resolve, reject) => {
|
||||
const ffmpeg = spawn(ffmpegBin, [
|
||||
"-f", sourceFormat,
|
||||
"-i", "pipe:0",
|
||||
"-f", targetFormat,
|
||||
"pipe:1",
|
||||
], { stdio: ["pipe", "pipe", "pipe"] });
|
||||
const chunks: Buffer[] = [];
|
||||
ffmpeg.stdout.on("data", (c: Buffer) => chunks.push(c));
|
||||
ffmpeg.stderr.on("data", () => {}); // discard
|
||||
ffmpeg.on("close", (code) => code === 0 ? resolve(Buffer.concat(chunks)) : reject(new Error(`ffmpeg exited ${code}`)));
|
||||
ffmpeg.on("error", reject);
|
||||
ffmpeg.stdin.write(inputBuffer);
|
||||
ffmpeg.stdin.end();
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
### Data Format Conversion (CSV ↔ JSON ↔ XLSX)
|
||||
|
||||
```typescript
|
||||
// Source: xlsx documentation + csv-parse documentation
|
||||
import * as XLSX from "xlsx";
|
||||
import { parse as csvParse } from "csv-parse/sync";
|
||||
|
||||
// CSV → JSON
|
||||
function csvToJson(buffer: Buffer): Record<string, unknown>[] {
|
||||
return csvParse(buffer, { columns: true, skip_empty_lines: true });
|
||||
}
|
||||
|
||||
// JSON → XLSX
|
||||
function jsonToXlsx(data: Record<string, unknown>[]): Buffer {
|
||||
const ws = XLSX.utils.json_to_sheet(data);
|
||||
const wb = XLSX.utils.book_new();
|
||||
XLSX.utils.book_append_sheet(wb, ws, "Sheet1");
|
||||
return Buffer.from(XLSX.write(wb, { type: "buffer", bookType: "xlsx" }));
|
||||
}
|
||||
```
|
||||
|
||||
### useContentJob Pattern (UI — already exists)
|
||||
|
||||
```typescript
|
||||
// Source: ui/src/hooks/useContentJob.ts (Phase 41)
|
||||
// Usage in WallpaperGeneratePanel:
|
||||
const job = useContentJob(companyId);
|
||||
|
||||
// Submit
|
||||
job.submit("wallpaper", { prompt, platformKey: "desktop-hd" });
|
||||
|
||||
// Render result when done
|
||||
if (job.status === "done" && job.bundle) {
|
||||
const bundle = job.bundle as WallpaperBundle;
|
||||
// bundle.pngBase64, bundle.dimensions, bundle.platformKey
|
||||
}
|
||||
```
|
||||
|
||||
### Converter Capabilities in UI
|
||||
|
||||
```typescript
|
||||
// ui/src/hooks/useSystemProviders.ts (new)
|
||||
// Calls GET /api/system/providers once on mount, caches result
|
||||
// Returns: { whisperAvailable, piperAvailable, ... }
|
||||
// Used by ChatInput for offline badge, by ConvertTargetSelector for AI-fallback notice
|
||||
|
||||
// ui/src/hooks/useConverterCapabilities.ts (new)
|
||||
// Calls GET /api/system/converters once on mount
|
||||
// Returns: { imageConverter, audioVideoConverter, docConverter, dataConverter }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## State of the Art
|
||||
|
||||
| Old Approach | Current Approach | When Changed | Impact |
|
||||
|--------------|------------------|--------------|--------|
|
||||
| Manual MIME detection via extension | Magic-byte detection via file-type | file-type v19+ | Required for CONV-09 — extension can be spoofed |
|
||||
| Pandoc/LibreOffice for doc conversion | AI-bridge fallback when not available | CONV-08 design | No installer required; works everywhere |
|
||||
| Separate validate endpoint | Validate at submit time | UI spec v1 | Fewer round trips, simpler client code |
|
||||
|
||||
**Deprecated/outdated:**
|
||||
- `satori` for wallpaper generation: Not installed and not needed. The Phase 41 pattern (LLM SVG + sharp rasterize) is sufficient and consistent with existing code.
|
||||
- Separate `/api/convert/validate` endpoint: Consolidate validation into the convert submit route.
|
||||
|
||||
---
|
||||
|
||||
## Open Questions
|
||||
|
||||
1. **WallpaperBundle storage format**
|
||||
- What we know: Other bundles (DiagramBundle, IconSetBundle) store base64-encoded assets in JSON
|
||||
- What's unclear: For wallpapers at 2560×1440, the PNG can be 5–15MB — base64 encoding adds ~33% → 20MB JSON blob stored in content_jobs.output. MAX_GENERATED_ASSET_BYTES = 500MB so it fits, but row size may be large for Postgres.
|
||||
- Recommendation: Store the PNG as an asset (same as diagram-renderer stores to storage), and return `WallpaperBundle` with `assetId` + `dimensions` + `platformKey`. The UI downloads via `/api/assets/:id/content`. This avoids storing large base64 in the DB. Follow the same pattern if app-icon returns multiple sizes: store each size as a separate asset or as a multi-size ZIP.
|
||||
|
||||
2. **Convert job input size for large files**
|
||||
- What we know: base64(10MB file) = ~13.3MB JSON in content_jobs.input column
|
||||
- What's unclear: Whether Postgres/Drizzle has JSONB size limits that would reject this
|
||||
- Recommendation: Postgres JSONB has no practical size limit beyond the max row size (1GB). 13.3MB is fine. Document the 10MB upload cap in the UI.
|
||||
|
||||
3. **Social post carousel slide format**
|
||||
- What we know: SOCIAL-02 says "Instagram carousels and thread sequences"
|
||||
- What's unclear: Whether thread sequences means Twitter threads (numbered tweets) or just a generic multi-part structure
|
||||
- Recommendation: Implement as a unified `slides: string[]` field in SocialPostBundle. The `collapsible` sections in SocialPostResult handle both Twitter threads and Instagram carousel displays.
|
||||
|
||||
---
|
||||
|
||||
## Environment Availability
|
||||
|
||||
| Dependency | Required By | Available | Version | Fallback |
|
||||
|------------|------------|-----------|---------|----------|
|
||||
| sharp | CONV-01, WALL-01-04 | ✓ | 0.34.5 | — |
|
||||
| @resvg/resvg-js | WALL-01-04 | ✓ | 2.6.2 | — |
|
||||
| ffmpeg-static | CONV-02 | ✓ | 7.0.2 (binary) | — |
|
||||
| file-type | CONV-09 | ✗ | — | Install: `pnpm add file-type@22.0.0` |
|
||||
| xlsx | CONV-04 | ✗ | — | Install: `pnpm add xlsx@0.18.5` |
|
||||
| csv-parse | CONV-04 | ✗ | — | Install: `pnpm add csv-parse@6.2.1` |
|
||||
| pandoc | CONV-03 | ✗ | — | AI-bridge (CONV-08 design) |
|
||||
| libreoffice | CONV-03 | ✗ | — | AI-bridge (CONV-08 design) |
|
||||
| whisper-cpp | VOICE-03 | ✗ | — | openai-whisper CLI fallback; error message if neither |
|
||||
| whisper (openai) | VOICE-03 | ✗ | — | whisper-cpp fallback |
|
||||
| satori | Phase goal wording | ✗ | — | Not needed — use LLM SVG + sharp pattern |
|
||||
|
||||
**Missing dependencies with no fallback:**
|
||||
- `file-type`, `xlsx`, `csv-parse` — these MUST be installed in Wave 0. Phase cannot complete CONV-01/CONV-04/CONV-09 without them.
|
||||
|
||||
**Missing dependencies with fallback:**
|
||||
- `pandoc`, `libreoffice` — document conversion falls through to AI-bridge per CONV-08 design. Planner should add a startup probe that logs "pandoc not found, doc conversion will use AI bridge" rather than failing.
|
||||
- `whisper-cpp`, `whisper` — existing voice pipeline already handles both missing gracefully with an informative error. VOICE-03 "offline" badge is shown based on `whisperAvailable` from hardware detection.
|
||||
|
||||
---
|
||||
|
||||
## Validation Architecture
|
||||
|
||||
### Test Framework
|
||||
|
||||
| Property | Value |
|
||||
|----------|-------|
|
||||
| Framework | vitest 3.0.5 |
|
||||
| Server config | server/vitest.config.ts (environment: node) |
|
||||
| UI config | ui/vitest.config.ts (environment: node, react plugin) |
|
||||
| Quick run (server) | `cd /opt/nexus/server && npx vitest run src/__tests__/42-*.test.ts` |
|
||||
| Full suite (server) | `cd /opt/nexus/server && npx vitest run` |
|
||||
| Quick run (UI) | `cd /opt/nexus/ui && npx vitest run src/**/*.test.{ts,tsx}` |
|
||||
|
||||
**Note:** Server baseline has 4 pre-existing failing test files (hardware-detection, skill-registry-routes, agent-permissions, heartbeat-workspace-session) — these are NOT caused by Phase 42. Phase 42 tests must not add to this count.
|
||||
|
||||
### Phase Requirements → Test Map
|
||||
|
||||
| Req ID | Behavior | Test Type | Automated Command | File Exists? |
|
||||
|--------|----------|-----------|-------------------|-------------|
|
||||
| WALL-01/02/03 | `renderWallpaper()` returns PNG buffer at correct dimensions per platform key | unit | `npx vitest run src/__tests__/42-wallpaper-renderer.test.ts` | ❌ Wave 0 |
|
||||
| WALL-04 | App icon renderer returns multi-size array | unit | `npx vitest run src/__tests__/42-wallpaper-renderer.test.ts` | ❌ Wave 0 |
|
||||
| SOCIAL-01/02/03 | `renderSocialPost()` returns post text + hashtags; carousel returns slides array | unit | `npx vitest run src/__tests__/42-social-renderer.test.ts` | ❌ Wave 0 |
|
||||
| CONV-01 | Image conversion round-trip (PNG→JPG) via sharp | unit | `npx vitest run src/__tests__/42-convert-renderer.test.ts` | ❌ Wave 0 |
|
||||
| CONV-02 | Audio conversion dispatch calls ffmpeg-static binary | unit (mocked) | `npx vitest run src/__tests__/42-convert-renderer.test.ts` | ❌ Wave 0 |
|
||||
| CONV-04 | CSV→JSON and JSON→XLSX conversions | unit | `npx vitest run src/__tests__/42-convert-renderer.test.ts` | ❌ Wave 0 |
|
||||
| CONV-05 | Unknown pair falls through to AI bridge | unit | `npx vitest run src/__tests__/42-convert-renderer.test.ts` | ❌ Wave 0 |
|
||||
| CONV-08 | converterCapabilitiesService probes pandoc/libreoffice at startup | unit (mocked execFile) | `npx vitest run src/__tests__/42-converter-capabilities.test.ts` | ❌ Wave 0 |
|
||||
| CONV-09 | MIME mismatch rejected with 422 at convert route | unit (supertest) | `npx vitest run src/__tests__/42-convert-routes.test.ts` | ❌ Wave 0 |
|
||||
| VOICE-01/02 | VoiceMicButton renders in ChatInput when enableVoiceInput=true | manual (pre-existing wiring) | n/a — already wired in ChatPanel.tsx | ✅ Existing |
|
||||
| VOICE-03 | Offline badge shows when whisperAvailable=true from /api/system/providers | unit (mocked hook) | `npx vitest run src/**/*.test.tsx` (UI test) | ❌ Wave 0 |
|
||||
|
||||
### Sampling Rate
|
||||
|
||||
- **Per task commit:** `cd /opt/nexus/server && npx vitest run src/__tests__/42-*.test.ts`
|
||||
- **Per wave merge:** `cd /opt/nexus/server && npx vitest run` (full server suite)
|
||||
- **Phase gate:** Full server + UI suites green before `/gsd:verify-work`
|
||||
|
||||
### Wave 0 Gaps
|
||||
|
||||
- [ ] `server/src/__tests__/42-wallpaper-renderer.test.ts` — covers WALL-01 through WALL-04
|
||||
- [ ] `server/src/__tests__/42-social-renderer.test.ts` — covers SOCIAL-01 through SOCIAL-03
|
||||
- [ ] `server/src/__tests__/42-convert-renderer.test.ts` — covers CONV-01 through CONV-05
|
||||
- [ ] `server/src/__tests__/42-converter-capabilities.test.ts` — covers CONV-08
|
||||
- [ ] `server/src/__tests__/42-convert-routes.test.ts` — covers CONV-09 (MIME validation at HTTP layer)
|
||||
- [ ] UI test for offline badge rendering (VOICE-03)
|
||||
- [ ] Package install: `pnpm add file-type@22.0.0 xlsx@0.18.5 csv-parse@6.2.1 && pnpm add -D @types/xlsx@0.0.36`
|
||||
|
||||
---
|
||||
|
||||
## Sources
|
||||
|
||||
### Primary (HIGH confidence)
|
||||
|
||||
- Codebase direct read: `server/src/services/renderers/icon-renderer.ts` — renderer pattern, sharp usage
|
||||
- Codebase direct read: `server/src/services/renderers/diagram-renderer.ts` — Playwright + Resvg pattern
|
||||
- Codebase direct read: `server/src/services/content-job-runner.ts` — job dispatch architecture
|
||||
- Codebase direct read: `server/src/services/voice-pipeline.ts` — Whisper probe and transcription pattern
|
||||
- Codebase direct read: `server/src/routes/voice.ts` — multer upload pattern for binary input
|
||||
- Codebase direct read: `ui/src/hooks/useContentJob.ts` — SSE hook established in Phase 41
|
||||
- Codebase direct read: `ui/src/components/ChatInput.tsx` — existing VoiceMicButton wiring
|
||||
- Codebase direct read: `ui/src/hooks/useVoiceMode.ts` — existing voice mode settings pattern
|
||||
- Codebase direct read: `server/src/services/hardware.ts` — whisperAvailable detection, probe pattern
|
||||
- Codebase direct read: `server/src/routes/hardware.ts` — GET /api/system/providers returns whisperAvailable
|
||||
- Codebase direct read: `server/src/app.ts` — route mounting pattern
|
||||
- Codebase direct read: `server/package.json` — installed deps list
|
||||
- `npm view file-type version` → 22.0.0 (verified 2026-04-04)
|
||||
- `npm view xlsx version` → 0.18.5 (verified 2026-04-04)
|
||||
- `npm view csv-parse version` → 6.2.1 (verified 2026-04-04)
|
||||
- Binary probe: `/opt/nexus/node_modules/.pnpm/ffmpeg-static@5.3.0/.../ffmpeg -version` → 7.0.2 (verified working)
|
||||
|
||||
### Secondary (MEDIUM confidence)
|
||||
|
||||
- `.planning/STATE.md` — accumulated decisions: CONV-05, CONV-08, CONV-09 architectural choices locked
|
||||
- Phase 41-01-SUMMARY.md — renderer pattern, useContentJob hook, tech stack context
|
||||
- Phase 40-01-SUMMARY.md — content_jobs schema, RenderResult interface, MAX_GENERATED_ASSET_BYTES
|
||||
|
||||
### Tertiary (LOW confidence)
|
||||
|
||||
- None — all critical claims verified by codebase inspection or npm registry.
|
||||
|
||||
---
|
||||
|
||||
## Metadata
|
||||
|
||||
**Confidence breakdown:**
|
||||
- Standard stack: HIGH — all packages verified via codebase inspection + npm registry
|
||||
- Architecture: HIGH — pattern directly derived from Phase 41 implementations in codebase
|
||||
- Pitfalls: HIGH — most derived from actual code review (ffmpeg-static cast, file-type ESM, etc.)
|
||||
- Environment availability: HIGH — verified via command execution on target system
|
||||
|
||||
**Research date:** 2026-04-04
|
||||
**Valid until:** 2026-05-04 (packages stable; architecture unlikely to change)
|
||||
|
|
@ -0,0 +1,307 @@
|
|||
---
|
||||
phase: 42
|
||||
slug: wallpapers-social-format-conversion-voice
|
||||
status: draft
|
||||
shadcn_initialized: true
|
||||
preset: new-york / neutral / cssVariables / lucide
|
||||
created: 2026-04-04
|
||||
---
|
||||
|
||||
# Phase 42 — UI Design Contract
|
||||
|
||||
> Visual and interaction contract for Phase 42: Wallpapers, Social, Format Conversion & Voice.
|
||||
> Generated by gsd-ui-researcher. Verified by gsd-ui-checker.
|
||||
|
||||
---
|
||||
|
||||
## Design System
|
||||
|
||||
| Property | Value | Source |
|
||||
|----------|-------|--------|
|
||||
| Tool | shadcn | components.json detected |
|
||||
| Style | new-york | components.json |
|
||||
| Preset | neutral base color, cssVariables, radius=0 | ui/src/index.css |
|
||||
| Component library | Radix UI (via shadcn) | components.json |
|
||||
| Icon library | lucide-react | components.json |
|
||||
| Font | System UI (inherited; no custom font loaded) | index.css |
|
||||
|
||||
**Existing components available (no reinstall needed):**
|
||||
`avatar`, `badge`, `breadcrumb`, `button`, `card`, `checkbox`, `collapsible`, `command`, `dialog`, `dropdown-menu`, `input`, `label`, `popover`, `progress`, `scroll-area`, `select`, `separator`, `sheet`, `skeleton`, `tabs`, `textarea`, `toggle`, `tooltip`
|
||||
|
||||
**New shadcn components needed for Phase 42:**
|
||||
- None. All required primitives were installed in Phase 41.
|
||||
|
||||
**Third-party/custom dependencies:**
|
||||
- `VoiceMicButton`, `VoiceWaveform`, `VoiceModeToggle` — already exist in `ui/src/components/`. Reuse directly; do not rebuild.
|
||||
|
||||
---
|
||||
|
||||
## Spacing Scale
|
||||
|
||||
Declared values (multiples of 4). Inherited from Phase 41; no changes.
|
||||
|
||||
| Token | Value | Usage |
|
||||
|-------|-------|-------|
|
||||
| xs | 4px | Icon gaps, badge padding, inline chip gaps |
|
||||
| sm | 8px | Compact element spacing, button icon gap |
|
||||
| md | 16px | Default card padding, form field spacing |
|
||||
| lg | 24px | Section padding, panel gaps |
|
||||
| xl | 32px | Layout column gaps, page section breaks |
|
||||
| 2xl | 48px | Major section breaks |
|
||||
| 3xl | 64px | Not used in Phase 42 |
|
||||
|
||||
**Exceptions (Phase 42 specific):**
|
||||
- Drag-drop upload zone: minimum height 120px, padding 24px, dashed border `2px dashed var(--border)`.
|
||||
- Format selector chips/badges: 8px horizontal padding, 4px vertical padding, `rounded-full`.
|
||||
- Platform dimension labels (e.g. "2560 × 1440"): display inline in `text-xs font-mono text-muted-foreground`, right-aligned in the option row.
|
||||
- VoiceMicButton: 32×32px (h-8 w-8) — existing contract from Phase 37; no change.
|
||||
|
||||
---
|
||||
|
||||
## Typography
|
||||
|
||||
Source: Phase 41 contract. No additions needed.
|
||||
|
||||
| Role | Size | Weight | Line Height | Usage |
|
||||
|------|------|--------|-------------|-------|
|
||||
| Body | 15px (0.9375rem) | 400 (regular) | 1.6 | Conversion UI descriptions, wallpaper prompt text |
|
||||
| Label | 14px (0.875rem) | 400 (regular) | 1.5 | Form labels, format chips, panel section titles |
|
||||
| Heading | 20px (1.25rem) | 600 (semibold) | 1.3 | Panel titles ("Generate Wallpaper", "Convert File") |
|
||||
| Display | 28px (1.75rem) | 600 (semibold) | 1.2 | Not used in Phase 42 |
|
||||
|
||||
**Declared weights: 2 — 400 (regular) and 600 (semibold).** Identical to Phase 41.
|
||||
|
||||
**Monospace:** `ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace` at 14px, weight 400, line-height 1.6. Used for platform dimension labels, file MIME type display, and converted file name display.
|
||||
|
||||
---
|
||||
|
||||
## Color
|
||||
|
||||
Source: Phase 41 contract — Catppuccin Latte (light) + Catppuccin Mocha (dark). No new tokens introduced.
|
||||
|
||||
| Role | Light value | Dark value | Usage |
|
||||
|------|-------------|------------|-------|
|
||||
| Dominant (60%) | `#eff1f5` (--background) | `#1e1e2e` (--background) | Page background, wallpaper canvas background |
|
||||
| Secondary (30%) | `#e6e9ef` (--card) | `#181825` (--card) | Format conversion panel card, image result card, social post preview card |
|
||||
| Muted surface | `#ccd0da` (--secondary) | `#313244` (--secondary) | Drag-drop zone background, platform selector bg, AI fallback notice bg |
|
||||
| Accent (10%) | `#bcc0cc` (--accent) | `#45475a` (--accent) | Hover states on non-primary interactive elements |
|
||||
| Primary | `#1e66f5` (--primary) | `#89b4fa` (--primary) | See "Accent reserved for" below |
|
||||
| Destructive | `#d20f39` (--destructive) | `#f38ba8` (--destructive) | MIME validation rejection error, destructive actions |
|
||||
| Border | `#ccd0da` (--border) | `#313244` (--border) | Panel edges, drag-drop zone border, input outlines |
|
||||
| Muted foreground | `#9ca0b0` (--muted-foreground) | `#6c7086` (--muted-foreground) | Platform dimension labels, helper text, secondary format labels |
|
||||
|
||||
**Accent (--primary) reserved for:**
|
||||
1. Primary CTA buttons ("Generate Wallpaper", "Convert File", "Generate Post")
|
||||
2. Job progress bar fill
|
||||
3. Active tab indicator (ContentStudio Tabs, conversion format selector active state)
|
||||
4. VoiceMicButton active/recording ring (`ring-2 ring-primary` — existing Phase 37 pattern)
|
||||
5. Selected format chip highlight
|
||||
|
||||
**Drag-drop zone states:**
|
||||
- Idle: `bg-secondary` background, `border-border` dashed border
|
||||
- Dragover: `bg-accent/30` background, `border-primary` dashed border (primary color signals acceptance)
|
||||
- Error (MIME rejection): `bg-destructive/10` background, `border-destructive` dashed border
|
||||
|
||||
---
|
||||
|
||||
## Component Inventory (Phase 42)
|
||||
|
||||
### ContentStudio Tab Extensions
|
||||
|
||||
Extend the existing `ContentStudio.tsx` Tabs component. Add three new tabs to the existing `<TabsList>`:
|
||||
- "Wallpapers" (value: `wallpapers`)
|
||||
- "Social" (value: `social`)
|
||||
- "Convert" (value: `convert`)
|
||||
|
||||
Voice mic integration is in `ChatInput` / `ChatPanel` (not ContentStudio). Phase 42 does not add a "Voice" tab.
|
||||
|
||||
---
|
||||
|
||||
### Wallpaper & Social Image Panel
|
||||
|
||||
**WallpaperGeneratePanel** — Card with:
|
||||
- Prompt textarea (4 rows, placeholder: "Describe the scene, mood, or concept…")
|
||||
- Platform selector (Select component). Options grouped:
|
||||
- Desktop: "Desktop HD (2560 × 1440)", "Desktop FHD (1920 × 1080)", "Desktop 4K (3840 × 2160)"
|
||||
- Mobile: "Mobile Portrait (1080 × 1920)", "Mobile Landscape (1920 × 1080)"
|
||||
- Social: "OG Image (1200 × 630)", "Twitter Card (1200 × 628)", "Instagram Post (1080 × 1080)", "Instagram Banner (1080 × 566)", "LinkedIn Banner (1584 × 396)"
|
||||
- App: "App Icon (1024 × 1024)", "Favicon (32 × 32)"
|
||||
- Dimension label renders inline in each Select option: `text-xs font-mono text-muted-foreground` right-aligned.
|
||||
- "Generate Wallpaper" Button (primary, full-width of card).
|
||||
- Progress bar below button (shadcn `progress`, primary fill) — same SSE job pattern as Phase 41.
|
||||
|
||||
**WallpaperPreview** — After job `ready`:
|
||||
- Image renders in a constrained container (`max-h-80 object-contain`).
|
||||
- Below image: "Download PNG" Button (primary) + resolution badge (text-xs, monospace, muted).
|
||||
- If job type is app icon / favicon: shows a multi-size grid. Each cell: size label (32×32, 64×64, etc.) + Download link.
|
||||
|
||||
---
|
||||
|
||||
### Social Post Panel
|
||||
|
||||
**SocialPostPanel** — Card with:
|
||||
- Prompt textarea (4 rows, placeholder: "Describe the topic or paste existing content to adapt…")
|
||||
- Platform selector (Select). Options: "Twitter/X", "LinkedIn", "Instagram Caption", "Instagram Carousel"
|
||||
- Character count indicator (below textarea, right-aligned): `text-xs text-muted-foreground`. Turns `text-destructive` when over platform limit.
|
||||
- "Generate Post" Button (primary).
|
||||
|
||||
**SocialPostResult** — After job `ready`:
|
||||
- Post text in a read-only card (`bg-card`, `rounded-lg`, `p-4`).
|
||||
- Character count badge inline (text-xs, monospace, muted).
|
||||
- "Copy Post" Button (secondary, full-width).
|
||||
- Hashtag section below copy button: chips in `rounded-full` badges (`bg-muted text-muted-foreground`). Each chip is clickable to copy the hashtag individually.
|
||||
- For carousel: shows a numbered list (slide 1, slide 2…) in collapsible sections (shadcn `collapsible`).
|
||||
|
||||
---
|
||||
|
||||
### Format Conversion Panel
|
||||
|
||||
**ConvertPanel** — Page layout (not nested in ContentStudio tabs; lives at `/convert` route with deep-link support `/convert/:sourceFormat/:targetFormat`).
|
||||
|
||||
Layout: two-column on desktop (source column left, target column right), single column on mobile.
|
||||
|
||||
**ConvertSourceZone** — Left column:
|
||||
- Drag-drop zone (`min-h-[120px]`, `p-6`, dashed border). Copy: "Drop a file here or click to browse".
|
||||
- `<input type="file" hidden>` triggered by zone click.
|
||||
- After file selected: shows file name (`text-sm font-medium`), file size (`text-xs text-muted-foreground`), detected MIME type (`text-xs font-mono text-muted-foreground`).
|
||||
- MIME validation error (magic-byte mismatch): replaces file metadata with a destructive error inline: "File extension does not match content. Got {actualMime}, expected {claimedMime}."
|
||||
|
||||
**ConvertTargetSelector** — Right column:
|
||||
- Grouped format chips. Groups: Images, Audio/Video, Documents, Data.
|
||||
- Each chip: `rounded-full px-3 py-1 text-xs font-medium`. Idle: `bg-muted text-muted-foreground`. Selected: `bg-primary text-primary-foreground`.
|
||||
- Unavailable formats (direct converter not detected at startup): chip still shown and selectable — falls through to AI-bridged conversion rather than disabled.
|
||||
- AI fallback indicator: when selected pair has no direct converter, a muted notice renders below chips: "No direct converter for this pair — AI bridge will be used." (`text-xs text-muted-foreground`, Info icon 14px, `bg-secondary` background, `rounded-md p-3`).
|
||||
|
||||
**ConvertActionBar** — Spans full width below both columns:
|
||||
- "Convert File" Button (primary, disabled until source file selected + target format selected).
|
||||
- Progress bar below button (same SSE job pattern).
|
||||
- After ready: "Download {filename}.{ext}" Button (primary) replaces progress bar.
|
||||
|
||||
**Deep-link pre-selection:** When route is `/convert/png/svg`, source format chip "PNG" and target format chip "SVG" are pre-highlighted on mount. Drag-drop zone shows "Drop a PNG file here or click to browse" with source format in the copy.
|
||||
|
||||
---
|
||||
|
||||
### Voice Integration (Web Chat)
|
||||
|
||||
Voice UI components (`VoiceMicButton`, `VoiceWaveform`, `VoiceModeToggle`) were built in Phase 37. Phase 42 wires the local Whisper offline model to the existing `VoiceMicButton` — no new component surface.
|
||||
|
||||
**Integration contract (no new visual components):**
|
||||
- `VoiceMicButton` already handles idle / recording / transcribing states with correct aria-labels.
|
||||
- Phase 42 ensures the button renders in `ChatInput` when voice mode is `voice_input` or `full_voice`.
|
||||
- Offline capability badge: if `WHISPER_MODEL=local`, a `text-xs text-muted-foreground` badge "Offline" renders inline next to the mic button (Wifi-off icon 12px, no tooltip needed — badge text is sufficient).
|
||||
|
||||
---
|
||||
|
||||
## Interaction Contracts
|
||||
|
||||
### Job Progress (shared pattern — identical to Phase 41)
|
||||
|
||||
1. User submits → Button shows spinner + "Generating…" label (disabled). No separate overlay.
|
||||
2. SSE events arrive → Progress bar (`progress` component, primary fill) animates 0→100%.
|
||||
3. On `ready` → progress bar fades out (200ms), result panel slides down (300ms ease-out). Button reverts to "Generate again" (secondary variant).
|
||||
4. On `error` → progress bar fills destructive color, error inline: "Render failed — {detail}. Try again." Button reverts to primary (enabled).
|
||||
5. Silent SSE reconnect on disconnect; no user-facing error unless render ultimately fails.
|
||||
|
||||
### Drag-Drop File Upload
|
||||
|
||||
1. User drags file over zone → zone border changes to `border-primary`, background to `bg-accent/30` (no animation, instant).
|
||||
2. User drops file → zone shows file metadata row (name, size, MIME).
|
||||
3. Magic-byte validation runs immediately (client sends file to `/api/convert/validate` or server rejects on job submit). If mismatch: zone border `border-destructive`, background `bg-destructive/10`, error copy inline.
|
||||
4. User can replace file by clicking zone again while file is selected.
|
||||
|
||||
### Format Deep-Link
|
||||
|
||||
1. On mount, read `:sourceFormat` and `:targetFormat` from URL params.
|
||||
2. Pre-select corresponding chips (case-insensitive, e.g. `png` matches "PNG" chip).
|
||||
3. Update drag-drop zone copy to mention source format.
|
||||
4. If format params are invalid or not found: silently ignore (no error shown), render default state.
|
||||
|
||||
### Social Character Count
|
||||
|
||||
1. Character count updates on every keystroke (no debounce — immediate feedback is important for limit enforcement).
|
||||
2. When over limit: count turns `text-destructive`, CTA button remains enabled (AI may trim on generate; do not block submission).
|
||||
3. Limit constants per platform: Twitter/X = 280, LinkedIn = 3000, Instagram Caption = 2200, Instagram Carousel (per slide) = 300.
|
||||
|
||||
### Hashtag Copy
|
||||
|
||||
- Clicking a hashtag chip copies the text (including `#`) to clipboard.
|
||||
- Chip briefly shows a check icon (CheckCheck, 12px) for 1.5s, then reverts to original text.
|
||||
- No toast. Inline feedback on the chip itself is sufficient.
|
||||
|
||||
---
|
||||
|
||||
## Copywriting Contract
|
||||
|
||||
| Element | Copy |
|
||||
|---------|------|
|
||||
| Wallpaper CTA | "Generate Wallpaper" |
|
||||
| Wallpaper generating state | "Generating…" |
|
||||
| Wallpaper download | "Download PNG" |
|
||||
| Wallpaper empty state heading | "No image yet" |
|
||||
| Wallpaper empty state body | "Describe a scene or mood and pick a platform size to generate a ready-to-use image." |
|
||||
| Wallpaper render error | "Render failed — {detail}. Try again." |
|
||||
| Social CTA | "Generate Post" |
|
||||
| Social generating state | "Generating…" |
|
||||
| Social copy CTA | "Copy Post" |
|
||||
| Social copied state | "Copied!" (reverts after 2s) |
|
||||
| Social hashtag copy (chip tooltip / accessible label) | "Copy hashtag" |
|
||||
| Social empty state heading | "No post yet" |
|
||||
| Social empty state body | "Describe your topic and choose a platform to generate a ready-to-publish post." |
|
||||
| Social render error | "Generation failed — {detail}. Try again." |
|
||||
| Character count within limit | "{N} / {limit}" |
|
||||
| Character count over limit | "{N} / {limit} — over limit" |
|
||||
| Convert drag-drop idle | "Drop a file here or click to browse" |
|
||||
| Convert drag-drop with source format | "Drop a {FORMAT} file here or click to browse" |
|
||||
| Convert drag-drop dragover | "Release to select" |
|
||||
| Convert MIME mismatch error | "File extension does not match content. Got {actualMime}, expected {claimedMime}." |
|
||||
| Convert AI fallback notice | "No direct converter for this pair — AI bridge will be used." |
|
||||
| Convert CTA | "Convert File" |
|
||||
| Convert converting state | "Converting…" |
|
||||
| Convert download | "Download {filename}.{ext}" |
|
||||
| Convert empty state heading | "No conversion yet" |
|
||||
| Convert empty state body | "Upload a file and choose a target format to convert." |
|
||||
| Convert render error | "Conversion failed — {detail}. Try again." |
|
||||
| Voice offline badge | "Offline" |
|
||||
| Voice mic idle (aria-label) | "Start voice input" |
|
||||
| Voice mic recording (aria-label) | "Recording — speak now" |
|
||||
| Voice mic transcribing (aria-label) | "Transcribing..." |
|
||||
|
||||
**Destructive actions in Phase 42:** None. No file deletions or irreversible actions in scope. MIME rejection is an error state, not a destructive confirmation.
|
||||
|
||||
---
|
||||
|
||||
## Registry Safety
|
||||
|
||||
| Registry | Blocks Used | Safety Gate |
|
||||
|----------|-------------|-------------|
|
||||
| shadcn official | all blocks from Phase 41 (reused) | not required |
|
||||
|
||||
No new shadcn blocks needed. No third-party registries declared for Phase 42.
|
||||
|
||||
---
|
||||
|
||||
## Accessibility
|
||||
|
||||
- Drag-drop zone: `role="button"` with `tabIndex={0}`, `aria-label="Upload file — drop here or press Enter to browse"`. `onKeyDown` handler for Enter/Space triggers file picker.
|
||||
- Format chips: `role="radio"` within a `role="radiogroup"` container. Each chip: `aria-checked={selected}`, `aria-label="{format name}"`.
|
||||
- Platform selector: standard shadcn `Select` component — accessible by default.
|
||||
- Progress bar: `role="progressbar"` with `aria-valuenow`, `aria-valuemin=0`, `aria-valuemax=100` (same contract as Phase 41).
|
||||
- Hashtag chips: `role="button"`, `aria-label="Copy hashtag {tag}"`.
|
||||
- Character count: `aria-live="polite"` on the count element so screen readers announce changes without interrupting.
|
||||
- VoiceMicButton: existing aria-labels from Phase 37 are correct — do not change.
|
||||
- Offline badge next to mic: `aria-label="Voice input is offline (local model)"` on the badge element.
|
||||
- `prefers-reduced-motion`: disable drag-drop zone color transition, disable result panel slide animation. Keep progress bar animation (functional feedback).
|
||||
- Wallpaper image result: `alt="{prompt text truncated to 100 chars}"` on `<img>`.
|
||||
|
||||
---
|
||||
|
||||
## Checker Sign-Off
|
||||
|
||||
- [ ] Dimension 1 Copywriting: PASS
|
||||
- [ ] Dimension 2 Visuals: PASS
|
||||
- [ ] Dimension 3 Color: PASS
|
||||
- [ ] Dimension 4 Typography: PASS
|
||||
- [ ] Dimension 5 Spacing: PASS
|
||||
- [ ] Dimension 6 Registry Safety: PASS
|
||||
|
||||
**Approval:** pending
|
||||
157
pnpm-lock.yaml
generated
157
pnpm-lock.yaml
generated
|
|
@ -537,6 +537,9 @@ importers:
|
|||
chokidar:
|
||||
specifier: ^4.0.3
|
||||
version: 4.0.3
|
||||
csv-parse:
|
||||
specifier: 6.2.1
|
||||
version: 6.2.1
|
||||
culori:
|
||||
specifier: ^4.0.2
|
||||
version: 4.0.2
|
||||
|
|
@ -561,6 +564,9 @@ importers:
|
|||
ffmpeg-static:
|
||||
specifier: ^5.3.0
|
||||
version: 5.3.0
|
||||
file-type:
|
||||
specifier: 22.0.0
|
||||
version: 22.0.0
|
||||
grammy:
|
||||
specifier: ^1.42.0
|
||||
version: 1.42.0
|
||||
|
|
@ -606,10 +612,16 @@ importers:
|
|||
ws:
|
||||
specifier: ^8.19.0
|
||||
version: 8.19.0
|
||||
xlsx:
|
||||
specifier: 0.18.5
|
||||
version: 0.18.5
|
||||
zod:
|
||||
specifier: ^3.24.2
|
||||
version: 3.25.76
|
||||
devDependencies:
|
||||
'@types/culori':
|
||||
specifier: ^4.0.1
|
||||
version: 4.0.1
|
||||
'@types/express':
|
||||
specifier: ^5.0.0
|
||||
version: 5.0.6
|
||||
|
|
@ -634,6 +646,9 @@ importers:
|
|||
'@types/supertest':
|
||||
specifier: ^6.0.2
|
||||
version: 6.0.3
|
||||
'@types/wcag-contrast':
|
||||
specifier: ^3.0.3
|
||||
version: 3.0.3
|
||||
'@types/ws':
|
||||
specifier: ^8.18.1
|
||||
version: 8.18.1
|
||||
|
|
@ -1112,6 +1127,9 @@ packages:
|
|||
'@better-fetch/fetch@1.1.21':
|
||||
resolution: {integrity: sha512-/ImESw0sskqlVR94jB+5+Pxjf+xBwDZF/N5+y2/q4EqD7IARUTSpPfIo8uf39SYpCxyOCtbyYpUrZ3F/k0zT4A==}
|
||||
|
||||
'@borewit/text-codec@0.2.2':
|
||||
resolution: {integrity: sha512-DDaRehssg1aNrH4+2hnj1B7vnUGEjU6OIlyRdkMd0aUdIUvKXrJfXsy8LVtXAy7DRvYVluWbMspsRhz2lcW0mQ==}
|
||||
|
||||
'@braintree/sanitize-url@7.1.2':
|
||||
resolution: {integrity: sha512-jigsZK+sMF/cuiB7sERuo9V7N9jx+dhmHHnQyDSVdpZwVutaBu7WvNYqMDLSgFgfB30n452TP3vjDAvFC973mA==}
|
||||
|
||||
|
|
@ -3889,6 +3907,13 @@ packages:
|
|||
'@types/react-dom':
|
||||
optional: true
|
||||
|
||||
'@tokenizer/inflate@0.4.1':
|
||||
resolution: {integrity: sha512-2mAv+8pkG6GIZiF1kNg1jAjh27IDxEPKwdGul3snfztFerfPGI1LjDezZp3i7BElXompqEtPmoPx6c2wgtWsOA==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
'@tokenizer/token@0.3.0':
|
||||
resolution: {integrity: sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==}
|
||||
|
||||
'@types/aria-query@5.0.4':
|
||||
resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==}
|
||||
|
||||
|
|
@ -3916,6 +3941,9 @@ packages:
|
|||
'@types/cookiejar@2.1.5':
|
||||
resolution: {integrity: sha512-he+DHOWReW0nghN24E1WUqM0efK4kI9oTqDm6XmK8ZPe2djZ90BSNdGnIyCLzCPw7/pogPlGbzI2wHGGmi4O/Q==}
|
||||
|
||||
'@types/culori@4.0.1':
|
||||
resolution: {integrity: sha512-43M51r/22CjhbOXyGT361GZ9vncSVQ39u62x5eJdBQFviI8zWp2X5jzqg7k4M6PVgDQAClpy2bUe2dtwEgEDVQ==}
|
||||
|
||||
'@types/d3-array@3.2.2':
|
||||
resolution: {integrity: sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==}
|
||||
|
||||
|
|
@ -4116,6 +4144,9 @@ packages:
|
|||
'@types/unist@3.0.3':
|
||||
resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==}
|
||||
|
||||
'@types/wcag-contrast@3.0.3':
|
||||
resolution: {integrity: sha512-oprevfwJSLfpQK4KaWsRKJuNoebV76+xhmbXiWJGy+FkS34LpCgCMNIwRXWTb8xmmSxUE2ycFOYE7uyRVRm3LA==}
|
||||
|
||||
'@types/web-push@3.6.4':
|
||||
resolution: {integrity: sha512-GnJmSr40H3RAnj0s34FNTcJi1hmWFV5KXugE0mYWnYhgTAHLJ/dJKAwDmvPJYMke0RplY2XE9LnM4hqSqKIjhQ==}
|
||||
|
||||
|
|
@ -4211,6 +4242,10 @@ packages:
|
|||
resolution: {integrity: sha512-XNAb/a6TCqou+TufU8/u11HCu9x1gYvOoxLwtlXgIqmkrYQADVv6ljyW2zwiPhHz9R1gItAWpuDrdJMmrOBFEA==}
|
||||
engines: {node: '>= 16.0.0'}
|
||||
|
||||
adler-32@1.3.1:
|
||||
resolution: {integrity: sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==}
|
||||
engines: {node: '>=0.8'}
|
||||
|
||||
agent-base@6.0.2:
|
||||
resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==}
|
||||
engines: {node: '>= 6.0.0'}
|
||||
|
|
@ -4418,6 +4453,10 @@ packages:
|
|||
ccount@2.0.1:
|
||||
resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==}
|
||||
|
||||
cfb@1.2.2:
|
||||
resolution: {integrity: sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==}
|
||||
engines: {node: '>=0.8'}
|
||||
|
||||
chai@5.3.3:
|
||||
resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==}
|
||||
engines: {node: '>=18'}
|
||||
|
|
@ -4480,6 +4519,10 @@ packages:
|
|||
codemirror@6.0.2:
|
||||
resolution: {integrity: sha512-VhydHotNW5w1UGK0Qj96BwSk/Zqbp9WbnyK2W/eVMv4QyF41INRGpjUhFJY7/uDNuudSc33a/PKr4iDqRduvHw==}
|
||||
|
||||
codepage@1.15.0:
|
||||
resolution: {integrity: sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==}
|
||||
engines: {node: '>=0.8'}
|
||||
|
||||
colorette@2.0.20:
|
||||
resolution: {integrity: sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==}
|
||||
|
||||
|
|
@ -4551,6 +4594,11 @@ packages:
|
|||
cose-base@2.2.0:
|
||||
resolution: {integrity: sha512-AzlgcsCbUMymkADOJtQm3wO9S3ltPfYOFD5033keQn9NJzIbtnZj+UdBJe7DYml/8TdbtHJW3j58SOnKhWY/5g==}
|
||||
|
||||
crc-32@1.2.2:
|
||||
resolution: {integrity: sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==}
|
||||
engines: {node: '>=0.8'}
|
||||
hasBin: true
|
||||
|
||||
crelt@1.0.6:
|
||||
resolution: {integrity: sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==}
|
||||
|
||||
|
|
@ -4600,6 +4648,9 @@ packages:
|
|||
csstype@3.2.3:
|
||||
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
|
||||
|
||||
csv-parse@6.2.1:
|
||||
resolution: {integrity: sha512-LRLMV+UCyfMokp8Wb411duBf1gaBKJfOfBWU9eHMJ+b+cJYZsNu3AFmjJf3+yPGd59Exz1TsMjaSFyxnYB9+IQ==}
|
||||
|
||||
culori@4.0.2:
|
||||
resolution: {integrity: sha512-1+BhOB8ahCn4O0cep0Sh2l9KCOfOdY+BXJnKMHFFzDEouSr/el18QwXEMRlOj9UY5nCeA8UN3a/82rUWRBeyBw==}
|
||||
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
|
||||
|
|
@ -5188,6 +5239,10 @@ packages:
|
|||
resolution: {integrity: sha512-H+K6sW6TiIX6VGend0KQwthe+kaceeH/luE8dIZyOP35ik7ahYojDuqlTV1bOrtEwl01sy2HFNGQfi5IDJvotg==}
|
||||
engines: {node: '>=16'}
|
||||
|
||||
file-type@22.0.0:
|
||||
resolution: {integrity: sha512-cmBmnYo8Zymabm2+qAP7jTFbKF10bQpYmxoGfuZbRFRcq00BRddJdGNH/P7GA1EMpJy5yQbqa9B7yROb3z8Ziw==}
|
||||
engines: {node: '>=22'}
|
||||
|
||||
finalhandler@2.1.1:
|
||||
resolution: {integrity: sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==}
|
||||
engines: {node: '>= 18.0.0'}
|
||||
|
|
@ -5215,6 +5270,10 @@ packages:
|
|||
resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==}
|
||||
engines: {node: '>= 0.6'}
|
||||
|
||||
frac@1.1.2:
|
||||
resolution: {integrity: sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==}
|
||||
engines: {node: '>=0.8'}
|
||||
|
||||
fresh@2.0.0:
|
||||
resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==}
|
||||
engines: {node: '>= 0.8'}
|
||||
|
|
@ -6444,6 +6503,10 @@ packages:
|
|||
resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==}
|
||||
engines: {node: '>= 10.x'}
|
||||
|
||||
ssf@0.11.2:
|
||||
resolution: {integrity: sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==}
|
||||
engines: {node: '>=0.8'}
|
||||
|
||||
stackback@0.0.2:
|
||||
resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==}
|
||||
|
||||
|
|
@ -6484,6 +6547,10 @@ packages:
|
|||
strnum@2.1.2:
|
||||
resolution: {integrity: sha512-l63NF9y/cLROq/yqKXSLtcMeeyOfnSQlfMSlzFt/K73oIaD8DGaQWd7Z34X9GPiKqP5rbSh84Hl4bOlLcjiSrQ==}
|
||||
|
||||
strtok3@10.3.5:
|
||||
resolution: {integrity: sha512-ki4hZQfh5rX0QDLLkOCj+h+CVNkqmp/CMf8v8kZpkNVK6jGQooMytqzLZYUVYIZcFZ6yDB70EfD8POcFXiF5oA==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
style-mod@4.1.3:
|
||||
resolution: {integrity: sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==}
|
||||
|
||||
|
|
@ -6583,6 +6650,10 @@ packages:
|
|||
resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==}
|
||||
engines: {node: '>=0.6'}
|
||||
|
||||
token-types@6.1.2:
|
||||
resolution: {integrity: sha512-dRXchy+C0IgK8WPC6xvCHFRIWYUbqqdEIKPaKo/AcTUNzwLTK6AH7RjdLWsEZcAN/TBdtfUw3PYEgPr5VPr6ww==}
|
||||
engines: {node: '>=14.16'}
|
||||
|
||||
tough-cookie@6.0.1:
|
||||
resolution: {integrity: sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==}
|
||||
engines: {node: '>=16'}
|
||||
|
|
@ -6634,6 +6705,10 @@ packages:
|
|||
ufo@1.6.3:
|
||||
resolution: {integrity: sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q==}
|
||||
|
||||
uint8array-extras@1.5.0:
|
||||
resolution: {integrity: sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
undici-types@6.21.0:
|
||||
resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==}
|
||||
|
||||
|
|
@ -6972,6 +7047,14 @@ packages:
|
|||
engines: {node: '>=8'}
|
||||
hasBin: true
|
||||
|
||||
wmf@1.0.2:
|
||||
resolution: {integrity: sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==}
|
||||
engines: {node: '>=0.8'}
|
||||
|
||||
word@0.3.0:
|
||||
resolution: {integrity: sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==}
|
||||
engines: {node: '>=0.8'}
|
||||
|
||||
wrappy@1.0.2:
|
||||
resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==}
|
||||
|
||||
|
|
@ -6991,6 +7074,11 @@ packages:
|
|||
resolution: {integrity: sha512-g/eziiSUNBSsdDJtCLB8bdYEUMj4jR7AGeUo96p/3dTafgjHhpF4RiCFPiRILwjQoDXx5MqkBr4fwWtR3Ky4Wg==}
|
||||
engines: {node: '>=20'}
|
||||
|
||||
xlsx@0.18.5:
|
||||
resolution: {integrity: sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==}
|
||||
engines: {node: '>=0.8'}
|
||||
hasBin: true
|
||||
|
||||
xml-name-validator@5.0.0:
|
||||
resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==}
|
||||
engines: {node: '>=18'}
|
||||
|
|
@ -7675,6 +7763,8 @@ snapshots:
|
|||
|
||||
'@better-fetch/fetch@1.1.21': {}
|
||||
|
||||
'@borewit/text-codec@0.2.2': {}
|
||||
|
||||
'@braintree/sanitize-url@7.1.2': {}
|
||||
|
||||
'@bramus/specificity@2.4.2':
|
||||
|
|
@ -10453,6 +10543,15 @@ snapshots:
|
|||
'@types/react': 19.2.14
|
||||
'@types/react-dom': 19.2.3(@types/react@19.2.14)
|
||||
|
||||
'@tokenizer/inflate@0.4.1':
|
||||
dependencies:
|
||||
debug: 4.4.3
|
||||
token-types: 6.1.2
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@tokenizer/token@0.3.0': {}
|
||||
|
||||
'@types/aria-query@5.0.4': {}
|
||||
|
||||
'@types/babel__core@7.20.5':
|
||||
|
|
@ -10492,6 +10591,8 @@ snapshots:
|
|||
|
||||
'@types/cookiejar@2.1.5': {}
|
||||
|
||||
'@types/culori@4.0.1': {}
|
||||
|
||||
'@types/d3-array@3.2.2': {}
|
||||
|
||||
'@types/d3-axis@3.0.6':
|
||||
|
|
@ -10733,6 +10834,8 @@ snapshots:
|
|||
|
||||
'@types/unist@3.0.3': {}
|
||||
|
||||
'@types/wcag-contrast@3.0.3': {}
|
||||
|
||||
'@types/web-push@3.6.4':
|
||||
dependencies:
|
||||
'@types/node': 25.2.3
|
||||
|
|
@ -10862,6 +10965,8 @@ snapshots:
|
|||
|
||||
address@2.0.3: {}
|
||||
|
||||
adler-32@1.3.1: {}
|
||||
|
||||
agent-base@6.0.2:
|
||||
dependencies:
|
||||
debug: 4.4.3
|
||||
|
|
@ -11022,6 +11127,11 @@ snapshots:
|
|||
|
||||
ccount@2.0.1: {}
|
||||
|
||||
cfb@1.2.2:
|
||||
dependencies:
|
||||
adler-32: 1.3.1
|
||||
crc-32: 1.2.2
|
||||
|
||||
chai@5.3.3:
|
||||
dependencies:
|
||||
assertion-error: 2.0.1
|
||||
|
|
@ -11097,6 +11207,8 @@ snapshots:
|
|||
'@codemirror/state': 6.5.4
|
||||
'@codemirror/view': 6.39.15
|
||||
|
||||
codepage@1.15.0: {}
|
||||
|
||||
colorette@2.0.20: {}
|
||||
|
||||
combined-stream@1.0.8:
|
||||
|
|
@ -11148,6 +11260,8 @@ snapshots:
|
|||
dependencies:
|
||||
layout-base: 2.0.1
|
||||
|
||||
crc-32@1.2.2: {}
|
||||
|
||||
crelt@1.0.6: {}
|
||||
|
||||
cross-env@10.1.0:
|
||||
|
|
@ -11204,6 +11318,8 @@ snapshots:
|
|||
|
||||
csstype@3.2.3: {}
|
||||
|
||||
csv-parse@6.2.1: {}
|
||||
|
||||
culori@4.0.2: {}
|
||||
|
||||
cytoscape-cose-bilkent@4.1.0(cytoscape@3.33.1):
|
||||
|
|
@ -11865,6 +11981,15 @@ snapshots:
|
|||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
file-type@22.0.0:
|
||||
dependencies:
|
||||
'@tokenizer/inflate': 0.4.1
|
||||
strtok3: 10.3.5
|
||||
token-types: 6.1.2
|
||||
uint8array-extras: 1.5.0
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
finalhandler@2.1.1:
|
||||
dependencies:
|
||||
debug: 4.4.3
|
||||
|
|
@ -11900,6 +12025,8 @@ snapshots:
|
|||
|
||||
forwarded@0.2.0: {}
|
||||
|
||||
frac@1.1.2: {}
|
||||
|
||||
fresh@2.0.0: {}
|
||||
|
||||
fsevents@2.3.2:
|
||||
|
|
@ -13584,6 +13711,10 @@ snapshots:
|
|||
|
||||
split2@4.2.0: {}
|
||||
|
||||
ssf@0.11.2:
|
||||
dependencies:
|
||||
frac: 1.1.2
|
||||
|
||||
stackback@0.0.2: {}
|
||||
|
||||
static-browser-server@1.0.3:
|
||||
|
|
@ -13622,6 +13753,10 @@ snapshots:
|
|||
|
||||
strnum@2.1.2: {}
|
||||
|
||||
strtok3@10.3.5:
|
||||
dependencies:
|
||||
'@tokenizer/token': 0.3.0
|
||||
|
||||
style-mod@4.1.3: {}
|
||||
|
||||
style-to-js@1.1.21:
|
||||
|
|
@ -13713,6 +13848,12 @@ snapshots:
|
|||
|
||||
toidentifier@1.0.1: {}
|
||||
|
||||
token-types@6.1.2:
|
||||
dependencies:
|
||||
'@borewit/text-codec': 0.2.2
|
||||
'@tokenizer/token': 0.3.0
|
||||
ieee754: 1.2.1
|
||||
|
||||
tough-cookie@6.0.1:
|
||||
dependencies:
|
||||
tldts: 7.0.26
|
||||
|
|
@ -13757,6 +13898,8 @@ snapshots:
|
|||
|
||||
ufo@1.6.3: {}
|
||||
|
||||
uint8array-extras@1.5.0: {}
|
||||
|
||||
undici-types@6.21.0: {}
|
||||
|
||||
undici-types@7.16.0: {}
|
||||
|
|
@ -14180,6 +14323,10 @@ snapshots:
|
|||
siginfo: 2.0.0
|
||||
stackback: 0.0.2
|
||||
|
||||
wmf@1.0.2: {}
|
||||
|
||||
word@0.3.0: {}
|
||||
|
||||
wrappy@1.0.2: {}
|
||||
|
||||
ws@8.19.0: {}
|
||||
|
|
@ -14189,6 +14336,16 @@ snapshots:
|
|||
is-wsl: 3.1.1
|
||||
powershell-utils: 0.1.0
|
||||
|
||||
xlsx@0.18.5:
|
||||
dependencies:
|
||||
adler-32: 1.3.1
|
||||
cfb: 1.2.2
|
||||
codepage: 1.15.0
|
||||
crc-32: 1.2.2
|
||||
ssf: 0.11.2
|
||||
wmf: 1.0.2
|
||||
word: 0.3.0
|
||||
|
||||
xml-name-validator@5.0.0: {}
|
||||
|
||||
xmlchars@2.2.0: {}
|
||||
|
|
|
|||
|
|
@ -63,6 +63,7 @@
|
|||
"ajv-formats": "^3.0.1",
|
||||
"better-auth": "1.4.18",
|
||||
"chokidar": "^4.0.3",
|
||||
"csv-parse": "6.2.1",
|
||||
"culori": "^4.0.2",
|
||||
"detect-port": "^2.1.0",
|
||||
"dompurify": "^3.3.2",
|
||||
|
|
@ -71,6 +72,7 @@
|
|||
"embedded-postgres": "^18.1.0-beta.16",
|
||||
"express": "^5.1.0",
|
||||
"ffmpeg-static": "^5.3.0",
|
||||
"file-type": "22.0.0",
|
||||
"grammy": "^1.42.0",
|
||||
"hermes-paperclip-adapter": "^0.2.0",
|
||||
"jsdom": "^28.1.0",
|
||||
|
|
@ -86,6 +88,7 @@
|
|||
"wcag-contrast": "^3.0.0",
|
||||
"web-push": "^3.6.7",
|
||||
"ws": "^8.19.0",
|
||||
"xlsx": "0.18.5",
|
||||
"zod": "^3.24.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@ import { chatFileRoutes } from "./routes/chat-files.js";
|
|||
import { nexusSettingsRoutes } from "./routes/nexus-settings.js";
|
||||
import { voiceRoutes } from "./routes/voice.js";
|
||||
import { contentJobRoutes } from "./routes/content-jobs.js";
|
||||
import { convertRoutes } from "./routes/convert.js";
|
||||
import { telegramService } from "./services/telegram.js";
|
||||
import { telegramRoutes } from "./routes/telegram.js";
|
||||
import { nexusSettingsService } from "./services/nexus-settings.js";
|
||||
|
|
@ -188,6 +189,7 @@ export async function createApp(
|
|||
api.use(nexusSettingsRoutes());
|
||||
api.use(voiceRoutes());
|
||||
api.use(contentJobRoutes(db, opts.storageService));
|
||||
api.use(convertRoutes(db, opts.storageService));
|
||||
|
||||
// Telegram bridge — create service instance and mount routes
|
||||
const tg = telegramService(db);
|
||||
|
|
|
|||
157
server/src/routes/convert.ts
Normal file
157
server/src/routes/convert.ts
Normal file
|
|
@ -0,0 +1,157 @@
|
|||
import { Router, type Request, type Response } from "express";
|
||||
import multer from "multer";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import type { StorageService } from "../storage/types.js";
|
||||
import { fileTypeFromBuffer } from "file-type";
|
||||
import { contentJobStore } from "../services/content-job-store.js";
|
||||
import { contentJobRunner } from "../services/content-job-runner.js";
|
||||
import { converterCapabilitiesService } from "../services/converter-capabilities.js";
|
||||
import { assertCompanyAccess } from "./authz.js";
|
||||
import { MAX_ATTACHMENT_BYTES } from "../attachment-types.js";
|
||||
|
||||
// Extension → expected MIME map for magic-byte mismatch detection
|
||||
const EXTENSION_MIME_MAP: Record<string, string> = {
|
||||
png: "image/png",
|
||||
jpg: "image/jpeg",
|
||||
jpeg: "image/jpeg",
|
||||
gif: "image/gif",
|
||||
webp: "image/webp",
|
||||
svg: "image/svg+xml",
|
||||
mp3: "audio/mpeg",
|
||||
mp4: "video/mp4",
|
||||
wav: "audio/wav",
|
||||
ogg: "audio/ogg",
|
||||
csv: "text/csv",
|
||||
json: "application/json",
|
||||
xlsx: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
};
|
||||
|
||||
// These formats have no magic bytes — null from fileTypeFromBuffer is expected
|
||||
const TEXT_BASED_EXTENSIONS = new Set(["svg", "csv", "json", "txt", "html", "md", "xml"]);
|
||||
|
||||
const fileUpload = multer({
|
||||
storage: multer.memoryStorage(),
|
||||
limits: { fileSize: MAX_ATTACHMENT_BYTES, files: 1 },
|
||||
});
|
||||
|
||||
async function runSingleFileUpload(
|
||||
upload: ReturnType<typeof multer>,
|
||||
req: Request,
|
||||
res: Response,
|
||||
): Promise<void> {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
upload.single("file")(req, res, (err: unknown) => {
|
||||
if (err) reject(err);
|
||||
else resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function convertRoutes(db: Db, _storage: StorageService) {
|
||||
const router = Router();
|
||||
|
||||
// POST /companies/:companyId/convert — multipart upload + MIME validation + job dispatch
|
||||
router.post("/companies/:companyId/convert", async (req, res) => {
|
||||
const companyId = req.params.companyId!;
|
||||
assertCompanyAccess(req, companyId);
|
||||
|
||||
// Handle multer upload
|
||||
try {
|
||||
await runSingleFileUpload(fileUpload, req, res);
|
||||
} catch (err) {
|
||||
if (err instanceof multer.MulterError) {
|
||||
if (err.code === "LIMIT_FILE_SIZE") {
|
||||
res.status(422).json({ error: `File exceeds ${MAX_ATTACHMENT_BYTES} bytes` });
|
||||
return;
|
||||
}
|
||||
res.status(400).json({ error: err.message });
|
||||
return;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
const file = (
|
||||
req as Request & {
|
||||
file?: {
|
||||
mimetype: string;
|
||||
buffer: Buffer;
|
||||
originalname: string;
|
||||
size: number;
|
||||
};
|
||||
}
|
||||
).file;
|
||||
|
||||
if (!file) {
|
||||
res.status(400).json({ error: "Missing file field 'file'" });
|
||||
return;
|
||||
}
|
||||
|
||||
const targetFormat =
|
||||
typeof req.body.targetFormat === "string" && req.body.targetFormat.trim() !== ""
|
||||
? (req.body.targetFormat as string).trim()
|
||||
: null;
|
||||
|
||||
if (!targetFormat) {
|
||||
res.status(400).json({ error: "targetFormat is required" });
|
||||
return;
|
||||
}
|
||||
|
||||
// ── Magic-byte MIME validation (CONV-09) ──────────────────────────────────
|
||||
const originalFilename: string = file.originalname ?? "upload";
|
||||
const ext = originalFilename.split(".").pop()?.toLowerCase() ?? "";
|
||||
|
||||
// Only validate if the extension has a known MIME and is NOT text-based
|
||||
// (text-based files return null from fileTypeFromBuffer — allowed through)
|
||||
if (ext && EXTENSION_MIME_MAP[ext] && !TEXT_BASED_EXTENSIONS.has(ext)) {
|
||||
const detected = await fileTypeFromBuffer(file.buffer);
|
||||
if (detected) {
|
||||
const claimedMime = EXTENSION_MIME_MAP[ext]!;
|
||||
// Normalise audio/mpeg vs audio/mp3 comparison
|
||||
const detectedNorm = detected.mime.toLowerCase();
|
||||
const claimedNorm = claimedMime.toLowerCase();
|
||||
if (detectedNorm !== claimedNorm) {
|
||||
res.status(422).json({
|
||||
error: `File MIME mismatch: file claims to be ${claimedMime} but magic bytes indicate ${detected.mime}`,
|
||||
actualMime: detected.mime,
|
||||
claimedMime,
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Determine source MIME — prefer file-type detection over multer claim for known binary types
|
||||
let sourceMime = file.mimetype || "application/octet-stream";
|
||||
if (ext && EXTENSION_MIME_MAP[ext]) {
|
||||
sourceMime = EXTENSION_MIME_MAP[ext]!;
|
||||
}
|
||||
|
||||
// ── Create and dispatch content job ──────────────────────────────────────
|
||||
const store = contentJobStore(db);
|
||||
const job = await store.create(companyId, {
|
||||
jobType: "convert",
|
||||
input: {
|
||||
fileBase64: file.buffer.toString("base64"),
|
||||
sourceMime,
|
||||
targetFormat,
|
||||
originalFilename,
|
||||
},
|
||||
sourceTaskId: typeof req.body.sourceTaskId === "string" ? req.body.sourceTaskId : null,
|
||||
});
|
||||
|
||||
void contentJobRunner.dispatch(db, {} as StorageService, job!);
|
||||
|
||||
res.status(202).json({
|
||||
jobId: job!.id,
|
||||
status: job!.status,
|
||||
});
|
||||
});
|
||||
|
||||
// GET /system/converters — capability map (CONV-08)
|
||||
router.get("/system/converters", async (_req, res) => {
|
||||
const caps = await converterCapabilitiesService().get();
|
||||
res.json(caps);
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
|
|
@ -26,6 +26,18 @@ export async function renderContent(
|
|||
const { renderThemePalette } = await import("./renderers/theme-renderer.js");
|
||||
return renderThemePalette(input);
|
||||
}
|
||||
case "wallpaper": {
|
||||
const { renderWallpaper } = await import("./renderers/wallpaper-renderer.js");
|
||||
return renderWallpaper(input);
|
||||
}
|
||||
case "social-post": {
|
||||
const { renderSocialPost } = await import("./renderers/social-renderer.js");
|
||||
return renderSocialPost(input);
|
||||
}
|
||||
case "convert": {
|
||||
const { renderConvert } = await import("./renderers/convert-renderer.js");
|
||||
return renderConvert(input);
|
||||
}
|
||||
default:
|
||||
throw new Error(`Unknown jobType: ${jobType}`);
|
||||
}
|
||||
|
|
|
|||
39
server/src/services/converter-capabilities.ts
Normal file
39
server/src/services/converter-capabilities.ts
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
import { execFileNoThrow } from "../utils/execFileNoThrow.js";
|
||||
|
||||
export interface ConverterCapabilities {
|
||||
/** Always true — sharp/file-type available as npm deps */
|
||||
imageConverter: boolean;
|
||||
/** Always true — ffmpeg-static available as npm dep */
|
||||
audioVideoConverter: boolean;
|
||||
/** True only if pandoc or libreoffice binary responds with status 0 */
|
||||
docConverter: boolean;
|
||||
/** Always true — xlsx/csv-parse available as npm deps */
|
||||
dataConverter: boolean;
|
||||
}
|
||||
|
||||
let cachedCapabilities: ConverterCapabilities | null = null;
|
||||
|
||||
async function probe(): Promise<ConverterCapabilities> {
|
||||
const [pandocResult, libreofficeResult] = await Promise.all([
|
||||
execFileNoThrow("pandoc", ["--version"]),
|
||||
execFileNoThrow("libreoffice", ["--version"]),
|
||||
]);
|
||||
|
||||
return {
|
||||
imageConverter: true,
|
||||
audioVideoConverter: true,
|
||||
docConverter: pandocResult.status === 0 || libreofficeResult.status === 0,
|
||||
dataConverter: true,
|
||||
};
|
||||
}
|
||||
|
||||
export function converterCapabilitiesService() {
|
||||
return {
|
||||
async get(): Promise<ConverterCapabilities> {
|
||||
if (cachedCapabilities === null) {
|
||||
cachedCapabilities = await probe();
|
||||
}
|
||||
return cachedCapabilities;
|
||||
},
|
||||
};
|
||||
}
|
||||
363
server/src/services/renderers/convert-renderer.ts
Normal file
363
server/src/services/renderers/convert-renderer.ts
Normal file
|
|
@ -0,0 +1,363 @@
|
|||
import { spawn } from "node:child_process";
|
||||
import ffmpegPath from "ffmpeg-static";
|
||||
import sharp from "sharp";
|
||||
import * as xlsx from "xlsx";
|
||||
import { parse as parseCsv } from "csv-parse/sync";
|
||||
import { puterChatComplete } from "../puter-inference.js";
|
||||
import type { RenderResult, ConvertBundle } from "./types.js";
|
||||
|
||||
// ─── Format helpers ────────────────────────────────────────────────────────────
|
||||
|
||||
function isImageFormat(mimeOrFormat: string): boolean {
|
||||
const lower = mimeOrFormat.toLowerCase();
|
||||
return (
|
||||
lower === "image/png" ||
|
||||
lower === "image/jpeg" ||
|
||||
lower === "image/jpg" ||
|
||||
lower === "image/webp" ||
|
||||
lower === "image/gif" ||
|
||||
lower === "image/svg+xml" ||
|
||||
lower === "png" ||
|
||||
lower === "jpg" ||
|
||||
lower === "jpeg" ||
|
||||
lower === "webp" ||
|
||||
lower === "gif" ||
|
||||
lower === "svg"
|
||||
);
|
||||
}
|
||||
|
||||
function isAVFormat(mimeOrFormat: string): boolean {
|
||||
const lower = mimeOrFormat.toLowerCase();
|
||||
return (
|
||||
lower.startsWith("audio/") ||
|
||||
lower.startsWith("video/") ||
|
||||
lower === "mp3" ||
|
||||
lower === "mp4" ||
|
||||
lower === "wav" ||
|
||||
lower === "ogg" ||
|
||||
lower === "flac" ||
|
||||
lower === "aac" ||
|
||||
lower === "webm" ||
|
||||
lower === "mkv" ||
|
||||
lower === "avi" ||
|
||||
lower === "mov"
|
||||
);
|
||||
}
|
||||
|
||||
function isDataFormat(mimeOrFormat: string): boolean {
|
||||
const lower = mimeOrFormat.toLowerCase();
|
||||
return (
|
||||
lower === "text/csv" ||
|
||||
lower === "application/json" ||
|
||||
lower === "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" ||
|
||||
lower === "csv" ||
|
||||
lower === "json" ||
|
||||
lower === "xlsx"
|
||||
);
|
||||
}
|
||||
|
||||
function getTargetMime(targetFormat: string): string {
|
||||
const map: Record<string, string> = {
|
||||
png: "image/png",
|
||||
jpg: "image/jpeg",
|
||||
jpeg: "image/jpeg",
|
||||
webp: "image/webp",
|
||||
gif: "image/gif",
|
||||
svg: "image/svg+xml",
|
||||
mp3: "audio/mpeg",
|
||||
mp4: "video/mp4",
|
||||
wav: "audio/wav",
|
||||
ogg: "audio/ogg",
|
||||
flac: "audio/flac",
|
||||
aac: "audio/aac",
|
||||
webm: "video/webm",
|
||||
mkv: "video/x-matroska",
|
||||
avi: "video/x-msvideo",
|
||||
mov: "video/quicktime",
|
||||
csv: "text/csv",
|
||||
json: "application/json",
|
||||
xlsx: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
txt: "text/plain",
|
||||
html: "text/html",
|
||||
md: "text/markdown",
|
||||
};
|
||||
return map[targetFormat.toLowerCase()] ?? "application/octet-stream";
|
||||
}
|
||||
|
||||
function getSharpFormat(targetFormat: string): keyof sharp.FormatEnum {
|
||||
const fmt = targetFormat.toLowerCase();
|
||||
if (fmt === "jpg") return "jpeg";
|
||||
return fmt as keyof sharp.FormatEnum;
|
||||
}
|
||||
|
||||
// ─── Image conversion ──────────────────────────────────────────────────────────
|
||||
|
||||
async function convertImage(
|
||||
fileBuffer: Buffer,
|
||||
sourceMime: string,
|
||||
targetFormat: string,
|
||||
originalFilename: string,
|
||||
): Promise<RenderResult> {
|
||||
const sharpFormat = getSharpFormat(targetFormat);
|
||||
const options: sharp.SharpOptions =
|
||||
sourceMime === "image/svg+xml" ? { density: 300 } : {};
|
||||
|
||||
const outputBuffer = await sharp(fileBuffer, options)
|
||||
.toFormat(sharpFormat)
|
||||
.toBuffer();
|
||||
|
||||
const baseName = originalFilename.replace(/\.[^.]+$/, "");
|
||||
const bundle: ConvertBundle = {
|
||||
type: "convert-bundle",
|
||||
outputFilename: `${baseName}.${targetFormat.toLowerCase()}`,
|
||||
outputMime: getTargetMime(targetFormat),
|
||||
outputBase64: outputBuffer.toString("base64"),
|
||||
method: "direct",
|
||||
};
|
||||
|
||||
return {
|
||||
filename: "convert-bundle.json",
|
||||
contentType: "application/json",
|
||||
buffer: Buffer.from(JSON.stringify(bundle)),
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Audio/video conversion ────────────────────────────────────────────────────
|
||||
|
||||
async function convertAV(
|
||||
fileBuffer: Buffer,
|
||||
sourceMime: string,
|
||||
targetFormat: string,
|
||||
originalFilename: string,
|
||||
): Promise<RenderResult> {
|
||||
// Derive source format from MIME for -f flag
|
||||
const sourceFmtMap: Record<string, string> = {
|
||||
"audio/mpeg": "mp3",
|
||||
"audio/mp3": "mp3",
|
||||
"audio/wav": "wav",
|
||||
"audio/ogg": "ogg",
|
||||
"audio/flac": "flac",
|
||||
"audio/aac": "aac",
|
||||
"video/mp4": "mp4",
|
||||
"video/webm": "webm",
|
||||
"video/x-matroska": "matroska",
|
||||
"video/x-msvideo": "avi",
|
||||
"video/quicktime": "mov",
|
||||
};
|
||||
const sourceFmt = sourceFmtMap[sourceMime.toLowerCase()] ?? sourceMime.split("/").pop() ?? "mp3";
|
||||
const targetFmt = targetFormat.toLowerCase() === "mkv" ? "matroska" : targetFormat.toLowerCase();
|
||||
|
||||
const outputBuffer = await new Promise<Buffer>((resolve, reject) => {
|
||||
const chunks: Buffer[] = [];
|
||||
const proc = spawn(ffmpegPath as unknown as string, [
|
||||
"-f", sourceFmt,
|
||||
"-i", "pipe:0",
|
||||
"-f", targetFmt,
|
||||
"pipe:1",
|
||||
]);
|
||||
|
||||
proc.stdout.on("data", (chunk: Buffer) => chunks.push(chunk));
|
||||
proc.stderr.on("data", () => {
|
||||
// suppress ffmpeg stderr noise
|
||||
});
|
||||
proc.on("error", reject);
|
||||
proc.on("close", (code) => {
|
||||
if (code !== 0) {
|
||||
reject(new Error(`ffmpeg exited with code ${code}`));
|
||||
return;
|
||||
}
|
||||
resolve(Buffer.concat(chunks));
|
||||
});
|
||||
|
||||
proc.stdin.write(fileBuffer);
|
||||
proc.stdin.end();
|
||||
});
|
||||
|
||||
const baseName = originalFilename.replace(/\.[^.]+$/, "");
|
||||
const bundle: ConvertBundle = {
|
||||
type: "convert-bundle",
|
||||
outputFilename: `${baseName}.${targetFormat.toLowerCase()}`,
|
||||
outputMime: getTargetMime(targetFormat),
|
||||
outputBase64: outputBuffer.toString("base64"),
|
||||
method: "direct",
|
||||
};
|
||||
|
||||
return {
|
||||
filename: "convert-bundle.json",
|
||||
contentType: "application/json",
|
||||
buffer: Buffer.from(JSON.stringify(bundle)),
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Data format conversion ────────────────────────────────────────────────────
|
||||
|
||||
type JsonRow = Record<string, unknown>;
|
||||
|
||||
function csvToJsonRows(fileBuffer: Buffer): JsonRow[] {
|
||||
return parseCsv(fileBuffer, { columns: true, skip_empty_lines: true }) as JsonRow[];
|
||||
}
|
||||
|
||||
function jsonRowsToCsv(rows: JsonRow[]): Buffer {
|
||||
if (rows.length === 0) return Buffer.from("");
|
||||
const headers = Object.keys(rows[0]!);
|
||||
const lines = [
|
||||
headers.map((h) => JSON.stringify(h)).join(","),
|
||||
...rows.map((row) =>
|
||||
headers.map((h) => {
|
||||
const val = row[h];
|
||||
if (val === null || val === undefined) return "";
|
||||
return JSON.stringify(String(val));
|
||||
}).join(",")
|
||||
),
|
||||
];
|
||||
return Buffer.from(lines.join("\n"));
|
||||
}
|
||||
|
||||
function xlsxToJsonRows(fileBuffer: Buffer): JsonRow[] {
|
||||
const wb = xlsx.read(fileBuffer, { type: "buffer" });
|
||||
const firstSheet = wb.SheetNames[0];
|
||||
if (!firstSheet) return [];
|
||||
return xlsx.utils.sheet_to_json<JsonRow>(wb.Sheets[firstSheet]!);
|
||||
}
|
||||
|
||||
function jsonRowsToXlsx(rows: JsonRow[]): Buffer {
|
||||
const ws = xlsx.utils.json_to_sheet(rows);
|
||||
const wb = xlsx.utils.book_new();
|
||||
xlsx.utils.book_append_sheet(wb, ws, "Sheet1");
|
||||
return Buffer.from(xlsx.write(wb, { type: "buffer", bookType: "xlsx" }) as Buffer);
|
||||
}
|
||||
|
||||
async function convertData(
|
||||
fileBuffer: Buffer,
|
||||
sourceMime: string,
|
||||
targetFormat: string,
|
||||
originalFilename: string,
|
||||
): Promise<RenderResult> {
|
||||
const srcNorm = sourceMime.toLowerCase();
|
||||
const tgtNorm = targetFormat.toLowerCase();
|
||||
|
||||
let outputBuffer: Buffer;
|
||||
|
||||
if ((srcNorm === "text/csv" || srcNorm === "csv") && (tgtNorm === "json")) {
|
||||
const rows = csvToJsonRows(fileBuffer);
|
||||
outputBuffer = Buffer.from(JSON.stringify(rows, null, 2));
|
||||
} else if ((srcNorm === "application/json" || srcNorm === "json") && (tgtNorm === "csv")) {
|
||||
const rows = JSON.parse(fileBuffer.toString("utf-8")) as JsonRow[];
|
||||
outputBuffer = jsonRowsToCsv(Array.isArray(rows) ? rows : [rows]);
|
||||
} else if (
|
||||
(srcNorm === "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" || srcNorm === "xlsx") &&
|
||||
(tgtNorm === "json")
|
||||
) {
|
||||
const rows = xlsxToJsonRows(fileBuffer);
|
||||
outputBuffer = Buffer.from(JSON.stringify(rows, null, 2));
|
||||
} else if ((srcNorm === "application/json" || srcNorm === "json") && (tgtNorm === "xlsx")) {
|
||||
const rows = JSON.parse(fileBuffer.toString("utf-8")) as JsonRow[];
|
||||
outputBuffer = jsonRowsToXlsx(Array.isArray(rows) ? rows : [rows]);
|
||||
} else if ((srcNorm === "text/csv" || srcNorm === "csv") && (tgtNorm === "xlsx")) {
|
||||
const rows = csvToJsonRows(fileBuffer);
|
||||
outputBuffer = jsonRowsToXlsx(rows);
|
||||
} else if (
|
||||
(srcNorm === "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" || srcNorm === "xlsx") &&
|
||||
(tgtNorm === "csv")
|
||||
) {
|
||||
const rows = xlsxToJsonRows(fileBuffer);
|
||||
outputBuffer = jsonRowsToCsv(rows);
|
||||
} else {
|
||||
throw new Error(`Unsupported data conversion: ${sourceMime} → ${targetFormat}`);
|
||||
}
|
||||
|
||||
const baseName = originalFilename.replace(/\.[^.]+$/, "");
|
||||
const bundle: ConvertBundle = {
|
||||
type: "convert-bundle",
|
||||
outputFilename: `${baseName}.${tgtNorm}`,
|
||||
outputMime: getTargetMime(tgtNorm),
|
||||
outputBase64: outputBuffer.toString("base64"),
|
||||
method: "direct",
|
||||
};
|
||||
|
||||
return {
|
||||
filename: "convert-bundle.json",
|
||||
contentType: "application/json",
|
||||
buffer: Buffer.from(JSON.stringify(bundle)),
|
||||
};
|
||||
}
|
||||
|
||||
// ─── AI-bridge fallback ────────────────────────────────────────────────────────
|
||||
|
||||
async function convertViaAiBridge(
|
||||
fileBuffer: Buffer,
|
||||
sourceMime: string,
|
||||
targetFormat: string,
|
||||
originalFilename: string,
|
||||
): Promise<RenderResult> {
|
||||
const systemPrompt = `Convert the following ${sourceMime} content to ${targetFormat} format. Return ONLY the converted content, no explanation.`;
|
||||
|
||||
// Determine if the source is text-based
|
||||
const isTextBased =
|
||||
sourceMime.startsWith("text/") ||
|
||||
sourceMime === "application/json" ||
|
||||
sourceMime === "image/svg+xml";
|
||||
|
||||
let userContent: string;
|
||||
if (isTextBased) {
|
||||
userContent = fileBuffer.toString("utf-8");
|
||||
} else {
|
||||
userContent = `Convert this binary ${sourceMime} file "${originalFilename}" to ${targetFormat} format. Provide the best-effort conversion or transformation.`;
|
||||
}
|
||||
|
||||
const result = await puterChatComplete([
|
||||
{ role: "system", content: systemPrompt },
|
||||
{ role: "user", content: userContent },
|
||||
]);
|
||||
|
||||
const outputBuffer = Buffer.from(result, "utf-8");
|
||||
const baseName = originalFilename.replace(/\.[^.]+$/, "");
|
||||
const bundle: ConvertBundle = {
|
||||
type: "convert-bundle",
|
||||
outputFilename: `${baseName}.${targetFormat.toLowerCase()}`,
|
||||
outputMime: getTargetMime(targetFormat),
|
||||
outputBase64: outputBuffer.toString("base64"),
|
||||
method: "ai-bridge",
|
||||
};
|
||||
|
||||
return {
|
||||
filename: "convert-bundle.json",
|
||||
contentType: "application/json",
|
||||
buffer: Buffer.from(JSON.stringify(bundle)),
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Main renderer ─────────────────────────────────────────────────────────────
|
||||
|
||||
export async function renderConvert(
|
||||
input: Record<string, unknown>,
|
||||
): Promise<RenderResult> {
|
||||
const fileBase64 =
|
||||
typeof input.fileBase64 === "string" ? input.fileBase64 : "";
|
||||
const sourceMime =
|
||||
typeof input.sourceMime === "string" ? input.sourceMime : "application/octet-stream";
|
||||
const targetFormat =
|
||||
typeof input.targetFormat === "string" ? input.targetFormat : "txt";
|
||||
const originalFilename =
|
||||
typeof input.originalFilename === "string"
|
||||
? input.originalFilename
|
||||
: `file.${targetFormat}`;
|
||||
|
||||
const fileBuffer = Buffer.from(fileBase64, "base64");
|
||||
|
||||
// Route by format category
|
||||
if (isImageFormat(sourceMime) && isImageFormat(targetFormat)) {
|
||||
return convertImage(fileBuffer, sourceMime, targetFormat, originalFilename);
|
||||
}
|
||||
|
||||
if (isAVFormat(sourceMime) || isAVFormat(targetFormat)) {
|
||||
return convertAV(fileBuffer, sourceMime, targetFormat, originalFilename);
|
||||
}
|
||||
|
||||
if (isDataFormat(sourceMime) || isDataFormat(targetFormat)) {
|
||||
return convertData(fileBuffer, sourceMime, targetFormat, originalFilename);
|
||||
}
|
||||
|
||||
// All other pairs: AI-bridge fallback
|
||||
return convertViaAiBridge(fileBuffer, sourceMime, targetFormat, originalFilename);
|
||||
}
|
||||
111
server/src/services/renderers/social-renderer.ts
Normal file
111
server/src/services/renderers/social-renderer.ts
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
import { puterChatComplete } from "../puter-inference.js";
|
||||
import type { RenderResult, SocialPostBundle } from "./types.js";
|
||||
|
||||
// ─── Platform character limits ──────────────────────────────────────────────────
|
||||
|
||||
export const PLATFORM_CHAR_LIMITS: Record<string, number> = {
|
||||
"twitter-x": 280,
|
||||
"linkedin": 3000,
|
||||
"instagram-caption": 2200,
|
||||
"instagram-carousel": 300, // per slide
|
||||
};
|
||||
|
||||
// ─── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
function buildSystemPrompt(platform: string, charLimit: number): string {
|
||||
if (platform === "instagram-carousel") {
|
||||
return [
|
||||
`You are a social media copywriter specializing in Instagram carousels.`,
|
||||
`Generate an Instagram carousel with 5-10 slides, each under ${charLimit} characters.`,
|
||||
`Also suggest 3-5 relevant hashtags.`,
|
||||
`Return JSON only:`,
|
||||
`{ "post": "intro caption", "slides": ["slide 1 text", "slide 2 text", "..."], "hashtags": ["#tag1", "#tag2"] }`,
|
||||
`No explanation, no markdown fences. Just the JSON object.`,
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
return [
|
||||
`You are a social media copywriter.`,
|
||||
`Generate a ${platform} post under ${charLimit} characters.`,
|
||||
`Also suggest 3-5 relevant hashtags.`,
|
||||
`Return JSON only:`,
|
||||
`{ "post": "...", "hashtags": ["#tag1", "#tag2"] }`,
|
||||
`No explanation, no markdown fences. Just the JSON object.`,
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
interface ParsedPost {
|
||||
post: string;
|
||||
hashtags: string[];
|
||||
slides?: string[];
|
||||
}
|
||||
|
||||
function parseLlmJson(raw: string): ParsedPost {
|
||||
// Strip markdown fences if present
|
||||
const match =
|
||||
raw.match(/```json\s*([\s\S]*?)\s*```/) ||
|
||||
raw.match(/```\s*([\s\S]*?)\s*```/) ||
|
||||
raw.match(/(\{[\s\S]*\})/);
|
||||
|
||||
const jsonStr = match ? match[1] : raw;
|
||||
const parsed = JSON.parse(jsonStr.trim()) as unknown;
|
||||
|
||||
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
||||
throw new Error("LLM response is not a JSON object");
|
||||
}
|
||||
|
||||
const obj = parsed as Record<string, unknown>;
|
||||
|
||||
const post = typeof obj.post === "string" ? obj.post : "";
|
||||
const hashtags = Array.isArray(obj.hashtags)
|
||||
? (obj.hashtags as unknown[]).filter((h): h is string => typeof h === "string")
|
||||
: [];
|
||||
const slides = Array.isArray(obj.slides)
|
||||
? (obj.slides as unknown[]).filter((s): s is string => typeof s === "string")
|
||||
: undefined;
|
||||
|
||||
return { post, hashtags, slides };
|
||||
}
|
||||
|
||||
// ─── Main renderer ──────────────────────────────────────────────────────────────
|
||||
|
||||
export async function renderSocialPost(
|
||||
input: Record<string, unknown>,
|
||||
): Promise<RenderResult> {
|
||||
const prompt =
|
||||
typeof input.prompt === "string" ? input.prompt : "a post about technology";
|
||||
const platform =
|
||||
typeof input.platform === "string" ? input.platform : "twitter-x";
|
||||
|
||||
const charLimit = PLATFORM_CHAR_LIMITS[platform];
|
||||
if (charLimit === undefined) {
|
||||
throw new Error(
|
||||
`renderSocialPost: unknown platform "${platform}". ` +
|
||||
`Available: ${Object.keys(PLATFORM_CHAR_LIMITS).join(", ")}`,
|
||||
);
|
||||
}
|
||||
|
||||
const systemPrompt = buildSystemPrompt(platform, charLimit);
|
||||
|
||||
const rawResponse = await puterChatComplete([
|
||||
{ role: "system", content: systemPrompt },
|
||||
{ role: "user", content: prompt },
|
||||
]);
|
||||
|
||||
const parsed = parseLlmJson(rawResponse);
|
||||
|
||||
const bundle: SocialPostBundle = {
|
||||
type: "social-post-bundle",
|
||||
platform,
|
||||
post: parsed.post,
|
||||
hashtags: parsed.hashtags,
|
||||
...(parsed.slides !== undefined ? { slides: parsed.slides } : {}),
|
||||
charLimit,
|
||||
};
|
||||
|
||||
return {
|
||||
filename: `social-${platform}.json`,
|
||||
contentType: "application/json",
|
||||
buffer: Buffer.from(JSON.stringify(bundle)),
|
||||
};
|
||||
}
|
||||
|
|
@ -35,4 +35,36 @@ export interface PaletteRole {
|
|||
light: { oklch: string; hex: string; wcagAA: boolean };
|
||||
}
|
||||
|
||||
export type ContentBundle = DiagramBundle | IconSetBundle | ThemePaletteBundle;
|
||||
export interface WallpaperBundle {
|
||||
type: "wallpaper-bundle";
|
||||
platform: string;
|
||||
width: number;
|
||||
height: number;
|
||||
pngBase64: string;
|
||||
prompt: string;
|
||||
}
|
||||
|
||||
export interface AppIconBundle {
|
||||
type: "app-icon-bundle";
|
||||
sizes: Array<{ size: number; pngBase64: string }>;
|
||||
prompt: string;
|
||||
}
|
||||
|
||||
export interface SocialPostBundle {
|
||||
type: "social-post-bundle";
|
||||
platform: string;
|
||||
post: string;
|
||||
hashtags: string[];
|
||||
slides?: string[];
|
||||
charLimit: number;
|
||||
}
|
||||
|
||||
export interface ConvertBundle {
|
||||
type: "convert-bundle";
|
||||
outputFilename: string;
|
||||
outputMime: string;
|
||||
outputBase64: string;
|
||||
method: "direct" | "ai-bridge";
|
||||
}
|
||||
|
||||
export type ContentBundle = DiagramBundle | IconSetBundle | ThemePaletteBundle | WallpaperBundle | AppIconBundle | SocialPostBundle | ConvertBundle;
|
||||
|
|
|
|||
130
server/src/services/renderers/wallpaper-renderer.ts
Normal file
130
server/src/services/renderers/wallpaper-renderer.ts
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
import sharp from "sharp";
|
||||
import { puterChatComplete } from "../puter-inference.js";
|
||||
import type { RenderResult, WallpaperBundle, AppIconBundle } from "./types.js";
|
||||
|
||||
// ─── Platform dimensions ────────────────────────────────────────────────────────
|
||||
|
||||
export const PLATFORM_DIMENSIONS: Record<
|
||||
string,
|
||||
{ width: number; height: number; label: string }
|
||||
> = {
|
||||
"desktop-hd": { width: 2560, height: 1440, label: "Desktop HD (2560×1440)" },
|
||||
"desktop-fhd": { width: 1920, height: 1080, label: "Desktop FHD (1920×1080)" },
|
||||
"desktop-4k": { width: 3840, height: 2160, label: "Desktop 4K (3840×2160)" },
|
||||
"mobile-portrait": { width: 1080, height: 1920, label: "Mobile Portrait (1080×1920)" },
|
||||
"mobile-landscape": { width: 1920, height: 1080, label: "Mobile Landscape (1920×1080)" },
|
||||
"og-image": { width: 1200, height: 630, label: "OG Image (1200×630)" },
|
||||
"twitter-card": { width: 1200, height: 628, label: "Twitter Card (1200×628)" },
|
||||
"instagram-post": { width: 1080, height: 1080, label: "Instagram Post (1080×1080)" },
|
||||
"instagram-banner": { width: 1080, height: 566, label: "Instagram Banner (1080×566)" },
|
||||
"linkedin-banner": { width: 1584, height: 396, label: "LinkedIn Banner (1584×396)" },
|
||||
"app-icon": { width: 1024, height: 1024, label: "App Icon (1024×1024)" },
|
||||
"favicon": { width: 32, height: 32, label: "Favicon (32×32)" },
|
||||
};
|
||||
|
||||
export const APP_ICON_SIZES = [1024, 512, 256, 64, 32] as const;
|
||||
|
||||
// ─── SVG generation ─────────────────────────────────────────────────────────────
|
||||
|
||||
function buildWallpaperSystemPrompt(width: number, height: number): string {
|
||||
return [
|
||||
`You are an SVG artwork generator.`,
|
||||
`Output ONLY valid SVG — no markdown fences, no explanation, no surrounding text.`,
|
||||
`Use viewBox="0 0 ${width} ${height}" matching the target dimensions exactly.`,
|
||||
`Create a visually rich scene based on the user prompt.`,
|
||||
`Use gradients, shapes, patterns, and organic forms to fill the canvas.`,
|
||||
`Do NOT include any text elements (<text>, <tspan>).`,
|
||||
`Do NOT include external images or scripts.`,
|
||||
`The SVG must start with <svg and end with </svg>.`,
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function stripMarkdownFences(raw: string): string {
|
||||
return raw
|
||||
.trim()
|
||||
.replace(/^```(?:svg|xml)?\s*/i, "")
|
||||
.replace(/\s*```$/, "")
|
||||
.trim();
|
||||
}
|
||||
|
||||
|
||||
// ─── Rasterization helpers ──────────────────────────────────────────────────────
|
||||
|
||||
async function rasterizeSvgToPng(
|
||||
svgString: string,
|
||||
width: number,
|
||||
height: number,
|
||||
): Promise<Buffer> {
|
||||
return sharp(Buffer.from(svgString), { density: 300 })
|
||||
.resize(width, height, { fit: "fill" })
|
||||
.png({ compressionLevel: 9 })
|
||||
.toBuffer();
|
||||
}
|
||||
|
||||
// ─── Main renderer ──────────────────────────────────────────────────────────────
|
||||
|
||||
export async function renderWallpaper(
|
||||
input: Record<string, unknown>,
|
||||
): Promise<RenderResult> {
|
||||
const prompt =
|
||||
typeof input.prompt === "string" ? input.prompt : "abstract digital art";
|
||||
const platform =
|
||||
typeof input.platform === "string" ? input.platform : "desktop-fhd";
|
||||
|
||||
const dims = PLATFORM_DIMENSIONS[platform];
|
||||
if (!dims) {
|
||||
throw new Error(
|
||||
`renderWallpaper: unknown platform "${platform}". ` +
|
||||
`Available: ${Object.keys(PLATFORM_DIMENSIONS).join(", ")}`,
|
||||
);
|
||||
}
|
||||
|
||||
const { width, height } = dims;
|
||||
const systemPrompt = buildWallpaperSystemPrompt(width, height);
|
||||
|
||||
const rawSvg = await puterChatComplete([
|
||||
{ role: "system", content: systemPrompt },
|
||||
{ role: "user", content: prompt },
|
||||
]);
|
||||
const svgString = stripMarkdownFences(rawSvg);
|
||||
|
||||
// App icon / favicon → multi-size bundle
|
||||
if (platform === "app-icon" || platform === "favicon") {
|
||||
const sizes: AppIconBundle["sizes"] = [];
|
||||
|
||||
for (const size of APP_ICON_SIZES) {
|
||||
const pngBuffer = await rasterizeSvgToPng(svgString, size, size);
|
||||
sizes.push({ size, pngBase64: pngBuffer.toString("base64") });
|
||||
}
|
||||
|
||||
const bundle: AppIconBundle = {
|
||||
type: "app-icon-bundle",
|
||||
sizes,
|
||||
prompt,
|
||||
};
|
||||
|
||||
return {
|
||||
filename: "app-icon-bundle.json",
|
||||
contentType: "application/json",
|
||||
buffer: Buffer.from(JSON.stringify(bundle)),
|
||||
};
|
||||
}
|
||||
|
||||
// All other platforms → single PNG at exact target dimensions
|
||||
const pngBuffer = await rasterizeSvgToPng(svgString, width, height);
|
||||
|
||||
const bundle: WallpaperBundle = {
|
||||
type: "wallpaper-bundle",
|
||||
platform,
|
||||
width,
|
||||
height,
|
||||
pngBase64: pngBuffer.toString("base64"),
|
||||
prompt,
|
||||
};
|
||||
|
||||
return {
|
||||
filename: `wallpaper-${platform}.png`,
|
||||
contentType: "image/png",
|
||||
buffer: pngBuffer,
|
||||
};
|
||||
}
|
||||
26
server/src/utils/execFileNoThrow.ts
Normal file
26
server/src/utils/execFileNoThrow.ts
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
import { execFile } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
export interface ExecFileResult {
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
status: 0 | 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run an external binary and return its output without throwing.
|
||||
* Returns status 0 on success, status 1 on error (non-zero exit or spawn failure).
|
||||
*/
|
||||
export async function execFileNoThrow(
|
||||
file: string,
|
||||
args: string[],
|
||||
): Promise<ExecFileResult> {
|
||||
try {
|
||||
const result = await execFileAsync(file, args, { timeout: 10_000 });
|
||||
return { stdout: result.stdout, stderr: result.stderr, status: 0 };
|
||||
} catch {
|
||||
return { stdout: "", stderr: "", status: 1 };
|
||||
}
|
||||
}
|
||||
|
|
@ -53,6 +53,7 @@ const InviteLandingPage = lazy(() => import("./pages/InviteLanding").then(m => (
|
|||
const NotFoundPage = lazy(() => import("./pages/NotFound").then(m => ({ default: m.NotFoundPage })));
|
||||
const PersonalAssistant = lazy(() => import("./pages/PersonalAssistant").then(m => ({ default: m.PersonalAssistant })));
|
||||
const ContentStudio = lazy(() => import("./pages/ContentStudio").then(m => ({ default: m.ContentStudio })));
|
||||
const ConvertPage = lazy(() => import("./pages/ConvertPage").then(m => ({ default: m.ConvertPage })));
|
||||
|
||||
function BootstrapPendingPage({ hasActiveInvite = false }: { hasActiveInvite?: boolean }) {
|
||||
return (
|
||||
|
|
@ -179,6 +180,9 @@ function boardRoutes() {
|
|||
<Route path="assistant" element={<PersonalAssistant />} />
|
||||
<Route path="assistant/:conversationId" element={<PersonalAssistant />} />
|
||||
<Route path="content-studio" element={<ContentStudio />} />
|
||||
<Route path="convert" element={<ConvertPage />} />
|
||||
<Route path="convert/:sourceFormat" element={<ConvertPage />} />
|
||||
<Route path="convert/:sourceFormat/:targetFormat" element={<ConvertPage />} />
|
||||
<Route path="design-guide" element={<DesignGuide />} />
|
||||
<Route path="tests/ux/runs" element={<RunTranscriptUxLab />} />
|
||||
<Route path=":pluginRoutePath" element={<PluginPage />} />
|
||||
|
|
|
|||
54
ui/src/api/convert.ts
Normal file
54
ui/src/api/convert.ts
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
import { api } from "./client";
|
||||
|
||||
export interface ConverterCapabilities {
|
||||
imageConverter: boolean;
|
||||
audioVideoConverter: boolean;
|
||||
docConverter: boolean;
|
||||
dataConverter: boolean;
|
||||
}
|
||||
|
||||
export interface ConvertJobResult {
|
||||
jobId: string;
|
||||
status: string;
|
||||
}
|
||||
|
||||
export interface ConvertMimeError {
|
||||
error: string;
|
||||
actualMime: string;
|
||||
claimedMime: string;
|
||||
}
|
||||
|
||||
export async function submitConvertJob(
|
||||
companyId: string,
|
||||
file: File,
|
||||
targetFormat: string,
|
||||
): Promise<ConvertJobResult | ConvertMimeError> {
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
formData.append("targetFormat", targetFormat);
|
||||
|
||||
// Use fetch directly so we can inspect 422 vs 202 status codes before throwing
|
||||
const res = await fetch(`/api/companies/${companyId}/convert`, {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
credentials: "include",
|
||||
// Do NOT set Content-Type — browser sets multipart boundary automatically
|
||||
});
|
||||
|
||||
if (res.status === 422) {
|
||||
return (await res.json()) as ConvertMimeError;
|
||||
}
|
||||
|
||||
if (res.status === 202) {
|
||||
return (await res.json()) as ConvertJobResult;
|
||||
}
|
||||
|
||||
const errorBody = await res.json().catch(() => null);
|
||||
const message =
|
||||
(errorBody as { error?: string } | null)?.error ?? `Request failed: ${res.status}`;
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
export async function getConverterCapabilities(): Promise<ConverterCapabilities> {
|
||||
return api.get<ConverterCapabilities>("/system/converters");
|
||||
}
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { Send, Loader2, Paperclip, X } from "lucide-react";
|
||||
import { Send, Loader2, Paperclip, X, WifiOff } from "lucide-react";
|
||||
import { useSystemProviders } from "../hooks/useSystemProviders";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ChatSlashCommandPopover } from "./ChatSlashCommandPopover";
|
||||
import { ChatMentionPopover } from "./ChatMentionPopover";
|
||||
|
|
@ -40,6 +41,7 @@ export function ChatInput({
|
|||
}: ChatInputProps) {
|
||||
const [value, setValue] = useState("");
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const { providers } = useSystemProviders();
|
||||
|
||||
// Slash command popover state
|
||||
const [slashOpen, setSlashOpen] = useState(false);
|
||||
|
|
@ -251,6 +253,17 @@ export function ChatInput({
|
|||
/>
|
||||
)}
|
||||
|
||||
{/* Offline badge — shown when local Whisper model is detected */}
|
||||
{enableVoiceInput && providers?.whisperAvailable && (
|
||||
<span
|
||||
className="text-xs text-muted-foreground flex items-center gap-1"
|
||||
aria-label="Voice input is offline (local model)"
|
||||
>
|
||||
<WifiOff className="h-3 w-3" />
|
||||
Offline
|
||||
</span>
|
||||
)}
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
variant="ghost"
|
||||
|
|
|
|||
489
ui/src/components/ConvertPanel.tsx
Normal file
489
ui/src/components/ConvertPanel.tsx
Normal file
|
|
@ -0,0 +1,489 @@
|
|||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { Info, Loader2 } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useContentJob } from "@/hooks/useContentJob";
|
||||
import { getContentJobAsset } from "@/api/contentJobs";
|
||||
import {
|
||||
submitConvertJob,
|
||||
getConverterCapabilities,
|
||||
type ConverterCapabilities,
|
||||
type ConvertMimeError,
|
||||
} from "@/api/convert";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Format groups
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const FORMAT_GROUPS: Record<string, string[]> = {
|
||||
Images: ["png", "jpg", "svg", "webp", "gif"],
|
||||
"Audio/Video": ["mp3", "mp4", "wav", "ogg", "webm"],
|
||||
Documents: ["md", "html", "pdf", "docx"],
|
||||
Data: ["csv", "json", "xlsx"],
|
||||
};
|
||||
|
||||
// All formats as a flat list for validation
|
||||
const ALL_FORMATS = Object.values(FORMAT_GROUPS).flat();
|
||||
|
||||
interface ConvertBundle {
|
||||
type: "convert-bundle";
|
||||
outputFilename: string;
|
||||
outputMime: string;
|
||||
outputBase64: string;
|
||||
method: "direct" | "ai-bridge";
|
||||
}
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
function isConvertMimeError(result: object): result is ConvertMimeError {
|
||||
return "error" in result && "actualMime" in result;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Determine whether a format group requires a direct converter
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function groupNeedsDirectConverter(group: string, capabilities: ConverterCapabilities): boolean {
|
||||
switch (group) {
|
||||
case "Images":
|
||||
return !capabilities.imageConverter;
|
||||
case "Audio/Video":
|
||||
return !capabilities.audioVideoConverter;
|
||||
case "Documents":
|
||||
return !capabilities.docConverter;
|
||||
case "Data":
|
||||
return !capabilities.dataConverter;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function targetGroupForFormat(format: string): string | null {
|
||||
for (const [group, formats] of Object.entries(FORMAT_GROUPS)) {
|
||||
if (formats.includes(format.toLowerCase())) return group;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Props
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface ConvertPanelProps {
|
||||
initialSourceFormat?: string;
|
||||
initialTargetFormat?: string;
|
||||
companyId: string;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function ConvertPanel({ initialSourceFormat, initialTargetFormat, companyId }: ConvertPanelProps) {
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [targetFormat, setTargetFormat] = useState<string | null>(
|
||||
initialTargetFormat ? initialTargetFormat.toLowerCase() : null,
|
||||
);
|
||||
const [mimeError, setMimeError] = useState<ConvertMimeError | null>(null);
|
||||
const [dragOver, setDragOver] = useState(false);
|
||||
const [capabilities, setCapabilities] = useState<ConverterCapabilities | null>(null);
|
||||
const [convertBundle, setConvertBundle] = useState<ConvertBundle | null>(null);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const job = useContentJob(companyId);
|
||||
|
||||
// Normalize initialSourceFormat
|
||||
const normalizedSourceFormat = initialSourceFormat
|
||||
? ALL_FORMATS.includes(initialSourceFormat.toLowerCase())
|
||||
? initialSourceFormat.toLowerCase()
|
||||
: null
|
||||
: null;
|
||||
|
||||
// Fetch converter capabilities on mount
|
||||
useEffect(() => {
|
||||
getConverterCapabilities()
|
||||
.then(setCapabilities)
|
||||
.catch(() => {
|
||||
// Silently fall back — all formats will show AI bridge notice
|
||||
setCapabilities({
|
||||
imageConverter: false,
|
||||
audioVideoConverter: false,
|
||||
docConverter: false,
|
||||
dataConverter: false,
|
||||
});
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Fetch asset bundle when job completes
|
||||
useEffect(() => {
|
||||
if (job.status === "done" && job.resultAssetId && !convertBundle) {
|
||||
void getContentJobAsset(companyId, job.resultAssetId).then(async (assetUrl) => {
|
||||
const res = await fetch(assetUrl);
|
||||
const text = await res.text();
|
||||
try {
|
||||
const parsed = JSON.parse(text) as ConvertBundle;
|
||||
setConvertBundle(parsed);
|
||||
} catch {
|
||||
// ignore parse error — error state will show
|
||||
}
|
||||
});
|
||||
}
|
||||
}, [job.status, job.resultAssetId, convertBundle, companyId]);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// File selection helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function handleFileSelect(selected: File) {
|
||||
setFile(selected);
|
||||
setMimeError(null);
|
||||
setConvertBundle(null);
|
||||
}
|
||||
|
||||
function handleDragOver(e: React.DragEvent) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setDragOver(true);
|
||||
}
|
||||
|
||||
function handleDragLeave(e: React.DragEvent) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (e.currentTarget === e.target || !e.currentTarget.contains(e.relatedTarget as Node)) {
|
||||
setDragOver(false);
|
||||
}
|
||||
}
|
||||
|
||||
function handleDrop(e: React.DragEvent) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setDragOver(false);
|
||||
const dropped = e.dataTransfer.files[0];
|
||||
if (dropped) handleFileSelect(dropped);
|
||||
}
|
||||
|
||||
function handleKeyDown(e: React.KeyboardEvent) {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
fileInputRef.current?.click();
|
||||
}
|
||||
}
|
||||
|
||||
function handleInputChange(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
const selected = e.target.files?.[0];
|
||||
if (selected) handleFileSelect(selected);
|
||||
// Reset input so same file can be reselected
|
||||
e.target.value = "";
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Convert action
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const handleConvert = useCallback(async () => {
|
||||
if (!file || !targetFormat) return;
|
||||
setMimeError(null);
|
||||
setConvertBundle(null);
|
||||
setIsSubmitting(true);
|
||||
|
||||
try {
|
||||
const result = await submitConvertJob(companyId, file, targetFormat);
|
||||
if (isConvertMimeError(result)) {
|
||||
setMimeError(result);
|
||||
setIsSubmitting(false);
|
||||
return;
|
||||
}
|
||||
// Track job via SSE — the hook handles polling
|
||||
// We need to reuse the SSE pattern, but submitConvertJob already submitted.
|
||||
// Re-submit via the job hook SSE tracking by connecting the existing jobId.
|
||||
// However, useContentJob.submit() calls submitContentJob internally.
|
||||
// For convert, we already have the jobId — connect SSE directly.
|
||||
void connectJobSse(result.jobId);
|
||||
} catch (err) {
|
||||
setIsSubmitting(false);
|
||||
job.reset();
|
||||
}
|
||||
}, [file, targetFormat, companyId]);
|
||||
|
||||
// Connect SSE for an already-submitted job
|
||||
const connectJobSse = useCallback(
|
||||
async (jobId: string) => {
|
||||
// Directly connect SSE without re-submitting
|
||||
// We'll track progress state manually via EventSource
|
||||
setIsSubmitting(false);
|
||||
// Leverage the hook's internal submit by injecting: since useContentJob doesn't
|
||||
// expose a "track existing job" method, we call submit with a dummy type that
|
||||
// matches — but the convert route already dispatched the job.
|
||||
// Instead, manage SSE here directly (same pattern as useContentJob internals).
|
||||
const url = `/api/companies/${companyId}/content-jobs/${jobId}/events`;
|
||||
const es = new EventSource(url, { withCredentials: true });
|
||||
|
||||
es.addEventListener("status", (e: MessageEvent) => {
|
||||
const data = JSON.parse(e.data as string) as {
|
||||
status?: string;
|
||||
resultAssetId?: string | null;
|
||||
errorMessage?: string | null;
|
||||
};
|
||||
|
||||
if (data.status === "done" || data.status === "failed") {
|
||||
es.close();
|
||||
if (data.status === "done" && data.resultAssetId) {
|
||||
void getContentJobAsset(companyId, data.resultAssetId).then(async (assetUrl) => {
|
||||
const res = await fetch(assetUrl);
|
||||
const text = await res.text();
|
||||
try {
|
||||
const parsed = JSON.parse(text) as ConvertBundle;
|
||||
setConvertBundle(parsed);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
// Sync progress into job state via the hook's submit? Not possible.
|
||||
// Track progress via local state here.
|
||||
setJobProgress(data.status ?? "queued");
|
||||
});
|
||||
|
||||
es.addEventListener("error", () => {
|
||||
es.close();
|
||||
setJobProgressState("failed");
|
||||
});
|
||||
},
|
||||
[companyId],
|
||||
);
|
||||
|
||||
// Local SSE state (since we bypass useContentJob for convert submissions)
|
||||
type LocalJobStatus = "idle" | "queued" | "running" | "done" | "failed";
|
||||
const [jobProgressState, setJobProgressState] = useState<LocalJobStatus>("idle");
|
||||
|
||||
function setJobProgress(status: string) {
|
||||
setJobProgressState(status as LocalJobStatus);
|
||||
}
|
||||
|
||||
function progressValue(): number {
|
||||
switch (jobProgressState) {
|
||||
case "queued":
|
||||
return 5;
|
||||
case "running":
|
||||
return 50;
|
||||
case "done":
|
||||
return 100;
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
const isConverting = isSubmitting || jobProgressState === "queued" || jobProgressState === "running";
|
||||
const canConvert = file !== null && targetFormat !== null && !isConverting;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// AI fallback notice
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function showAiFallbackNotice(): boolean {
|
||||
if (!capabilities || !targetFormat) return false;
|
||||
const group = targetGroupForFormat(targetFormat);
|
||||
if (!group) return false;
|
||||
return groupNeedsDirectConverter(group, capabilities);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Download handler
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function handleDownload() {
|
||||
if (!convertBundle) return;
|
||||
const binary = atob(convertBundle.outputBase64);
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
for (let i = 0; i < binary.length; i++) {
|
||||
bytes[i] = binary.charCodeAt(i);
|
||||
}
|
||||
const blob = new Blob([bytes], { type: convertBundle.outputMime });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = convertBundle.outputFilename;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Drag-drop zone copy
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function dropZoneIdleCopy(): string {
|
||||
if (normalizedSourceFormat) {
|
||||
return `Drop a ${normalizedSourceFormat.toUpperCase()} file here or click to browse`;
|
||||
}
|
||||
return "Drop a file here or click to browse";
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Drag-drop zone classes
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function dropZoneClasses(): string {
|
||||
const base =
|
||||
"relative flex min-h-[120px] cursor-pointer flex-col items-center justify-center gap-2 rounded-md border-2 border-dashed p-6 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring";
|
||||
const reducedMotionTransition = "motion-safe:transition-colors motion-safe:duration-150";
|
||||
|
||||
if (mimeError) {
|
||||
return cn(base, reducedMotionTransition, "border-destructive bg-destructive/10");
|
||||
}
|
||||
if (dragOver) {
|
||||
return cn(base, "border-primary bg-accent/30");
|
||||
}
|
||||
return cn(base, reducedMotionTransition, "border-border bg-secondary");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Render
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
{/* Two-column layout: source left, target right */}
|
||||
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
|
||||
{/* ConvertSourceZone */}
|
||||
<div>
|
||||
<p className="mb-2 text-sm font-medium">Source File</p>
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label="Upload file — drop here or press Enter to browse"
|
||||
className={dropZoneClasses()}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
onKeyDown={handleKeyDown}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
className="hidden"
|
||||
onChange={handleInputChange}
|
||||
tabIndex={-1}
|
||||
/>
|
||||
|
||||
{dragOver ? (
|
||||
<p className="text-sm text-primary font-medium">Release to select</p>
|
||||
) : mimeError ? (
|
||||
<p className="text-sm text-destructive text-center">
|
||||
File extension does not match content. Got {mimeError.actualMime}, expected{" "}
|
||||
{mimeError.claimedMime}.
|
||||
</p>
|
||||
) : file ? (
|
||||
<div className="flex flex-col items-center gap-1 text-center">
|
||||
<p className="text-sm font-medium">{file.name}</p>
|
||||
<p className="text-xs text-muted-foreground">{formatBytes(file.size)}</p>
|
||||
<p className="font-mono text-xs text-muted-foreground">{file.type || "unknown"}</p>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground text-center">{dropZoneIdleCopy()}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ConvertTargetSelector */}
|
||||
<div>
|
||||
<p className="mb-2 text-sm font-medium">Target Format</p>
|
||||
<div
|
||||
role="radiogroup"
|
||||
aria-label="Target format"
|
||||
className="flex flex-col gap-4"
|
||||
>
|
||||
{Object.entries(FORMAT_GROUPS).map(([group, formats]) => (
|
||||
<div key={group}>
|
||||
<p className="mb-1.5 text-sm font-medium text-muted-foreground">{group}</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{formats.map((fmt) => (
|
||||
<button
|
||||
key={fmt}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={targetFormat === fmt}
|
||||
aria-label={fmt.toUpperCase()}
|
||||
className={cn(
|
||||
"rounded-full px-3 py-1 text-xs font-medium transition-colors",
|
||||
targetFormat === fmt
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "bg-muted text-muted-foreground hover:bg-muted/80",
|
||||
)}
|
||||
onClick={() => setTargetFormat(fmt)}
|
||||
>
|
||||
{fmt.toUpperCase()}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* AI fallback notice */}
|
||||
{showAiFallbackNotice() && (
|
||||
<div className="mt-3 flex items-center gap-2 rounded-md bg-secondary p-3">
|
||||
<Info className="h-[14px] w-[14px] shrink-0 text-muted-foreground" />
|
||||
<p className="text-xs text-muted-foreground">
|
||||
No direct converter for this pair — AI bridge will be used.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ConvertActionBar */}
|
||||
<div className="flex flex-col gap-3">
|
||||
{convertBundle ? (
|
||||
<Button onClick={handleDownload}>
|
||||
Download {convertBundle.outputFilename}
|
||||
</Button>
|
||||
) : jobProgressState === "failed" ? (
|
||||
<>
|
||||
<p className="text-sm text-destructive">
|
||||
Conversion failed — {job.errorMessage ?? "Unknown error"}. Try again.
|
||||
</p>
|
||||
<Button
|
||||
disabled={!canConvert}
|
||||
onClick={() => {
|
||||
setJobProgressState("idle");
|
||||
void handleConvert();
|
||||
}}
|
||||
>
|
||||
Convert File
|
||||
</Button>
|
||||
</>
|
||||
) : isConverting ? (
|
||||
<>
|
||||
<Button disabled>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Converting...
|
||||
</Button>
|
||||
<Progress value={progressValue()} className="h-2" />
|
||||
</>
|
||||
) : jobProgressState === "idle" && !file && !targetFormat ? (
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="text-base font-semibold text-muted-foreground">No conversion yet</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Upload a file and choose a target format to convert.
|
||||
</p>
|
||||
<Button disabled className="mt-2 self-start">
|
||||
Convert File
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<Button disabled={!canConvert} onClick={() => void handleConvert()}>
|
||||
Convert File
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
172
ui/src/components/SocialPostPanel.tsx
Normal file
172
ui/src/components/SocialPostPanel.tsx
Normal file
|
|
@ -0,0 +1,172 @@
|
|||
import { useState } from "react";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { useContentJob } from "@/hooks/useContentJob";
|
||||
import { getContentJobAsset } from "@/api/contentJobs";
|
||||
import { SocialPostResult } from "./SocialPostResult";
|
||||
|
||||
export const PLATFORM_CHAR_LIMITS: Record<string, number> = {
|
||||
"twitter-x": 280,
|
||||
"linkedin": 3000,
|
||||
"instagram-caption": 2200,
|
||||
"instagram-carousel": 300,
|
||||
};
|
||||
|
||||
const PLATFORM_OPTIONS = [
|
||||
{ value: "twitter-x", label: "Twitter/X" },
|
||||
{ value: "linkedin", label: "LinkedIn" },
|
||||
{ value: "instagram-caption", label: "Instagram Caption" },
|
||||
{ value: "instagram-carousel", label: "Instagram Carousel" },
|
||||
];
|
||||
|
||||
export type SocialPostBundle = {
|
||||
type: "social-post-bundle";
|
||||
platform: string;
|
||||
post: string;
|
||||
hashtags: string[];
|
||||
slides?: string[];
|
||||
charLimit: number;
|
||||
};
|
||||
|
||||
interface SocialPostPanelProps {
|
||||
companyId: string;
|
||||
}
|
||||
|
||||
export function SocialPostPanel({ companyId }: SocialPostPanelProps) {
|
||||
const [prompt, setPrompt] = useState("");
|
||||
const [platform, setPlatform] = useState("twitter-x");
|
||||
const [charCount, setCharCount] = useState(0);
|
||||
const [bundle, setBundle] = useState<SocialPostBundle | null>(null);
|
||||
const job = useContentJob(companyId);
|
||||
|
||||
const charLimit = PLATFORM_CHAR_LIMITS[platform] ?? 280;
|
||||
const isOverLimit = charCount > charLimit;
|
||||
const isGenerating = job.status === "queued" || job.status === "running";
|
||||
const isIdle = job.status === "idle";
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!prompt.trim() || isGenerating) return;
|
||||
setBundle(null);
|
||||
await job.submit("social-post", { prompt, platform });
|
||||
}
|
||||
|
||||
function handlePromptChange(e: React.ChangeEvent<HTMLTextAreaElement>) {
|
||||
setPrompt(e.target.value);
|
||||
setCharCount(e.target.value.length);
|
||||
}
|
||||
|
||||
// Fetch asset when job completes
|
||||
if (job.status === "done" && job.resultAssetId && !bundle) {
|
||||
void getContentJobAsset(companyId, job.resultAssetId).then(async (assetUrl) => {
|
||||
const res = await fetch(assetUrl);
|
||||
const text = await res.text();
|
||||
try {
|
||||
const parsed = JSON.parse(text) as SocialPostBundle;
|
||||
setBundle(parsed);
|
||||
} catch {
|
||||
// ignore parse error — will show empty state
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="w-full">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-xl font-semibold">Generate Post</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-2">
|
||||
<label htmlFor="social-prompt" className="text-sm font-medium">
|
||||
Describe your topic
|
||||
</label>
|
||||
<Textarea
|
||||
id="social-prompt"
|
||||
rows={4}
|
||||
placeholder="Describe the topic or paste existing content to adapt..."
|
||||
value={prompt}
|
||||
onChange={handlePromptChange}
|
||||
disabled={isGenerating}
|
||||
/>
|
||||
<p
|
||||
className={`text-xs text-right ${isOverLimit ? "text-destructive" : "text-muted-foreground"}`}
|
||||
aria-live="polite"
|
||||
>
|
||||
{charCount} / {charLimit}{isOverLimit ? " — over limit" : ""}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<label htmlFor="social-platform" className="text-sm font-medium">
|
||||
Platform
|
||||
</label>
|
||||
<Select value={platform} onValueChange={setPlatform} disabled={isGenerating}>
|
||||
<SelectTrigger id="social-platform" className="w-full">
|
||||
<SelectValue placeholder="Select platform" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{PLATFORM_OPTIONS.map((opt) => (
|
||||
<SelectItem key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => void handleSubmit()}
|
||||
disabled={isGenerating || !prompt.trim()}
|
||||
className="w-full"
|
||||
>
|
||||
{isGenerating ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 size-4 animate-spin" aria-hidden="true" />
|
||||
Generating...
|
||||
</>
|
||||
) : (
|
||||
"Generate Post"
|
||||
)}
|
||||
</Button>
|
||||
|
||||
{isGenerating && (
|
||||
<Progress
|
||||
value={job.progress}
|
||||
role="progressbar"
|
||||
aria-valuenow={job.progress}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
aria-label="Post generation progress"
|
||||
/>
|
||||
)}
|
||||
|
||||
{job.status === "failed" && job.errorMessage && (
|
||||
<p className="text-sm text-destructive">
|
||||
Generation failed — {job.errorMessage}. Try again.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{bundle ? (
|
||||
<SocialPostResult bundle={bundle} />
|
||||
) : isIdle ? (
|
||||
<div className="flex flex-col items-center gap-2 py-8 text-center">
|
||||
<p className="text-xl font-semibold">No post yet</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Describe your topic and choose a platform to generate a ready-to-publish post.
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
115
ui/src/components/SocialPostResult.tsx
Normal file
115
ui/src/components/SocialPostResult.tsx
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
import { useState } from "react";
|
||||
import { CheckCheck } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
|
||||
import type { SocialPostBundle } from "./SocialPostPanel";
|
||||
|
||||
interface SocialPostResultProps {
|
||||
bundle: SocialPostBundle;
|
||||
}
|
||||
|
||||
export function SocialPostResult({ bundle }: SocialPostResultProps) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [copiedTag, setCopiedTag] = useState<string | null>(null);
|
||||
const [openSlides, setOpenSlides] = useState<Record<number, boolean>>({});
|
||||
|
||||
if (!bundle) {
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-2 py-8 text-center">
|
||||
<p className="text-xl font-semibold">No post yet</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Describe your topic and choose a platform to generate a ready-to-publish post.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
async function handleCopyPost() {
|
||||
await navigator.clipboard.writeText(bundle.post);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
}
|
||||
|
||||
async function handleCopyHashtag(tag: string) {
|
||||
await navigator.clipboard.writeText(tag);
|
||||
setCopiedTag(tag);
|
||||
setTimeout(() => setCopiedTag(null), 1500);
|
||||
}
|
||||
|
||||
function toggleSlide(index: number) {
|
||||
setOpenSlides((prev) => ({ ...prev, [index]: !prev[index] }));
|
||||
}
|
||||
|
||||
const hasSlides = bundle.slides && bundle.slides.length > 0;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
{/* Post text card */}
|
||||
<div className="bg-card rounded-lg p-4 flex flex-col gap-2">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-sm font-medium">Generated post</span>
|
||||
<span className="text-xs font-mono text-muted-foreground">
|
||||
{bundle.post.length} / {bundle.charLimit}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm whitespace-pre-wrap break-words">{bundle.post}</p>
|
||||
</div>
|
||||
|
||||
{/* Copy Post button */}
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
className="w-full"
|
||||
onClick={() => void handleCopyPost()}
|
||||
>
|
||||
{copied ? "Copied!" : "Copy Post"}
|
||||
</Button>
|
||||
|
||||
{/* Hashtag chips */}
|
||||
{bundle.hashtags && bundle.hashtags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{bundle.hashtags.map((tag) => (
|
||||
<button
|
||||
key={tag}
|
||||
type="button"
|
||||
role="button"
|
||||
aria-label={`Copy hashtag ${tag}`}
|
||||
className="inline-flex items-center gap-1 rounded-full bg-muted px-3 py-1 text-xs text-muted-foreground hover:bg-muted/80 transition-colors"
|
||||
onClick={() => void handleCopyHashtag(tag)}
|
||||
>
|
||||
{copiedTag === tag ? (
|
||||
<CheckCheck className="size-3" aria-hidden="true" />
|
||||
) : (
|
||||
tag
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Instagram Carousel slides */}
|
||||
{hasSlides && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<p className="text-sm font-medium">Carousel slides</p>
|
||||
{bundle.slides!.map((slide, index) => (
|
||||
<Collapsible
|
||||
key={index}
|
||||
open={!!openSlides[index]}
|
||||
onOpenChange={() => toggleSlide(index)}
|
||||
>
|
||||
<CollapsibleTrigger className="flex w-full items-center justify-between rounded-md border border-border bg-muted/50 px-4 py-2 text-sm font-medium hover:bg-muted transition-colors">
|
||||
<span>Slide {index + 1}</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{openSlides[index] ? "collapse" : "expand"}
|
||||
</span>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="rounded-b-md border border-t-0 border-border bg-card px-4 py-3">
|
||||
<p className="text-sm whitespace-pre-wrap break-words">{slide}</p>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
199
ui/src/components/WallpaperGeneratePanel.tsx
Normal file
199
ui/src/components/WallpaperGeneratePanel.tsx
Normal file
|
|
@ -0,0 +1,199 @@
|
|||
import { useState } from "react";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { useContentJob } from "@/hooks/useContentJob";
|
||||
import { getContentJobAsset } from "@/api/contentJobs";
|
||||
import { WallpaperPreview } from "./WallpaperPreview";
|
||||
|
||||
export const PLATFORM_DIMENSIONS: Record<string, { width: number; height: number; label: string }> = {
|
||||
"desktop-hd": { width: 2560, height: 1440, label: "Desktop HD (2560 × 1440)" },
|
||||
"desktop-fhd": { width: 1920, height: 1080, label: "Desktop FHD (1920 × 1080)" },
|
||||
"desktop-4k": { width: 3840, height: 2160, label: "Desktop 4K (3840 × 2160)" },
|
||||
"mobile-portrait": { width: 1080, height: 1920, label: "Mobile Portrait (1080 × 1920)" },
|
||||
"mobile-landscape": { width: 1920, height: 1080, label: "Mobile Landscape (1920 × 1080)" },
|
||||
"og-image": { width: 1200, height: 630, label: "OG Image (1200 × 630)" },
|
||||
"twitter-card": { width: 1200, height: 628, label: "Twitter Card (1200 × 628)" },
|
||||
"instagram-post": { width: 1080, height: 1080, label: "Instagram Post (1080 × 1080)" },
|
||||
"instagram-banner": { width: 1080, height: 566, label: "Instagram Banner (1080 × 566)" },
|
||||
"linkedin-banner": { width: 1584, height: 396, label: "LinkedIn Banner (1584 × 396)" },
|
||||
"app-icon": { width: 1024, height: 1024, label: "App Icon (1024 × 1024)" },
|
||||
"favicon": { width: 32, height: 32, label: "Favicon (32 × 32)" },
|
||||
};
|
||||
|
||||
const PLATFORM_GROUPS = [
|
||||
{
|
||||
label: "Desktop",
|
||||
platforms: ["desktop-hd", "desktop-fhd", "desktop-4k"],
|
||||
},
|
||||
{
|
||||
label: "Mobile",
|
||||
platforms: ["mobile-portrait", "mobile-landscape"],
|
||||
},
|
||||
{
|
||||
label: "Social",
|
||||
platforms: ["og-image", "twitter-card", "instagram-post", "instagram-banner", "linkedin-banner"],
|
||||
},
|
||||
{
|
||||
label: "App",
|
||||
platforms: ["app-icon", "favicon"],
|
||||
},
|
||||
];
|
||||
|
||||
export type WallpaperBundle = {
|
||||
type: "wallpaper-bundle";
|
||||
platform: string;
|
||||
width: number;
|
||||
height: number;
|
||||
pngBase64: string;
|
||||
prompt: string;
|
||||
};
|
||||
|
||||
export type AppIconBundle = {
|
||||
type: "app-icon-bundle";
|
||||
sizes: Array<{ size: number; pngBase64: string }>;
|
||||
prompt: string;
|
||||
};
|
||||
|
||||
interface WallpaperGeneratePanelProps {
|
||||
companyId: string;
|
||||
}
|
||||
|
||||
export function WallpaperGeneratePanel({ companyId }: WallpaperGeneratePanelProps) {
|
||||
const [prompt, setPrompt] = useState("");
|
||||
const [platform, setPlatform] = useState("desktop-hd");
|
||||
const [bundle, setBundle] = useState<WallpaperBundle | AppIconBundle | null>(null);
|
||||
const job = useContentJob(companyId);
|
||||
|
||||
const isGenerating = job.status === "queued" || job.status === "running";
|
||||
const isIdle = job.status === "idle";
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!prompt.trim() || isGenerating) return;
|
||||
setBundle(null);
|
||||
await job.submit("wallpaper", { prompt, platform });
|
||||
}
|
||||
|
||||
// Fetch asset when job completes
|
||||
if (job.status === "done" && job.resultAssetId && !bundle) {
|
||||
void getContentJobAsset(companyId, job.resultAssetId).then(async (assetUrl) => {
|
||||
const res = await fetch(assetUrl);
|
||||
const text = await res.text();
|
||||
try {
|
||||
const parsed = JSON.parse(text) as WallpaperBundle | AppIconBundle;
|
||||
setBundle(parsed);
|
||||
} catch {
|
||||
// ignore parse error — will show empty state
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="w-full">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-xl font-semibold">Generate Wallpaper</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-2">
|
||||
<label htmlFor="wallpaper-prompt" className="text-sm font-medium">
|
||||
Describe the scene or concept
|
||||
</label>
|
||||
<Textarea
|
||||
id="wallpaper-prompt"
|
||||
rows={4}
|
||||
placeholder="Describe the scene, mood, or concept..."
|
||||
value={prompt}
|
||||
onChange={(e) => setPrompt(e.target.value)}
|
||||
disabled={isGenerating}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<label htmlFor="wallpaper-platform" className="text-sm font-medium">
|
||||
Platform size
|
||||
</label>
|
||||
<Select value={platform} onValueChange={setPlatform} disabled={isGenerating}>
|
||||
<SelectTrigger id="wallpaper-platform" className="w-full">
|
||||
<SelectValue placeholder="Select platform" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{PLATFORM_GROUPS.map((group) => (
|
||||
<SelectGroup key={group.label}>
|
||||
<SelectLabel>{group.label}</SelectLabel>
|
||||
{group.platforms.map((key) => {
|
||||
const dim = PLATFORM_DIMENSIONS[key];
|
||||
return (
|
||||
<SelectItem key={key} value={key}>
|
||||
<span className="flex items-center justify-between gap-3 w-full">
|
||||
<span>{dim.label}</span>
|
||||
<span className="text-xs font-mono text-muted-foreground">
|
||||
{dim.width} × {dim.height}
|
||||
</span>
|
||||
</span>
|
||||
</SelectItem>
|
||||
);
|
||||
})}
|
||||
</SelectGroup>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => void handleSubmit()}
|
||||
disabled={isGenerating || !prompt.trim()}
|
||||
className="w-full"
|
||||
>
|
||||
{isGenerating ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 size-4 animate-spin" aria-hidden="true" />
|
||||
Generating...
|
||||
</>
|
||||
) : (
|
||||
"Generate Wallpaper"
|
||||
)}
|
||||
</Button>
|
||||
|
||||
{isGenerating && (
|
||||
<Progress
|
||||
value={job.progress}
|
||||
role="progressbar"
|
||||
aria-valuenow={job.progress}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
aria-label="Wallpaper generation progress"
|
||||
/>
|
||||
)}
|
||||
|
||||
{job.status === "failed" && job.errorMessage && (
|
||||
<p className="text-sm text-destructive">
|
||||
Render failed — {job.errorMessage}. Try again.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{bundle ? (
|
||||
<WallpaperPreview bundle={bundle} />
|
||||
) : isIdle ? (
|
||||
<div className="flex flex-col items-center gap-2 py-8 text-center">
|
||||
<p className="text-xl font-semibold">No image yet</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Describe a scene or mood and pick a platform size to generate a ready-to-use image.
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
107
ui/src/components/WallpaperPreview.tsx
Normal file
107
ui/src/components/WallpaperPreview.tsx
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
import type { WallpaperBundle, AppIconBundle } from "./WallpaperGeneratePanel";
|
||||
|
||||
function downloadPng(base64: string, filename: string) {
|
||||
const byteString = atob(base64);
|
||||
const ab = new ArrayBuffer(byteString.length);
|
||||
const ia = new Uint8Array(ab);
|
||||
for (let i = 0; i < byteString.length; i++) {
|
||||
ia[i] = byteString.charCodeAt(i);
|
||||
}
|
||||
const blob = new Blob([ab], { type: "image/png" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
interface WallpaperPreviewProps {
|
||||
bundle: WallpaperBundle | AppIconBundle;
|
||||
}
|
||||
|
||||
export function WallpaperPreview({ bundle }: WallpaperPreviewProps) {
|
||||
if (!bundle) {
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-2 py-8 text-center">
|
||||
<p className="text-xl font-semibold">No image yet</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Describe a scene or mood and pick a platform size to generate a ready-to-use image.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (bundle.type === "wallpaper-bundle") {
|
||||
const altText = bundle.prompt.slice(0, 100);
|
||||
const filename = `wallpaper-${bundle.platform}.png`;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex items-center justify-center rounded-lg overflow-hidden bg-muted">
|
||||
<img
|
||||
src={`data:image/png;base64,${bundle.pngBase64}`}
|
||||
alt={altText}
|
||||
className="max-h-80 object-contain"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center justify-center rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 transition-colors"
|
||||
onClick={() => downloadPng(bundle.pngBase64, filename)}
|
||||
>
|
||||
Download PNG
|
||||
</button>
|
||||
<span className="text-xs font-mono text-muted-foreground">
|
||||
{bundle.width} × {bundle.height}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (bundle.type === "app-icon-bundle") {
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<p className="text-sm font-medium">App Icon sizes</p>
|
||||
<div className="grid grid-cols-2 gap-3 md:grid-cols-3 lg:grid-cols-5">
|
||||
{bundle.sizes.map(({ size, pngBase64 }) => (
|
||||
<div
|
||||
key={size}
|
||||
className="flex flex-col items-center gap-2 rounded-lg border border-border bg-card p-3"
|
||||
>
|
||||
<img
|
||||
src={`data:image/png;base64,${pngBase64}`}
|
||||
alt={`App icon ${size}×${size}`}
|
||||
className="size-10 object-contain"
|
||||
/>
|
||||
<span className="text-xs font-mono text-muted-foreground">
|
||||
{size} × {size}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="text-xs text-primary underline underline-offset-2 hover:no-underline"
|
||||
onClick={() => downloadPng(pngBase64, `app-icon-${size}.png`)}
|
||||
>
|
||||
Download
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Fallback empty state
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-2 py-8 text-center">
|
||||
<p className="text-xl font-semibold">No image yet</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Describe a scene or mood and pick a platform size to generate a ready-to-use image.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
31
ui/src/hooks/useSystemProviders.ts
Normal file
31
ui/src/hooks/useSystemProviders.ts
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
import { useState, useEffect } from "react";
|
||||
import { api } from "../api/client";
|
||||
|
||||
export interface SystemProviders {
|
||||
whisperAvailable: boolean;
|
||||
piperAvailable: boolean;
|
||||
}
|
||||
|
||||
export function useSystemProviders(): { providers: SystemProviders | null; loading: boolean } {
|
||||
const [providers, setProviders] = useState<SystemProviders | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
api
|
||||
.get<{ voiceCapability?: { whisperAvailable?: boolean; piperAvailable?: boolean } }>(
|
||||
"/system/providers",
|
||||
)
|
||||
.then((data) => {
|
||||
const vc = data.voiceCapability;
|
||||
setProviders({
|
||||
whisperAvailable: vc?.whisperAvailable ?? false,
|
||||
piperAvailable: vc?.piperAvailable ?? false,
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
// Graceful degradation — badge simply won't show
|
||||
setProviders(null);
|
||||
});
|
||||
}, []);
|
||||
|
||||
return { providers, loading: providers === null };
|
||||
}
|
||||
|
|
@ -2,6 +2,8 @@ import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
|
|||
import { useCompany } from "../context/CompanyContext";
|
||||
import { DiagramGeneratePanel } from "../components/DiagramGeneratePanel";
|
||||
import { IconGeneratePanel } from "../components/IconGeneratePanel";
|
||||
import { WallpaperGeneratePanel } from "../components/WallpaperGeneratePanel";
|
||||
import { SocialPostPanel } from "../components/SocialPostPanel";
|
||||
import { ThemeSeedInput } from "../components/ThemeSeedInput";
|
||||
import { ThemePaletteGrid } from "../components/ThemePaletteGrid";
|
||||
import { ThemePreviewPanel } from "../components/ThemePreviewPanel";
|
||||
|
|
@ -25,6 +27,8 @@ export function ContentStudio() {
|
|||
<TabsTrigger value="diagrams">Diagrams</TabsTrigger>
|
||||
<TabsTrigger value="icons">Icons</TabsTrigger>
|
||||
<TabsTrigger value="themes">Themes</TabsTrigger>
|
||||
<TabsTrigger value="wallpapers">Wallpapers</TabsTrigger>
|
||||
<TabsTrigger value="social">Social</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="diagrams" className="mt-4">
|
||||
|
|
@ -78,6 +82,22 @@ export function ContentStudio() {
|
|||
<p className="text-sm text-muted-foreground">Select a company to get started.</p>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="wallpapers" className="mt-4">
|
||||
{companyId ? (
|
||||
<WallpaperGeneratePanel companyId={companyId} />
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">Select a company to get started.</p>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="social" className="mt-4">
|
||||
{companyId ? (
|
||||
<SocialPostPanel companyId={companyId} />
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">Select a company to get started.</p>
|
||||
)}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
37
ui/src/pages/ConvertPage.tsx
Normal file
37
ui/src/pages/ConvertPage.tsx
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
import { useParams } from "@/lib/router";
|
||||
import { useCompany } from "../context/CompanyContext";
|
||||
import { ConvertPanel, FORMAT_GROUPS } from "../components/ConvertPanel";
|
||||
|
||||
// All valid formats for case-insensitive validation
|
||||
const ALL_FORMATS = Object.values(FORMAT_GROUPS).flat();
|
||||
|
||||
function normalizeFormatParam(param: string | undefined): string | undefined {
|
||||
if (!param) return undefined;
|
||||
const lower = param.toLowerCase();
|
||||
return ALL_FORMATS.includes(lower) ? lower : undefined;
|
||||
}
|
||||
|
||||
export function ConvertPage() {
|
||||
const { sourceFormat, targetFormat } = useParams<{
|
||||
sourceFormat?: string;
|
||||
targetFormat?: string;
|
||||
}>();
|
||||
|
||||
const { selectedCompanyId } = useCompany();
|
||||
const companyId = selectedCompanyId ?? "";
|
||||
|
||||
// Normalize to lowercase; silently ignore invalid format strings
|
||||
const initialSourceFormat = normalizeFormatParam(sourceFormat);
|
||||
const initialTargetFormat = normalizeFormatParam(targetFormat);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6 p-6">
|
||||
<h1 className="text-xl font-semibold">Convert File</h1>
|
||||
<ConvertPanel
|
||||
companyId={companyId}
|
||||
initialSourceFormat={initialSourceFormat}
|
||||
initialTargetFormat={initialTargetFormat}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue