diff --git a/.gitignore b/.gitignore index 0a8ae55..441ad5e 100644 --- a/.gitignore +++ b/.gitignore @@ -39,5 +39,10 @@ next-env.d.ts # email /.react-email/ +# media studio (npm workspace) +/media-studio/node_modules +/media-studio/data +/media-studio/platform/.next + .vscode .contentlayer \ No newline at end of file diff --git a/README.md b/README.md index 2219aef..03f7e9b 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,25 @@ cp .env.example .env.local pnpm dev ``` +## UGC Media Studio + +This repo includes **Ultimate Multimodal** — a MuAPI-powered UGC media studio for generating product ads, lifestyle try-ons, and social video content. It lives in [`media-studio/`](media-studio/). + +```bash +cd media-studio +npm install +cp .env.example .env.local # add MUAPI_API_KEY +npm run dev # http://localhost:3000 +``` + +Features: +- UGC Video Factory, Ads Workflow, Lifestyle Try-On +- Image & Video studios (100+ MuAPI models) +- Admin dashboard at `/dashboard` +- Workflow recipes from [Generative-Media-Skills](https://github.com/SamurAIGPT/Generative-Media-Skills) + +See [media-studio/README.md](media-studio/README.md) for full docs. + > [!NOTE] > I use [npm-check-updates](https://www.npmjs.com/package/npm-check-updates) package for update this project. > diff --git a/media-studio/.env.example b/media-studio/.env.example new file mode 100644 index 0000000..5bb8496 --- /dev/null +++ b/media-studio/.env.example @@ -0,0 +1,2 @@ +MUAPI_API_KEY= +NEXT_PUBLIC_APP_URL=http://localhost:3000 diff --git a/media-studio/.gitignore b/media-studio/.gitignore new file mode 100644 index 0000000..5118070 --- /dev/null +++ b/media-studio/.gitignore @@ -0,0 +1,9 @@ +node_modules/ +.next/ +dist/ +.env +.env.local +*.log +.DS_Store +media_outputs/ +.turbo/ diff --git a/media-studio/DESIGN1.md b/media-studio/DESIGN1.md new file mode 100644 index 0000000..afeac9d --- /dev/null +++ b/media-studio/DESIGN1.md @@ -0,0 +1,405 @@ +# Skillshare — Style Reference +> Poster studio on matte black — bold instructor faces, chalk-white type, single neon pulse. + +**Theme:** mixed + +Skillshare operates in stark black-and-white contrast with a single electric cyan-green accent that fires only on key interactive moments. The hero splits between a chalk-white left panel with bold headline weight and a pure black right panel hosting the registration flow — a literal light/dark duality that mirrors its creator/learner tension. Photo cards of instructors are presented edge-to-edge without padding, letting faces fill the frame; identity replaces ornament. The GT Walsheim Pro typeface at weight 700 for headlines and tight 0.90-0.96 line heights on large sizes creates a stacked, poster-like density. Cyan-green (#55da9b) and the brighter #00ff84 appear exclusively on CTAs and checkmarks — tiny amounts of color doing maximum work against acres of black and white. + +## Tokens — Colors + +| Name | Value | Token | Role | +|------|-------|-------|------| +| Studio Black | `#000000` | `--color-studio-black` | Primary text, dark section backgrounds, nav background on scroll — full-spectrum black with no tint creates maximum contrast against white sections | +| Pure White | `#ffffff` | `--color-pure-white` | Page background, card surfaces, text on dark sections, button fills | +| Deep Ink | `#0b1215` | `--color-deep-ink` | Body text on white backgrounds, button text on white buttons — near-black with the faintest cool tint | +| Graphite Stroke | `#394649` | `--color-graphite-stroke` | Border color on white buttons, secondary text, dividers | +| Fog Border | `#e0e0e0` | `--color-fog-border` | Input borders, card dividers, subtle separators | +| Ash Mid | `#757575` | `--color-ash-mid` | Placeholder text in inputs, secondary metadata | +| Charcoal Surface | `#232424` | `--color-charcoal-surface` | Elevated dark card backgrounds on black sections — one step lighter than pure black to create surface depth without shadows | +| Skill Green | `#55da9b` | `--color-skill-green` | Primary CTA button fill, brand accent — a muted-vivid green that reads warm against black and cool against white, avoiding both danger and safety connotations | +| Neon Pulse | `#00ff84` | `--color-neon-pulse` | Checkmark icons on feature lists, active fill states — saturated to near-glow, appears only as small marks on black backgrounds where luminosity hits hardest | +| Electric Cyan | `#24c2f2` | `--color-electric-cyan` | Secondary accent, stat highlights, animated elements on dark sections | +| Creator Violet | `#6927ef` | `--color-creator-violet` | Hover state backgrounds, link accent on specialized flows | +| Deep Violet | `#5620c1` | `--color-deep-violet` | Active/pressed state for violet interactive elements | +| Facebook Blue | `#1877f2` | `--color-facebook-blue` | Facebook social login icon fill — brand color of the platform, not Skillshare's own | +| Google Red | `#ea4335` | `--color-google-red` | Google 'G' logo fill in social login button | +| Warning Amber | `#f6b60b` | `--color-warning-amber` | Star ratings, achievement indicators | + +## Tokens — Typography + +### GT Walsheim Pro — Every UI element — headlines at 700 weight with 0.90 line-height produce a stacked poster effect at 46–48px. Body at 400/16px with 1.50 line-height. Buttons and labels at 500–600. Weight 100 appears only in decorative or watermark contexts. The custom font brings rounded geometric terminals that soften the stark black/white palette. · `--font-gt-walsheim-pro` +- **Substitute:** Inter, DM Sans +- **Weights:** 100, 400, 500, 600, 700 +- **Sizes:** 11px, 12px, 13px, 14px, 15px, 16px, 18px, 20px, 22px, 24px, 28px, 34px, 36px, 38px, 46px, 48px +- **Line height:** 0.90–1.55 (tight 0.90–0.96 at display sizes 36px+, 1.20–1.50 at body sizes) +- **Letter spacing:** 0.1em on uppercase labels (caps tracking for ALL-CAPS metadata like MEMBERS, CLASSES, TEACHERS) +- **OpenType features:** `normal` +- **Role:** Every UI element — headlines at 700 weight with 0.90 line-height produce a stacked poster effect at 46–48px. Body at 400/16px with 1.50 line-height. Buttons and labels at 500–600. Weight 100 appears only in decorative or watermark contexts. The custom font brings rounded geometric terminals that soften the stark black/white palette. + +### Times — System fallback only — appears in legal/fine-print contexts where custom font fails to load. Not an intentional typographic choice. · `--font-times` +- **Substitute:** Georgia +- **Weights:** 400 +- **Sizes:** 16px +- **Line height:** 1.00 +- **Role:** System fallback only — appears in legal/fine-print contexts where custom font fails to load. Not an intentional typographic choice. + +### Type Scale + +| Role | Size | Line Height | Letter Spacing | Token | +|------|------|-------------|----------------|-------| +| caption | 11px | 1.45 | 1.1px | `--text-caption` | +| body | 16px | 1.5 | — | `--text-body` | +| subheading | 20px | 1.3 | — | `--text-subheading` | +| heading-sm | 28px | 1.25 | — | `--text-heading-sm` | +| heading | 38px | 1.1 | — | `--text-heading` | +| heading-lg | 46px | 0.96 | — | `--text-heading-lg` | +| display | 48px | 0.9 | — | `--text-display` | + +## Tokens — Spacing & Shapes + +**Base unit:** 8px + +**Density:** comfortable + +### Spacing Scale + +| Name | Value | Token | +|------|-------|-------| +| 8 | 8px | `--spacing-8` | +| 16 | 16px | `--spacing-16` | +| 24 | 24px | `--spacing-24` | +| 32 | 32px | `--spacing-32` | +| 40 | 40px | `--spacing-40` | +| 48 | 48px | `--spacing-48` | +| 64 | 64px | `--spacing-64` | +| 80 | 80px | `--spacing-80` | +| 96 | 96px | `--spacing-96` | +| 120 | 120px | `--spacing-120` | +| 128 | 128px | `--spacing-128` | +| 160 | 160px | `--spacing-160` | +| 168 | 168px | `--spacing-168` | + +### Border Radius + +| Element | Value | +|---------|-------| +| cards | 4px | +| pills | 100px | +| inputs | 4px | +| buttons | 4px | +| elevated | 8px | + +### Shadows + +| Name | Value | Token | +|------|-------|-------| +| sm | `rgba(0, 0, 0, 0.1) 0px 2px 4px 0px` | `--shadow-sm` | +| sm-2 | `rgba(180, 184, 184, 0.5) 0px 2px 4px 0px` | `--shadow-sm-2` | + +### Layout + +- **Page max-width:** 1280px +- **Section gap:** 80-120px +- **Card padding:** 16px +- **Element gap:** 8-12px + +## Components + +### Primary CTA Button +**Role:** Main registration and sign-up actions + +Background #55da9b, text #0b1215 at weight 600, 14–16px GT Walsheim Pro. Border-radius 4px. Padding 5px 15px. No border. Appears in navbar and hero — the only consistent green element on any page section regardless of surrounding theme. + +### White Outlined Auth Button +**Role:** Social login options (Google, Facebook, Apple) + +Background #ffffff, text #0b1215 at weight 500, 15px. Border 1px solid #394649, border-radius 4px. Padding 5px 15px. Contains 20px platform icon left-aligned. Full-width within registration panel. Stacked vertically with 8px gaps. + +### Ghost Text Link Button +**Role:** Email continuation, secondary navigation actions + +Background transparent, text #6927ef at 500 weight, 14px. No border, no radius, no padding. Underlined on hover. Used as the 'Continuar con correo electrónico' option below the social auth buttons. + +### Instructor Portrait Card +**Role:** Teacher browse grid — social proof section + +Background #ffffff, border-radius 4px top, 8px bottom. Box-shadow rgba(0,0,0,0.1) 0px 2px 4px. Zero padding — photography fills 100% of card face. Overlaid at bottom: small 'CONOCE' label at 11px uppercase tracking 0.1em in #ffffff, followed by instructor name at 24px weight 700 #ffffff, title at 12px uppercase #ffffff. Gradient scrim from transparent to rgba(0,0,0,0.7) over bottom 40% of image. + +### Category Browse Card +**Role:** Full-bleed horizontal category slider + +No card chrome — full-bleed photographic strip with category label at bottom-left, 18–22px weight 700 #ffffff, with a dark overlay. Cards are 150–200px wide, full viewport height within the slider strip. No border radius — hard edges on left/right, categories run edge-to-edge. + +### Stat Counter Card +**Role:** Social proof metrics — Members, Classes, Teachers, Rating + +Background #232424 on a #000000 section. Border-radius 8px. Padding 16px. No shadow. Stat value at 34–38px weight 700 #ffffff. Label below at 11px weight 500, uppercase, tracking 0.1em, #757575. Four cards in a 4-column horizontal grid. + +### Feature Checklist Item +**Role:** Benefit list on dark sections + +Horizontal flex: #00ff84 filled checkmark icon (20px) + body text at 16px weight 400 #ffffff. Vertical gap 12px between items. No container — items float on pure black background. + +### Search Input Bar +**Role:** Global navigation search + +Background transparent, border 1px solid #e0e0e0, border-radius 4px. Padding 0 38px on both sides (icon gutters). Placeholder text #757575 at 14px GT Walsheim Pro. Search icon 20px left-inset, clear/submit icon right-inset. Appears in white nav bar only. + +### Navigation Bar +**Role:** Sticky top navigation + +Background #ffffff. Height ~56px. Logo left-aligned, 'Navegar' dropdown with chevron center-left. Search bar spans ~50% width. Right side: 'Iniciar sesión' text link in #0b1215 weight 500, then 'Regístrate' green CTA button. No bottom border in hero section — relies on content separation. + +### Dark Split Section +**Role:** Feature/benefit alternating sections on black background + +Full-width background #000000. Two-column layout: left column heading at 38–46px weight 700 #ffffff, right column feature checklist items. Section padding 80px vertical. Section headings use tight 0.96 line-height producing poster-stacked effect. + +### Registration Panel +**Role:** Right-half hero sign-up widget + +Background #ffffff. Width ~380px. Heading 'Disfruta 7 días gratis de Skillshare' at 22px weight 700 #0b1215 centered. Three stacked auth buttons with 8px gaps. 'Continuar con correo electrónico' ghost link centered below. Fine print at 11px #757575. Panel sits on pure black hero background with no card chrome — the white block floats. + +## Do's and Don'ts + +### Do +- Use #55da9b exclusively for the primary CTA button — never as a background fill for sections, badges, or decorative elements +- Apply GT Walsheim Pro weight 700 with line-height 0.90–0.96 at 46–48px for all display headlines; the tight stacking is the signature poster effect +- Use 0.1em letter-spacing on ALL-CAPS metadata labels (11–13px) — MEMBERS, CLASSES, TEACHERS all track wide to distinguish label hierarchy from body text +- Set instructor/creator photography cards to zero padding — images fill 100% of card face with a bottom scrim overlay for text legibility +- Use border-radius 4px for all buttons, inputs, and cards; reserve 100px radius only for pill-shaped tags or badges — never apply 100px to buttons +- On black-background sections, use #232424 as the surface for stat/metric cards to create depth without shadows or borders +- Keep #00ff84 confined to small checkmarks and icon fills on dark backgrounds only — never use it as button background or large fill + +### Don't +- Never use the Creator Violet (#6927ef) or Electric Cyan (#24c2f2) as background fills for large sections — they are micro-accent colors for icons and specific interactive states only +- Never round hero photography or category browse strips — hard edges on full-bleed images are intentional; adding radius breaks the edge-to-edge poster aesthetic +- Never use weight 100 GT Walsheim Pro for any functional text — it appears only in decorative/watermark contexts and is illegible at body sizes on dark backgrounds +- Never place the Skill Green CTA (#55da9b) on a white section as a ghost or outlined variant — it only works as a filled button on both black and white backgrounds +- Never apply box-shadows to dark-theme (#000000 background) components — depth is achieved through #232424 surface elevation, not shadow +- Never mix multiple accent colors in the same section — pick either #55da9b or #24c2f2 per section, never both simultaneously +- Never set body text line-height below 1.40 at sizes 13–18px — tight line-height is reserved for 36px+ display sizes only + +## Surfaces + +| Level | Name | Value | Purpose | +|-------|------|-------|---------| +| 0 | Pure Black | `#000000` | Dark section backgrounds, hero right panel, feature sections | +| 1 | Charcoal Lift | `#232424` | Stat cards and metric tiles floating on pure black sections | +| 2 | White Page | `#ffffff` | Light section backgrounds, registration panel, card surfaces | +| 3 | Navigation Bar | `#ffffff` | Sticky nav — same white as page background, separated by content not chrome | + +## Elevation + +- **Course Card:** `rgba(0, 0, 0, 0.1) 0px 2px 4px 0px` +- **Instructor Portrait Card:** `rgba(0, 0, 0, 0.1) 0px 2px 4px 0px` +- **Secondary UI Element:** `rgba(180, 184, 184, 0.5) 0px 2px 4px 0px` + +## Imagery + +Photography-forward with zero illustration outside of small SVG icons. Instructor portraits are the dominant image type — tight head-and-shoulders crops with natural studio lighting, filling card faces edge-to-edge with no padding or border treatment. Category browse strips use lifestyle photography (hands on tablets, close-up product shots, faces with accessories) at full viewport height in a horizontal scroll strip, each panel hard-edged with no radius. Text overlays use a bottom-anchored gradient scrim (transparent to rgba(0,0,0,0.7)) rather than opaque overlays. Photography is high-contrast with saturated colors — editorial-quality, not stock. Icons are minimal outlined SVGs at 20px, monochrome (#ffffff on dark, #0b1215 on light), stroke-weight consistent at ~1.5px. Imagery occupies roughly 60% of visual space on browse sections; the registration hero is text/UI-dominant with no photography. + +## Layout + +Max-width ~1280px centered with full-bleed dark and light section bands. Hero is a two-column split: left ~55% white background with oversized stacked headline, right ~45% black background hosting the registration widget — the only page section using this side-by-side light/dark split. Below the hero, full-bleed horizontal photography strips serve as category navigation, spanning 100vw with zero margin. Subsequent sections alternate pure black and white backgrounds with 80–120px vertical padding. Dark feature sections use two-column layout (headline left, checklist right). Instructor grid is a 4-column responsive card grid with 12px gutters. Stat counters use a single-row 4-column flex layout within a dark section. Navigation is a white sticky bar at ~56px height with logo-left, search-center, auth-right arrangement — no mega-menu, no sidebar. + +## Agent Prompt Guide + +**Quick Color Reference** +- Page background (light sections): #ffffff +- Page background (dark sections): #000000 +- Primary text: #0b1215 +- Reverse text (on dark): #ffffff +- CTA button fill: #55da9b +- CTA button text: #0b1215 +- Borders / outlines: #394649 (buttons), #e0e0e0 (inputs) +- Brand accent (checkmarks, icons): #00ff84 +- Secondary accent: #24c2f2 + +**Example Component Prompts** + +1. **Navigation Bar**: White (#ffffff) sticky bar, 56px tall. Left: 'skillshare' wordmark at 20px GT Walsheim Pro weight 700 #0b1215. Center: search input with transparent fill, 1px #e0e0e0 border, 4px radius, 38px horizontal padding, placeholder #757575. Right: 'Iniciar sesión' text at 14px weight 500 #0b1215, then 'Regístrate' button — #55da9b fill, #0b1215 text, 4px radius, 5px 15px padding. + +2. **Hero Section**: Two-column full-bleed split. Left 55%: white background, headline 'Clases creativas impartidas...' at 46px GT Walsheim Pro weight 700 #0b1215 line-height 0.96, stacked 3 lines. Right 45%: pure black (#000000) background with white registration panel (380px wide, no card border) containing 22px weight 700 heading, three 15px weight 500 white outlined buttons (1px #394649, 4px radius, full width), ghost violet link below. + +3. **Stat Counter Row**: Full-width black (#000000) section, 80px vertical padding. 4-column grid, each cell: #232424 background, 8px radius, 16px padding. Stat value 38px GT Walsheim Pro weight 700 #ffffff. Label 11px weight 500 uppercase, 0.1em letter-spacing, #757575. + +4. **Instructor Portrait Card**: 4-column grid. Card: 4px radius top, 8px bottom, zero padding, box-shadow rgba(0,0,0,0.1) 0 2px 4px. Full-bleed portrait photo. Bottom overlay: gradient transparent → rgba(0,0,0,0.7) over 40% height. Overlay text: 'CONOCE' at 11px uppercase tracking 0.1em #ffffff, name at 22px weight 700 #ffffff, title at 11px uppercase #ffffff. + +5. **Feature Checklist on Dark**: Black (#000000) section, two columns. Left: heading 38px weight 700 #ffffff line-height 0.96. Right: vertical list, each item = 20px #00ff84 filled check icon + 16px weight 400 #ffffff text, 12px gap between items. + +## Black/White Duality System + +The design alternates between two modes with no transitional grays: MODE WHITE (#ffffff background, #0b1215 text, #55da9b CTA) and MODE BLACK (#000000 background, #ffffff text, #00ff84 accents, #232424 cards). Sections snap between these two modes — never use a gray or off-white background for section backgrounds. The #394649 graphite appears only as border/stroke on WHITE mode elements; it never appears on black backgrounds. The transition between modes is a hard edge (no gradient, no divider line) — the color change IS the section separator. + +## Similar Brands + +- **Masterclass** — Same photography-dominant dark sections with white display typography and a single brand-color CTA, instructor portraits as hero content +- **Coursera** — Split hero layout with sign-up widget on dark right panel alongside headline-left structure, social auth button stacking +- **Domestika** — Edge-to-edge instructor photography cards with name/title overlay scrims and a creator-community grid browse pattern +- **LinkedIn Learning** — Mixed light/dark section alternation with white nav, neon accent on CTA, and category-browse horizontal strips + +## Quick Start + +### CSS Custom Properties + +```css +:root { + /* Colors */ + --color-studio-black: #000000; + --color-pure-white: #ffffff; + --color-deep-ink: #0b1215; + --color-graphite-stroke: #394649; + --color-fog-border: #e0e0e0; + --color-ash-mid: #757575; + --color-charcoal-surface: #232424; + --color-skill-green: #55da9b; + --color-neon-pulse: #00ff84; + --color-electric-cyan: #24c2f2; + --color-creator-violet: #6927ef; + --color-deep-violet: #5620c1; + --color-facebook-blue: #1877f2; + --color-google-red: #ea4335; + --color-warning-amber: #f6b60b; + + /* Typography — Font Families */ + --font-gt-walsheim-pro: 'GT Walsheim Pro', ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; + --font-times: 'Times', ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; + + /* Typography — Scale */ + --text-caption: 11px; + --leading-caption: 1.45; + --tracking-caption: 1.1px; + --text-body: 16px; + --leading-body: 1.5; + --text-subheading: 20px; + --leading-subheading: 1.3; + --text-heading-sm: 28px; + --leading-heading-sm: 1.25; + --text-heading: 38px; + --leading-heading: 1.1; + --text-heading-lg: 46px; + --leading-heading-lg: 0.96; + --text-display: 48px; + --leading-display: 0.9; + + /* Typography — Weights */ + --font-weight-thin: 100; + --font-weight-regular: 400; + --font-weight-medium: 500; + --font-weight-semibold: 600; + --font-weight-bold: 700; + + /* Spacing */ + --spacing-unit: 8px; + --spacing-8: 8px; + --spacing-16: 16px; + --spacing-24: 24px; + --spacing-32: 32px; + --spacing-40: 40px; + --spacing-48: 48px; + --spacing-64: 64px; + --spacing-80: 80px; + --spacing-96: 96px; + --spacing-120: 120px; + --spacing-128: 128px; + --spacing-160: 160px; + --spacing-168: 168px; + + /* Layout */ + --page-max-width: 1280px; + --section-gap: 80-120px; + --card-padding: 16px; + --element-gap: 8-12px; + + /* Border Radius */ + --radius-md: 4px; + --radius-lg: 8px; + --radius-full: 100px; + + /* Named Radii */ + --radius-cards: 4px; + --radius-pills: 100px; + --radius-inputs: 4px; + --radius-buttons: 4px; + --radius-elevated: 8px; + + /* Shadows */ + --shadow-sm: rgba(0, 0, 0, 0.1) 0px 2px 4px 0px; + --shadow-sm-2: rgba(180, 184, 184, 0.5) 0px 2px 4px 0px; + + /* Surfaces */ + --surface-pure-black: #000000; + --surface-charcoal-lift: #232424; + --surface-white-page: #ffffff; + --surface-navigation-bar: #ffffff; +} +``` + +### Tailwind v4 + +```css +@theme { + /* Colors */ + --color-studio-black: #000000; + --color-pure-white: #ffffff; + --color-deep-ink: #0b1215; + --color-graphite-stroke: #394649; + --color-fog-border: #e0e0e0; + --color-ash-mid: #757575; + --color-charcoal-surface: #232424; + --color-skill-green: #55da9b; + --color-neon-pulse: #00ff84; + --color-electric-cyan: #24c2f2; + --color-creator-violet: #6927ef; + --color-deep-violet: #5620c1; + --color-facebook-blue: #1877f2; + --color-google-red: #ea4335; + --color-warning-amber: #f6b60b; + + /* Typography */ + --font-gt-walsheim-pro: 'GT Walsheim Pro', ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; + --font-times: 'Times', ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; + + /* Typography — Scale */ + --text-caption: 11px; + --leading-caption: 1.45; + --tracking-caption: 1.1px; + --text-body: 16px; + --leading-body: 1.5; + --text-subheading: 20px; + --leading-subheading: 1.3; + --text-heading-sm: 28px; + --leading-heading-sm: 1.25; + --text-heading: 38px; + --leading-heading: 1.1; + --text-heading-lg: 46px; + --leading-heading-lg: 0.96; + --text-display: 48px; + --leading-display: 0.9; + + /* Spacing */ + --spacing-8: 8px; + --spacing-16: 16px; + --spacing-24: 24px; + --spacing-32: 32px; + --spacing-40: 40px; + --spacing-48: 48px; + --spacing-64: 64px; + --spacing-80: 80px; + --spacing-96: 96px; + --spacing-120: 120px; + --spacing-128: 128px; + --spacing-160: 160px; + --spacing-168: 168px; + + /* Border Radius */ + --radius-md: 4px; + --radius-lg: 8px; + --radius-full: 100px; + + /* Shadows */ + --shadow-sm: rgba(0, 0, 0, 0.1) 0px 2px 4px 0px; + --shadow-sm-2: rgba(180, 184, 184, 0.5) 0px 2px 4px 0px; +} +``` diff --git a/media-studio/DESIGN2.md b/media-studio/DESIGN2.md new file mode 100644 index 0000000..eeebb94 --- /dev/null +++ b/media-studio/DESIGN2.md @@ -0,0 +1,391 @@ +# Officevibe — Style Reference +> Editorial journal on warm cream paper. Think a thoughtful HR essay rendered as a product — serif italics whispering through a modern blue interface. + +**Theme:** light + +Workleap Officevibe speaks in a calm editorial register: a warm cream canvas, generous whitespace, and serif display type that lends the product a literary, almost handwritten authority. The palette pivots between two blues — a deep ink-navy for headings, dark cards, and editorial weight, and a vivid electric blue reserved for buttons and interactive punctuation. Surfaces are paper-soft with cream borders rather than gray; corners are gently rounded (16px cards, 100px pill buttons). The italicized serif words inside otherwise sans-serif headlines ("not paying attention", "Everything") are the signature: they break the corporate SaaS voice and make the interface feel like a well-edited magazine. Density stays comfortable; the chat widget anchored at the bottom of every screen is part of the identity, not an afterthought. + +## Tokens — Colors + +| Name | Value | Token | Role | +|------|-------|-------|------| +| Ink Navy | `#0c1754` | `--color-ink-navy` | Display headings, dark feature cards, footer background, body text on light canvas — the editorial weight color, used wherever the page needs to feel considered rather than decorative | +| Electric Cobalt | `#2545ff` | `--color-electric-cobalt` | Violet supporting accent for decorative details and low-frequency emphasis. Do not promote it to the primary CTA color | +| Charcoal | `#171417` | `--color-charcoal` | Primary body and heading text, nav text, icon strokes on light surfaces | +| Warm Canvas | `#f9f8f6` | `--color-warm-canvas` | Page background, footer surface, soft card fills, input backgrounds — the unifying warm off-white | +| Paper White | `#ffffff` | `--color-paper-white` | Elevated card surfaces, button text, nav background, input fields, icon fills on dark | +| Cream Border | `#f0e9e1` | `--color-cream-border` | Hairline dividers, card borders, subtle section separators — replaces the typical cool gray to keep the page warm | +| Graphite | `#222222` | `--color-graphite` | Secondary text, button text in outlined/ghost states, nav inactive labels | +| Stone | `#969696` | `--color-stone` | Muted helper text, placeholder content, tertiary metadata | +| Smoke | `#cccccc` | `--color-smoke` | Input borders, disabled form states, neutral dividers on white | +| Lavender Mist | `#eaebf8` | `--color-lavender-mist` | Tinted badge backgrounds, soft highlight washes on white surfaces — a whisper of the electric blue | + +## Tokens — Typography + +### Martinaplantijn — Display serif reserved for the largest hero and section headlines (40-64px). Its italic cuts carry the brand voice — the words that bend feel personal and editorial, breaking the SaaS monotone. Substitute: a humanist serif like GT Super or Tiempos Text. · `--font-martinaplantijn` +- **Substitute:** GT Super Display +- **Weights:** 400 +- **Sizes:** 40px, 64px +- **Line height:** 1.00-1.10 +- **Letter spacing:** normal +- **OpenType features:** `"ss01", "ss04"` +- **Role:** Display serif reserved for the largest hero and section headlines (40-64px). Its italic cuts carry the brand voice — the words that bend feel personal and editorial, breaking the SaaS monotone. Substitute: a humanist serif like GT Super or Tiempos Text. + +### Abcfavoritvariable — Geometric sans for sub-headings, step labels, and eyebrow text (16-40px). Tight tracking at large sizes (-0.05em) is the signature — it makes the type feel modern and compressed against the serif display. The ss01/ss04 alternates likely swap in single-story 'a' and more open terminals. Substitute: ABC Diatype Mono or Inter Tight. · `--font-abcfavoritvariable` +- **Substitute:** ABC Diatype +- **Weights:** 400, 500 +- **Sizes:** 12px, 14px, 16px, 20px, 24px, 32px, 40px, 48px, 64px +- **Line height:** 1.00-1.50 +- **Letter spacing:** -0.05em at 48-64px, -0.02em at 24-40px +- **OpenType features:** `"ss01", "ss04"` +- **Role:** Geometric sans for sub-headings, step labels, and eyebrow text (16-40px). Tight tracking at large sizes (-0.05em) is the signature — it makes the type feel modern and compressed against the serif display. The ss01/ss04 alternates likely swap in single-story 'a' and more open terminals. Substitute: ABC Diatype Mono or Inter Tight. + +### Inter — Workhorse for body copy, button labels, nav items, form fields, captions, and footer text. Stays neutral so the serif display and Abcfavorit can carry personality. The ss01/ss04 features are inherited to keep the typographic family consistent across all three faces. · `--font-inter` +- **Substitute:** Inter +- **Weights:** 400, 500, 700 +- **Sizes:** 12px, 14px, 16px, 20px +- **Line height:** 1.20-1.60 +- **Letter spacing:** normal +- **OpenType features:** `"ss01", "ss04"` +- **Role:** Workhorse for body copy, button labels, nav items, form fields, captions, and footer text. Stays neutral so the serif display and Abcfavorit can carry personality. The ss01/ss04 features are inherited to keep the typographic family consistent across all three faces. + +### Type Scale + +| Role | Size | Line Height | Letter Spacing | Token | +|------|------|-------------|----------------|-------| +| caption | 12px | 1.4 | — | `--text-caption` | +| body-sm | 14px | 1.6 | — | `--text-body-sm` | +| body | 16px | 1.6 | — | `--text-body` | +| subheading | 20px | 1.4 | — | `--text-subheading` | +| heading-sm | 24px | 1.4 | -0.48px | `--text-heading-sm` | +| heading | 32px | 1.4 | -0.64px | `--text-heading` | +| heading-lg | 40px | 1.2 | -0.8px | `--text-heading-lg` | +| display | 48px | 1.1 | -2.4px | `--text-display` | +| display-lg | 64px | 1 | -3.2px | `--text-display-lg` | + +## Tokens — Spacing & Shapes + +**Base unit:** 8px + +**Density:** comfortable + +### Spacing Scale + +| Name | Value | Token | +|------|-------|-------| +| 8 | 8px | `--spacing-8` | +| 16 | 16px | `--spacing-16` | +| 24 | 24px | `--spacing-24` | +| 32 | 32px | `--spacing-32` | +| 40 | 40px | `--spacing-40` | +| 48 | 48px | `--spacing-48` | +| 56 | 56px | `--spacing-56` | +| 64 | 64px | `--spacing-64` | +| 80 | 80px | `--spacing-80` | +| 128 | 128px | `--spacing-128` | + +### Border Radius + +| Element | Value | +|---------|-------| +| cards | 16px | +| badges | 16px | +| images | 24px | +| inputs | 16px | +| buttons | 100px | + +### Layout + +- **Page max-width:** 1200px +- **Section gap:** 80px +- **Card padding:** 32px +- **Element gap:** 24px + +## Components + +### Filled Pill Button (Primary) +**Role:** Main call-to-action + +Pill shape at 100px radius, Electric Cobalt (#2545ff) background, Paper White (#ffffff) text, Inter 16px weight 500, padding 12px 24px. Used for 'Request a demo' and the hero CTA. No shadow — the color does the work. + +### Ghost Nav Button +**Role:** Secondary navigation link + +Transparent background, Graphite (#222222) text, Inter 14px weight 500, no border. Sits inline with the nav bar, uses 16-24px horizontal padding. + +### Eyebrow Label +**Role:** Section pre-heading + +All-caps Inter 12px weight 700 or Abcfavorit 12px weight 500, Charcoal (#171417), letter-spacing 0.12em, sits 16-24px above the section heading. Example pattern: 'WORKLEAP OFFICEVIBE' above the hero headline. + +### Display Headline with Italic Accent +**Role:** Hero and section titles + +Martinaplantijn 48-64px, weight 400, lineHeight 1.0-1.1, Charcoal (#171417) on cream or Paper White on Ink Navy. A specific phrase inside the headline is set in italic (e.g. 'not paying attention', 'Everything') — this italic cut is the brand's typographic signature. + +### Step Card — Dark +**Role:** Feature step on dark background + +Ink Navy (#0c1754) background, 16px radius, 24-32px padding. Contains a numbered badge in Electric Cobalt, an Abcfavorit sub-heading in Paper White, and supporting copy in Lavender Mist (#eaebf8) or white at 70% opacity. + +### Step Card — Light +**Role:** Feature step on light background + +Paper White (#ffffff) background, 16px radius, cream border (#f0e9e1) at 1px, 24-32px padding. The middle step in the 3-card row — it stays neutral to let the dark and bordered variants pop. + +### Step Card — Bordered Accent +**Role:** Feature step with highlight + +Paper White (#ffffff) background, Electric Cobalt (#2545ff) border at 1.5px, 16px radius, 24-32px padding. Used for the 'Act with an AI coach' card to draw the eye to the most differentiated capability. + +### Chat Widget +**Role:** Persistent AI assistant + +Fixed to bottom-center of viewport. Paper White background, 16px radius, 1px Smoke (#cccccc) border, soft shadow. Header row contains a small Electric Cobalt circular avatar, Inter 14px question text, and three pill suggestion chips below the input. ~360px wide. + +### FAQ Accordion Row +**Role:** Expandable question + +Paper White (#ffffff) background, 1px cream border (#f0e9e1) on the bottom edge only, 24px vertical padding, Inter 16px weight 500 question text in Charcoal (#171417), chevron icon in Graphite on the right. Expands to reveal body copy at 16px weight 400. + +### Product Dashboard Card +**Role:** In-context product screenshot + +Paper White (#ffffff) background, 16px radius, subtle shadow (0 4px 24px rgba(12,23,84,0.08)). Contains a chart with a purple-to-coral gradient area fill, a circular score gauge in Electric Cobalt and Ink Navy, and metric rows with green/orange/red sentiment dots. + +### Top Navigation Bar +**Role:** Global site navigation + +Warm Canvas (#f9f8f6) background, Wordmark 'workleap' at left in Inter 18px weight 700, horizontal nav links in Inter 14px weight 500 Graphite, 'Log in' as ghost link and 'Request a demo' as the filled pill button at right. 64-72px tall, sits directly on the canvas without a separator line. + +### Metric Pill Badge +**Role:** Status and sentiment indicators + +Lavender Mist (#eaebf8) background, Ink Navy (#0c1754) text, Inter 12px weight 500, 16px radius, 4px 10px padding. Used for category labels and inline tags. + +## Do's and Don'ts + +### Do +- Set the largest headline in Martinaplantijn 48-64px and italicize one phrase inside it — the italic cut is the brand voice, not decoration. +- Use Electric Cobalt (#2545ff) exclusively for filled action buttons, active links, and the single most important icon per view; let it be the only chromatic punctuation on the page. +- Pair Ink Navy (#0c1754) with Paper White for dark feature cards and the footer; never use a gray to approximate it. +- Default to 100px pill radius for any clickable element that should feel approachable (buttons, tags, suggestion chips). +- Use 16px radius for cards, images, and the chat widget; 24px radius only for large hero illustrations. +- Keep the canvas at #f9f8f6 and use #f0e9e1 cream borders for dividers — do not introduce cool grays. +- Apply letter-spacing -0.05em to any Abcfavorit text at 40px and above so the geometric sans feels compressed and modern next to the serif. + +### Don't +- Do not set the display headline in Inter or Abcfavorit — Martinaplantijn (or a humanist serif) is required for the editorial voice. +- Do not use Electric Cobalt (#2545ff) for body text, borders, or large background fills — it loses its punch as a button accent. +- Do not use a gray border (#e5e5e5, #d4d4d4) — the system uses warm cream (#f0e9e1) for every hairline. +- Do not add box-shadow to filled buttons; the color is the elevation. +- Do not introduce a third heading color beyond Charcoal, Paper White, and Ink Navy — the two-blue system is the palette. +- Do not center-align body copy or set line-height below 1.5 at body sizes; the editorial feel depends on generous reading rhythm. +- Do not use a square or 4px radius on cards or inputs — the 16px softness is part of the visual identity. + +## Surfaces + +| Level | Name | Value | Purpose | +|-------|------|-------|---------| +| 0 | Canvas | `#f9f8f6` | Page background and footer | +| 1 | Paper | `#ffffff` | Elevated cards, input fields, chat widget | +| 2 | Ink | `#0c1754` | Dark feature cards, reversed text surfaces | + +## Elevation + +- **Product Dashboard Card:** `0 4px 24px rgba(12, 23, 84, 0.08)` +- **Chat Widget:** `0 8px 32px rgba(12, 23, 84, 0.12)` +- **Filled Pill Button:** `none — flat, color carries the weight` + +## Imagery + +Product screenshots are the primary visual — dashboard mockups showing sentiment scores, survey results, and AI chat panels are rendered at large scale and offset with tilted decorative cards behind them for depth. The dashboard's signature chart uses a purple-to-coral gradient area fill over a navy line, with a circular score gauge in Electric Cobalt. No lifestyle photography; no stock imagery. The only decorative graphic device is the slightly rotated dark card peeking out from behind the main product visual, creating a layered editorial feel. Icons are minimal, mono-color, and use Charcoal or Electric Cobalt only — no multicolor iconography. + +## Layout + +Max-width ~1200px centered with generous side padding. The hero is a 2-column split: left column holds an eyebrow label, the display headline (with italic phrase), supporting body copy, and a filled CTA; the right column holds the product dashboard mockup, vertically centered against the text. The steps section is a centered headline over a 3-column card grid with numbered badges and connecting hand-drawn arrows. The FAQ section flips to a 2-column layout: a sticky left column with a large display headline (italic accent on the first word) and a right column of accordion rows. Vertical rhythm uses ~80px between major sections with seamless flow — no hard section dividers, only whitespace and the warm cream canvas holding everything together. The chat widget is fixed to bottom-center on every screen. + +## Agent Prompt Guide + +**Quick Color Reference** +- text: #171417 +- background: #f9f8f6 +- card surface: #ffffff +- border: #f0e9e1 +- accent: #2545ff +- primary action: no distinct CTA color + +**Example Component Prompts** +No distinct primary action color was observed; use the extracted neutral button treatments instead of inventing a filled CTA color. +2. Build a 3-card feature row. Middle card: #ffffff background, 16px radius, 1px #f0e9e1 border, 32px padding. Left card: #0c1754 background, 16px radius, 32px padding, white text. Right card: #ffffff background, 1.5px #2545ff border, 16px radius, 32px padding. Numbered badge (1, 2, 3) in #2545ff at top-left of each card. +3. Create a fixed chat widget pinned to bottom-center: 360px wide, #ffffff background, 16px radius, 1px #cccccc border, shadow 0 8px 32px rgba(12,23,84,0.12). Header row has a 32px #2545ff circular avatar and Inter 14px question text. Three pill suggestion chips below: #f9f8f6 background, #171417 text, 100px radius, 8px 14px padding. +4. Build an FAQ accordion row: #ffffff background, no top/left/right border, 1px #f0e9e1 bottom border, 24px vertical padding. Question in Inter 16px weight 500, #171417. Chevron icon at right in #222222. Expand to reveal body copy in Inter 16px weight 400, #171417, line-height 1.6. +5. Create a product dashboard card: #ffffff background, 16px radius, shadow 0 4px 24px rgba(12,23,84,0.08), 32px padding. Inside, a circular gauge showing '8.2' in #2545ff with a thin #0c1754 arc, next to a chart area with a purple-to-coral gradient fill and a navy trend line. + +## Similar Brands + +- **Linear** — Same approach of letting a single vivid accent color carry all interactivity against a clean light surface. +- **Notion** — Shared comfortable light-canvas density, soft card radii, and pill-shaped primary buttons. +- **Substack** — Editorial sensibility with serif display type and italic emphasis set against a warm off-white background. +- **Plausible Analytics** — Restraint with a single accent blue, flat product screenshots as hero visuals, and minimal decoration. +- **Stripe** — Two-tone blue system (deep navy + electric blue) used the same way: one for editorial weight, one for action. + +## Quick Start + +### CSS Custom Properties + +```css +:root { + /* Colors */ + --color-ink-navy: #0c1754; + --color-electric-cobalt: #2545ff; + --color-charcoal: #171417; + --color-warm-canvas: #f9f8f6; + --color-paper-white: #ffffff; + --color-cream-border: #f0e9e1; + --color-graphite: #222222; + --color-stone: #969696; + --color-smoke: #cccccc; + --color-lavender-mist: #eaebf8; + + /* Typography — Font Families */ + --font-martinaplantijn: 'Martinaplantijn', ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; + --font-abcfavoritvariable: 'Abcfavoritvariable', ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; + --font-inter: 'Inter', ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; + + /* Typography — Scale */ + --text-caption: 12px; + --leading-caption: 1.4; + --text-body-sm: 14px; + --leading-body-sm: 1.6; + --text-body: 16px; + --leading-body: 1.6; + --text-subheading: 20px; + --leading-subheading: 1.4; + --text-heading-sm: 24px; + --leading-heading-sm: 1.4; + --tracking-heading-sm: -0.48px; + --text-heading: 32px; + --leading-heading: 1.4; + --tracking-heading: -0.64px; + --text-heading-lg: 40px; + --leading-heading-lg: 1.2; + --tracking-heading-lg: -0.8px; + --text-display: 48px; + --leading-display: 1.1; + --tracking-display: -2.4px; + --text-display-lg: 64px; + --leading-display-lg: 1; + --tracking-display-lg: -3.2px; + + /* Typography — Weights */ + --font-weight-regular: 400; + --font-weight-medium: 500; + --font-weight-bold: 700; + + /* Spacing */ + --spacing-unit: 8px; + --spacing-8: 8px; + --spacing-16: 16px; + --spacing-24: 24px; + --spacing-32: 32px; + --spacing-40: 40px; + --spacing-48: 48px; + --spacing-56: 56px; + --spacing-64: 64px; + --spacing-80: 80px; + --spacing-128: 128px; + + /* Layout */ + --page-max-width: 1200px; + --section-gap: 80px; + --card-padding: 32px; + --element-gap: 24px; + + /* Border Radius */ + --radius-sm: 2px; + --radius-lg: 9px; + --radius-2xl: 16px; + --radius-3xl: 24px; + --radius-full: 100px; + + /* Named Radii */ + --radius-cards: 16px; + --radius-badges: 16px; + --radius-images: 24px; + --radius-inputs: 16px; + --radius-buttons: 100px; + + /* Surfaces */ + --surface-canvas: #f9f8f6; + --surface-paper: #ffffff; + --surface-ink: #0c1754; +} +``` + +### Tailwind v4 + +```css +@theme { + /* Colors */ + --color-ink-navy: #0c1754; + --color-electric-cobalt: #2545ff; + --color-charcoal: #171417; + --color-warm-canvas: #f9f8f6; + --color-paper-white: #ffffff; + --color-cream-border: #f0e9e1; + --color-graphite: #222222; + --color-stone: #969696; + --color-smoke: #cccccc; + --color-lavender-mist: #eaebf8; + + /* Typography */ + --font-martinaplantijn: 'Martinaplantijn', ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; + --font-abcfavoritvariable: 'Abcfavoritvariable', ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; + --font-inter: 'Inter', ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; + + /* Typography — Scale */ + --text-caption: 12px; + --leading-caption: 1.4; + --text-body-sm: 14px; + --leading-body-sm: 1.6; + --text-body: 16px; + --leading-body: 1.6; + --text-subheading: 20px; + --leading-subheading: 1.4; + --text-heading-sm: 24px; + --leading-heading-sm: 1.4; + --tracking-heading-sm: -0.48px; + --text-heading: 32px; + --leading-heading: 1.4; + --tracking-heading: -0.64px; + --text-heading-lg: 40px; + --leading-heading-lg: 1.2; + --tracking-heading-lg: -0.8px; + --text-display: 48px; + --leading-display: 1.1; + --tracking-display: -2.4px; + --text-display-lg: 64px; + --leading-display-lg: 1; + --tracking-display-lg: -3.2px; + + /* Spacing */ + --spacing-8: 8px; + --spacing-16: 16px; + --spacing-24: 24px; + --spacing-32: 32px; + --spacing-40: 40px; + --spacing-48: 48px; + --spacing-56: 56px; + --spacing-64: 64px; + --spacing-80: 80px; + --spacing-128: 128px; + + /* Border Radius */ + --radius-sm: 2px; + --radius-lg: 9px; + --radius-2xl: 16px; + --radius-3xl: 24px; + --radius-full: 100px; +} +``` diff --git a/media-studio/README.md b/media-studio/README.md new file mode 100644 index 0000000..940e0e1 --- /dev/null +++ b/media-studio/README.md @@ -0,0 +1,52 @@ +# Ultimate Multimodal — UGC Media Studio Platform + +Part of [ReplyGuy-clone](https://github.com/cameronking4/ReplyGuy-clone) — use alongside BuzzDaddy to scrape social posts and generate UGC video/image ads for your product. + +A self-hosted generative media studio powered by [MuAPI](https://muapi.ai) and [Generative-Media-Skills](https://github.com/SamurAIGPT/Generative-Media-Skills). + +## Features + +- **UGC Studio** — Video Factory, Ads Workflow, Lifestyle Try-On pipelines +- **Image & Video Studios** — Generate with 100+ MuAPI models +- **Workflow Browser** — 41+ recipes from Generative-Media-Skills +- **Admin Dashboard** — Job queue, projects, API settings, credit balance +- **Dual design system** — Skillshare-style creator UI (DESIGN1) + Officevibe-style dashboard (DESIGN2) + +## Quick Start + +```bash +# Install dependencies +npm install + +# Configure API key +cp .env.example .env.local +# Add MUAPI_API_KEY=your_key_here + +# Start dev server +npm run dev +``` + +Open [http://localhost:3000](http://localhost:3000) for the creator studio. +Open [http://localhost:3000/dashboard](http://localhost:3000/dashboard) for the admin dashboard. + +## Project Structure + +``` +├── skills/ # Generative-Media-Skills (cloned) +├── platform/ # Next.js 15 app +├── packages/ +│ ├── muapi-client/ # MuAPI REST wrapper +│ ├── workflow-engine/ # SKILL.md parser + UGC pipelines +│ └── shared/ # Shared types +├── DESIGN1.md # Creator frontend design spec +└── DESIGN2.md # Dashboard design spec +``` + +## Prerequisites + +- Node.js 20+ +- MuAPI API key from [muapi.ai/dashboard](https://muapi.ai/dashboard) + +## License + +MIT diff --git a/media-studio/package.json b/media-studio/package.json new file mode 100644 index 0000000..f10d585 --- /dev/null +++ b/media-studio/package.json @@ -0,0 +1,15 @@ +{ + "name": "ultimate-multimodal", + "version": "0.1.0", + "private": true, + "workspaces": [ + "platform", + "packages/*" + ], + "scripts": { + "dev": "npm run dev -w platform", + "build": "npm run build -w platform", + "start": "npm run start -w platform", + "lint": "npm run lint -w platform" + } +} diff --git a/media-studio/packages/muapi-client/package.json b/media-studio/packages/muapi-client/package.json new file mode 100644 index 0000000..89689a5 --- /dev/null +++ b/media-studio/packages/muapi-client/package.json @@ -0,0 +1,16 @@ +{ + "name": "@ultimate-multimodal/muapi-client", + "version": "0.1.0", + "private": true, + "main": "./src/index.ts", + "types": "./src/index.ts", + "exports": { + ".": "./src/index.ts" + }, + "scripts": { + "test": "node --experimental-strip-types src/schema.test.ts" + }, + "dependencies": { + "@ultimate-multimodal/shared": "*" + } +} diff --git a/media-studio/packages/muapi-client/src/cli.ts b/media-studio/packages/muapi-client/src/cli.ts new file mode 100644 index 0000000..e0f01f5 --- /dev/null +++ b/media-studio/packages/muapi-client/src/cli.ts @@ -0,0 +1,181 @@ +import { spawn } from "child_process"; +import type { CliRunOptions } from "./types"; + +export class MuapiCliError extends Error { + constructor( + message: string, + readonly exitCode: number, + readonly stderr: string + ) { + super(message); + this.name = "MuapiCliError"; + } +} + +function resolveMuapiCommand(): string { + return process.platform === "win32" ? "muapi.cmd" : "muapi"; +} + +export async function runMuapiCli( + options: CliRunOptions +): Promise> { + const cmd = resolveMuapiCommand(); + const args = [...options.args, "--output-json"]; + + return new Promise((resolve, reject) => { + const env = { ...process.env }; + if (options.apiKey) env.MUAPI_API_KEY = options.apiKey; + + const child = spawn(cmd, args, { + cwd: options.cwd, + env, + shell: process.platform === "win32", + }); + + let stdout = ""; + let stderr = ""; + + child.stdout.on("data", (d) => { + stdout += d.toString(); + }); + child.stderr.on("data", (d) => { + stderr += d.toString(); + }); + + child.on("error", (err) => { + reject( + new MuapiCliError( + `Failed to spawn muapi CLI: ${err.message}. Install with: npm install -g muapi-cli`, + 127, + stderr + ) + ); + }); + + child.on("close", (code) => { + if (code !== 0) { + reject(new MuapiCliError(`muapi CLI exited with code ${code}`, code ?? 1, stderr)); + return; + } + try { + const trimmed = stdout.trim(); + const jsonStart = trimmed.indexOf("{"); + const jsonArrStart = trimmed.indexOf("["); + const start = + jsonStart >= 0 && (jsonArrStart < 0 || jsonStart < jsonArrStart) + ? jsonStart + : jsonArrStart; + if (start < 0) { + resolve({ raw: trimmed }); + return; + } + resolve(JSON.parse(trimmed.slice(start)) as Record); + } catch { + reject(new MuapiCliError("Failed to parse muapi CLI JSON output", 1, stdout)); + } + }); + }); +} + +export async function cliUpload( + filePath: string, + apiKey?: string +): Promise { + const result = await runMuapiCli({ + args: ["upload", "file", filePath, "--jq", ".url"], + apiKey, + }); + const url = (result as { url?: string }).url ?? (result.raw as string | undefined); + if (!url || typeof url !== "string") throw new Error("CLI upload did not return url"); + return url.replace(/^"|"$/g, ""); +} + +export async function cliPredictWait( + requestId: string, + apiKey?: string +): Promise> { + return runMuapiCli({ + args: ["predict", "wait", requestId], + apiKey, + }); +} + +export async function cliPredictStatus( + requestId: string, + apiKey?: string +): Promise> { + return runMuapiCli({ + args: ["predict", "result", requestId], + apiKey, + }); +} + +export async function cliAccountBalance(apiKey?: string): Promise { + try { + const result = await runMuapiCli({ + args: ["account", "balance"], + apiKey, + }); + const balance = (result as { balance?: number; credits?: number }).balance ?? + (result as { balance?: number; credits?: number }).credits; + return typeof balance === "number" ? balance : null; + } catch { + return null; + } +} + +export async function cliImageGenerate( + prompt: string, + model: string, + extra: Record = {}, + apiKey?: string +): Promise> { + const args = ["image", "generate", prompt, "--model", model, "--no-wait"]; + for (const [k, v] of Object.entries(extra)) { + args.push(`--${k.replace(/_/g, "-")}`, String(v)); + } + return runMuapiCli({ args, apiKey }); +} + +export async function cliImageEdit( + prompt: string, + model: string, + imageUrl: string, + extra: Record = {}, + apiKey?: string +): Promise> { + const args = ["image", "edit", prompt, "--model", model, "--image", imageUrl, "--no-wait"]; + for (const [k, v] of Object.entries(extra)) { + args.push(`--${k.replace(/_/g, "-")}`, String(v)); + } + return runMuapiCli({ args, apiKey }); +} + +export async function cliVideoFromImage( + prompt: string, + model: string, + imageUrl: string, + extra: Record = {}, + apiKey?: string +): Promise> { + const args = ["video", "from-image"]; + if (prompt) args.push(prompt); + args.push("--model", model, "--image", imageUrl, "--no-wait"); + for (const [k, v] of Object.entries(extra)) { + args.push(`--${k.replace(/_/g, "-")}`, String(v)); + } + return runMuapiCli({ args, apiKey }); +} + +export async function cliVideoGenerate( + prompt: string, + model: string, + extra: Record = {}, + apiKey?: string +): Promise> { + const args = ["video", "generate", prompt, "--model", model, "--no-wait"]; + for (const [k, v] of Object.entries(extra)) { + args.push(`--${k.replace(/_/g, "-")}`, String(v)); + } + return runMuapiCli({ args, apiKey }); +} diff --git a/media-studio/packages/muapi-client/src/client.ts b/media-studio/packages/muapi-client/src/client.ts new file mode 100644 index 0000000..9fc1dc4 --- /dev/null +++ b/media-studio/packages/muapi-client/src/client.ts @@ -0,0 +1,286 @@ +import type { + ImageEditParams, + ImageGenParams, + JobHandle, + JobResult, + VideoI2VParams, + VideoT2VParams, +} from "@ultimate-multimodal/shared"; +import { + cliAccountBalance, + cliImageEdit, + cliImageGenerate, + cliPredictStatus, + cliPredictWait, + cliUpload, + cliVideoFromImage, + cliVideoGenerate, + MuapiCliError, + runMuapiCli, +} from "./cli"; +import { + buildPayload, + getModel, + listModels, + loadSchema, + validateParams, +} from "./schema"; +import { + extractRequestId, + normalizeStatus, + restAccountBalance, + restPredictStatus, + restPredictWait, + restSubmit, + restUpload, + toJobResult, +} from "./rest"; +import type { ModelInfo, MuapiClientOptions, PredictPollOptions, ValidationResult } from "./types"; + +export class MuapiClient { + private readonly apiKey: string; + private readonly baseUrl: string; + private readonly preferCli: boolean; + private readonly schemaPath?: string; + private readonly pollIntervalMs: number; + private readonly pollMaxAttempts: number; + + constructor(options: MuapiClientOptions = {}) { + const key = options.apiKey ?? process.env.MUAPI_API_KEY; + if (!key) throw new Error("MUAPI_API_KEY is not configured"); + this.apiKey = key; + this.baseUrl = options.baseUrl ?? "https://api.muapi.ai/api/v1"; + this.preferCli = options.preferCli ?? false; + this.schemaPath = options.schemaPath; + this.pollIntervalMs = options.pollIntervalMs ?? 3000; + this.pollMaxAttempts = options.pollMaxAttempts ?? 120; + } + + // --- Schema --- + + getModel(modelId: string): ModelInfo { + return getModel(modelId, this.schemaPath); + } + + listModels(category?: string): ModelInfo[] { + return listModels(category, this.schemaPath); + } + + validate(modelId: string, params: Record): ValidationResult { + return validateParams(modelId, params, this.schemaPath); + } + + loadSchema() { + return loadSchema(this.schemaPath); + } + + // --- Upload --- + + async upload(file: Buffer, filename: string, mimeType: string): Promise { + if (this.preferCli) { + const tmpPath = await this.writeTempFile(file, filename); + try { + return await cliUpload(tmpPath, this.apiKey); + } finally { + await this.removeTempFile(tmpPath); + } + } + return restUpload(file, filename, mimeType, this.apiKey, this.baseUrl); + } + + // --- Generation --- + + async imageGenerate(params: ImageGenParams): Promise { + const body = { + prompt: params.prompt, + aspect_ratio: params.aspectRatio ?? "1:1", + num_images: params.numImages ?? 1, + }; + return this.submitModel(params.model, body, () => + cliImageGenerate(params.prompt, params.model, { + "aspect-ratio": params.aspectRatio ?? "1:1", + "num-images": params.numImages ?? 1, + }, this.apiKey) + ); + } + + async imageEdit(params: ImageEditParams): Promise { + const body: Record = { + prompt: params.prompt, + image_urls: params.imageUrls, + aspect_ratio: params.aspectRatio, + resolution: params.resolution, + }; + return this.submitModel(params.model, body, () => + cliImageEdit( + params.prompt, + params.model, + params.imageUrls[0], + { + "aspect-ratio": params.aspectRatio ?? "1:1", + resolution: params.resolution ?? "1k", + }, + this.apiKey + ) + ); + } + + async videoFromImage(params: VideoI2VParams): Promise { + const body: Record = { + prompt: params.prompt, + image_url: params.imageUrl, + aspect_ratio: params.aspectRatio ?? "9:16", + duration: params.duration ?? 10, + generate_audio: params.generateAudio ?? true, + }; + return this.submitModel(params.model, body, () => + cliVideoFromImage( + params.prompt, + params.model, + params.imageUrl, + { + "aspect-ratio": params.aspectRatio ?? "9:16", + duration: params.duration ?? 10, + "generate-audio": params.generateAudio ?? true, + }, + this.apiKey + ) + ); + } + + async videoGenerate(params: VideoT2VParams): Promise { + const body: Record = { + prompt: params.prompt, + aspect_ratio: params.aspectRatio ?? "16:9", + duration: params.duration ?? 5, + }; + return this.submitModel(params.model, body, () => + cliVideoGenerate(params.prompt, params.model, { + "aspect-ratio": params.aspectRatio ?? "16:9", + duration: params.duration ?? 5, + }, this.apiKey) + ); + } + + private async submitModel( + modelId: string, + params: Record, + cliFn: () => Promise> + ): Promise { + if (this.preferCli) { + return this.handleFromCli(await cliFn()); + } + + try { + const { endpoint, payload } = buildPayload(modelId, params, this.schemaPath); + return await restSubmit(endpoint, payload, this.apiKey, this.baseUrl); + } catch (restError) { + try { + return this.handleFromCli(await cliFn()); + } catch (cliError) { + const restMsg = restError instanceof Error ? restError.message : String(restError); + const cliMsg = cliError instanceof MuapiCliError ? cliError.message : String(cliError); + throw new Error(`MuAPI submit failed. REST: ${restMsg}. CLI: ${cliMsg}`); + } + } + } + + private handleFromCli(data: Record): JobHandle { + const requestId = extractRequestId(data as { request_id?: string; id?: string }); + if (!requestId) throw new Error("CLI response missing request_id"); + return { + requestId, + status: normalizeStatus(data.status as string | undefined), + }; + } + + // --- Polling --- + + async predictStatus(requestId: string): Promise { + if (this.preferCli) { + try { + const data = await cliPredictStatus(requestId, this.apiKey); + return toJobResult(requestId, data as Parameters[1]); + } catch { + return restPredictStatus(requestId, this.apiKey, this.baseUrl); + } + } + return restPredictStatus(requestId, this.apiKey, this.baseUrl); + } + + async predictWait( + requestId: string, + options: PredictPollOptions = {} + ): Promise { + const maxAttempts = options.maxAttempts ?? this.pollMaxAttempts; + const intervalMs = options.intervalMs ?? this.pollIntervalMs; + + if (this.preferCli) { + try { + const data = await cliPredictWait(requestId, this.apiKey); + return toJobResult(requestId, data as Parameters[1]); + } catch { + // fall through to REST polling + } + } + + return restPredictWait( + requestId, + this.apiKey, + maxAttempts, + intervalMs, + this.baseUrl + ); + } + + async getAccountBalance(): Promise { + if (this.preferCli) { + const balance = await cliAccountBalance(this.apiKey); + if (balance !== null) return balance; + } + return restAccountBalance(this.apiKey, this.baseUrl); + } + + // --- Raw CLI access --- + + runCli(args: string[]): Promise> { + return runMuapiCli({ args, apiKey: this.apiKey }); + } + + // --- Temp file helpers (CLI upload) --- + + private async writeTempFile(buffer: Buffer, filename: string): Promise { + const { mkdtemp, writeFile } = await import("fs/promises"); + const { join } = await import("path"); + const { tmpdir } = await import("os"); + const dir = await mkdtemp(join(tmpdir(), "muapi-upload-")); + const filePath = join(dir, filename); + await writeFile(filePath, buffer); + return filePath; + } + + private async removeTempFile(filePath: string): Promise { + const { unlink } = await import("fs/promises"); + try { + await unlink(filePath); + } catch { + // ignore cleanup errors + } + } +} + +export function createMuapiClient(options?: MuapiClientOptions): MuapiClient { + return new MuapiClient(options); +} + +export function getApiKey(): string { + const key = process.env.MUAPI_API_KEY; + if (!key) throw new Error("MUAPI_API_KEY is not configured"); + return key; +} + +export function resolveApiKey(settingsKey?: string): string { + const key = settingsKey ?? process.env.MUAPI_API_KEY; + if (!key) throw new Error("MUAPI_API_KEY is not configured"); + return key; +} diff --git a/media-studio/packages/muapi-client/src/index.ts b/media-studio/packages/muapi-client/src/index.ts new file mode 100644 index 0000000..86c8623 --- /dev/null +++ b/media-studio/packages/muapi-client/src/index.ts @@ -0,0 +1,92 @@ +import type { GenerateAction, ImageEditParams, ImageGenParams, JobHandle, JobResult, VideoI2VParams, VideoT2VParams } from "@ultimate-multimodal/shared"; +import { createMuapiClient, getApiKey, resolveApiKey } from "./client"; + +export { MuapiClient, createMuapiClient, getApiKey, resolveApiKey } from "./client"; +export { MuapiCliError } from "./cli"; +export { + buildPayload, + clearSchemaCache, + getModel, + listModels, + loadSchema, + modelHasProperty, + resolveSchemaPath, + validateParams, +} from "./schema"; +export { + extractOutputs, + extractRequestId, + normalizeStatus, + restAccountBalance, + restPredictStatus, + restPredictWait, + restSubmit, + restUpload, + toJobResult, +} from "./rest"; +export type { ModelInfo, MuapiClientOptions, SchemaModel, ValidationResult } from "./types"; + +function client(apiKey?: string) { + return createMuapiClient({ apiKey: apiKey ?? getApiKey() }); +} + +/** @deprecated Use createMuapiClient().upload() */ +export async function uploadFile( + file: Buffer, + filename: string, + mimeType: string, + apiKey?: string +): Promise { + return createMuapiClient({ apiKey: apiKey ?? resolveApiKey() }).upload(file, filename, mimeType); +} + +export async function imageGenerate(params: ImageGenParams, apiKey?: string): Promise { + return client(apiKey).imageGenerate(params); +} + +export async function imageEdit(params: ImageEditParams, apiKey?: string): Promise { + return client(apiKey).imageEdit(params); +} + +export async function videoFromImage(params: VideoI2VParams, apiKey?: string): Promise { + return client(apiKey).videoFromImage(params); +} + +export async function videoGenerate(params: VideoT2VParams, apiKey?: string): Promise { + return client(apiKey).videoGenerate(params); +} + +export async function predictStatus(requestId: string, apiKey?: string): Promise { + return client(apiKey).predictStatus(requestId); +} + +export async function predictWait( + requestId: string, + apiKey?: string, + maxAttempts = 120, + intervalMs = 3000 +): Promise { + return client(apiKey).predictWait(requestId, { maxAttempts, intervalMs }); +} + +export async function getAccountBalance(apiKey?: string): Promise { + return client(apiKey).getAccountBalance(); +} + +export async function executeGenerate(action: GenerateAction, apiKey?: string): Promise { + const c = client(apiKey); + switch (action.type) { + case "image_generate": + return c.imageGenerate(action.params); + case "image_edit": + return c.imageEdit(action.params); + case "video_from_image": + return c.videoFromImage(action.params); + case "video_generate": + return c.videoGenerate(action.params); + } +} + +export async function modelsList(category?: string, apiKey?: string): Promise { + return client(apiKey).listModels(category); +} diff --git a/media-studio/packages/muapi-client/src/rest.ts b/media-studio/packages/muapi-client/src/rest.ts new file mode 100644 index 0000000..f088807 --- /dev/null +++ b/media-studio/packages/muapi-client/src/rest.ts @@ -0,0 +1,158 @@ +import type { JobHandle, JobResult, JobStatus } from "@ultimate-multimodal/shared"; + +const DEFAULT_BASE = "https://api.muapi.ai/api/v1"; + +interface PredictResponse { + request_id?: string; + id?: string; + status?: string; + outputs?: string[]; + output?: string[] | { url?: string }; + url?: string; + error?: string; +} + +export function normalizeStatus(status?: string): JobStatus { + const s = (status ?? "pending").toLowerCase(); + if (s === "completed" || s === "succeeded" || s === "success") return "completed"; + if (s === "failed" || s === "error") return "failed"; + if (s === "processing" || s === "running" || s === "in_progress") return "processing"; + return "pending"; +} + +export function extractOutputs(data: PredictResponse): string[] { + if (Array.isArray(data.outputs)) return data.outputs.filter(Boolean); + if (Array.isArray(data.output)) return data.output.filter(Boolean); + if (data.output && typeof data.output === "object" && "url" in data.output && data.output.url) { + return [data.output.url]; + } + if (data.url) return [data.url]; + return []; +} + +export function extractRequestId(data: PredictResponse): string | null { + return data.request_id ?? data.id ?? null; +} + +export function toJobResult(requestId: string, data: PredictResponse): JobResult { + return { + requestId, + status: normalizeStatus(data.status), + outputs: extractOutputs(data), + error: data.error, + }; +} + +export async function restFetch( + path: string, + apiKey: string, + options: RequestInit = {}, + baseUrl = DEFAULT_BASE +): Promise { + const url = path.startsWith("http") ? path : `${baseUrl}${path.startsWith("/") ? path : `/${path}`}`; + const res = await fetch(url, { + ...options, + headers: { + "x-api-key": apiKey, + "content-type": "application/json", + ...(options.headers ?? {}), + }, + }); + + if (!res.ok) { + const text = await res.text(); + throw new Error(`MuAPI error ${res.status}: ${text}`); + } + + return res.json() as Promise; +} + +export async function restUpload( + file: Buffer, + filename: string, + mimeType: string, + apiKey: string, + baseUrl = DEFAULT_BASE +): Promise { + const form = new FormData(); + const blob = new Blob([new Uint8Array(file)], { type: mimeType }); + form.append("file", blob, filename); + + const res = await fetch(`${baseUrl}/upload`, { + method: "POST", + headers: { "x-api-key": apiKey }, + body: form, + }); + + if (!res.ok) { + const text = await res.text(); + throw new Error(`Upload failed ${res.status}: ${text}`); + } + + const data = (await res.json()) as { url?: string }; + if (!data.url) throw new Error("Upload response missing url"); + return data.url; +} + +export async function restSubmit( + endpoint: string, + payload: Record, + apiKey: string, + baseUrl = DEFAULT_BASE +): Promise { + const data = await restFetch( + `/${endpoint}`, + apiKey, + { method: "POST", body: JSON.stringify(payload) }, + baseUrl + ); + const requestId = extractRequestId(data); + if (!requestId) throw new Error("No request_id in MuAPI response"); + return { requestId, status: normalizeStatus(data.status) }; +} + +export async function restPredictStatus( + requestId: string, + apiKey: string, + baseUrl = DEFAULT_BASE +): Promise { + const data = await restFetch( + `/predictions/${requestId}/result`, + apiKey, + { method: "GET" }, + baseUrl + ); + return toJobResult(requestId, data); +} + +export async function restPredictWait( + requestId: string, + apiKey: string, + maxAttempts = 120, + intervalMs = 3000, + baseUrl = DEFAULT_BASE +): Promise { + for (let i = 0; i < maxAttempts; i++) { + const result = await restPredictStatus(requestId, apiKey, baseUrl); + if (result.status === "completed" || result.status === "failed") return result; + await new Promise((r) => setTimeout(r, intervalMs)); + } + throw new Error(`Prediction ${requestId} timed out after ${maxAttempts} attempts`); +} + +export async function restAccountBalance( + apiKey: string, + baseUrl = DEFAULT_BASE +): Promise { + try { + const data = await restFetch<{ balance?: number; credits?: number }>( + "/account/balance", + apiKey, + { method: "GET" }, + baseUrl + ); + return data.balance ?? data.credits ?? null; + } catch { + return null; + } +} diff --git a/media-studio/packages/muapi-client/src/schema.test.ts b/media-studio/packages/muapi-client/src/schema.test.ts new file mode 100644 index 0000000..51b2415 --- /dev/null +++ b/media-studio/packages/muapi-client/src/schema.test.ts @@ -0,0 +1,44 @@ +import { strict as assert } from "node:assert"; +import { buildPayload, getModel, validateParams } from "./schema.ts"; + +function test(name: string, fn: () => void) { + try { + fn(); + console.log(` ✓ ${name}`); + } catch (e) { + console.error(` ✗ ${name}`); + throw e; + } +} + +console.log("muapi-client schema tests\n"); + +test("resolves flux-dev endpoint from schema_data.json", () => { + const model = getModel("flux-dev"); + assert.equal(model.endpoint, "flux-dev-image"); + assert.ok(model.category.includes("Text to Image")); +}); + +test("validates prompt is required for flux-dev", () => { + const result = validateParams("flux-dev", {}); + assert.equal(result.valid, false); + assert.ok(result.errors.some((e) => e.includes("prompt"))); +}); + +test("builds payload with aspect ratio mapped to width/height", () => { + const { endpoint, payload } = buildPayload("flux-dev", { + prompt: "a sunset", + aspect_ratio: "16:9", + }); + assert.equal(endpoint, "flux-dev-image"); + assert.equal(payload.prompt, "a sunset"); + assert.equal(payload.width, 1344); + assert.equal(payload.height, 768); +}); + +test("resolves nano-banana-pro-edit for UGC workflows", () => { + const model = getModel("nano-banana-pro-edit"); + assert.equal(model.endpoint, "nano-banana-pro-edit"); +}); + +console.log("\nAll tests passed."); diff --git a/media-studio/packages/muapi-client/src/schema.ts b/media-studio/packages/muapi-client/src/schema.ts new file mode 100644 index 0000000..214db73 --- /dev/null +++ b/media-studio/packages/muapi-client/src/schema.ts @@ -0,0 +1,191 @@ +import { existsSync, readFileSync } from "fs"; +import { join } from "path"; +import type { ModelInfo, SchemaModel, ValidationResult } from "./types"; + +const ASPECT_TO_DIMENSIONS: Record = { + "1:1": { width: 1024, height: 1024 }, + "16:9": { width: 1344, height: 768 }, + "9:16": { width: 768, height: 1344 }, + "4:3": { width: 1152, height: 896 }, + "3:4": { width: 896, height: 1152 }, + "4:5": { width: 896, height: 1120 }, + "2:3": { width: 832, height: 1248 }, + "21:9": { width: 1536, height: 640 }, +}; + +let cachedModels: SchemaModel[] | null = null; +let cachedSchemaPath: string | null = null; + +function schemaCandidates(customPath?: string): string[] { + if (customPath) return [customPath]; + return [ + join(process.cwd(), "..", "skills", "schema_data.json"), + join(process.cwd(), "skills", "schema_data.json"), + join(process.cwd(), "..", "..", "skills", "schema_data.json"), + ]; +} + +export function resolveSchemaPath(customPath?: string): string { + for (const candidate of schemaCandidates(customPath)) { + if (existsSync(candidate)) return candidate; + } + throw new Error( + `schema_data.json not found. Checked: ${schemaCandidates(customPath).join(", ")}` + ); +} + +export function loadSchema(customPath?: string): SchemaModel[] { + const path = resolveSchemaPath(customPath); + if (cachedModels && cachedSchemaPath === path) return cachedModels; + const raw = readFileSync(path, "utf-8"); + cachedModels = JSON.parse(raw) as SchemaModel[]; + cachedSchemaPath = path; + return cachedModels; +} + +export function getModel(modelId: string, customPath?: string): ModelInfo { + const models = loadSchema(customPath); + const model = models.find((m) => m.name === modelId); + if (!model) { + const sample = models.slice(0, 8).map((m) => m.name).join(", "); + throw new Error(`Model '${modelId}' not found in schema_data.json. Examples: ${sample}...`); + } + const input = model.input_schema.schemas.input_data; + return { + name: model.name, + category: model.category, + endpoint: input.endpoint_url, + properties: input.properties ?? {}, + required: input.required ?? [], + description: model.description, + }; +} + +export function listModels(category?: string, customPath?: string): ModelInfo[] { + const models = loadSchema(customPath); + return models + .filter((m) => !category || m.category.toLowerCase().includes(category.toLowerCase())) + .map((m) => getModel(m.name, customPath)); +} + +export function modelHasProperty(model: ModelInfo, prop: string): boolean { + return prop in model.properties; +} + +export function validateParams( + modelId: string, + params: Record, + customPath?: string +): ValidationResult { + const errors: string[] = []; + let model: ModelInfo; + try { + model = getModel(modelId, customPath); + } catch (e) { + return { valid: false, errors: [e instanceof Error ? e.message : String(e)] }; + } + + for (const req of model.required) { + if (params[req] === undefined || params[req] === null || params[req] === "") { + // image_urls satisfies image_url / images_list requirements + if ( + req === "image_url" && + (params.image_url || (Array.isArray(params.image_urls) && params.image_urls[0])) + ) { + continue; + } + if ( + req === "images_list" && + Array.isArray(params.image_urls) && + params.image_urls.length > 0 + ) { + continue; + } + errors.push(`Missing required parameter: ${req}`); + } + } + + for (const [key, value] of Object.entries(params)) { + if (value === undefined || value === null) continue; + const prop = model.properties[key]; + if (!prop) continue; + + if (prop.enum && typeof value === "string" && !prop.enum.includes(value)) { + errors.push( + `Invalid ${key} '${value}'. Allowed: ${prop.enum.slice(0, 6).join(", ")}${prop.enum.length > 6 ? "..." : ""}` + ); + } + + if (typeof value === "number") { + if (prop.minValue !== undefined && value < prop.minValue) { + errors.push(`${key} must be >= ${prop.minValue}`); + } + if (prop.maxValue !== undefined && value > prop.maxValue) { + errors.push(`${key} must be <= ${prop.maxValue}`); + } + } + } + + return { valid: errors.length === 0, errors }; +} + +export function buildPayload( + modelId: string, + params: Record, + customPath?: string +): { endpoint: string; payload: Record } { + const validation = validateParams(modelId, params, customPath); + if (!validation.valid) { + throw new Error(`Validation failed for '${modelId}': ${validation.errors.join("; ")}`); + } + + const model = getModel(modelId, customPath); + const payload: Record = {}; + + for (const [key, value] of Object.entries(params)) { + if (value !== undefined && value !== null && modelHasProperty(model, key)) { + payload[key] = value; + } + } + + // Map image_urls array to model-specific fields + if (params.image_urls && Array.isArray(params.image_urls)) { + const urls = params.image_urls as string[]; + if (modelHasProperty(model, "image_urls")) { + payload.image_urls = urls; + } else if (modelHasProperty(model, "images_list")) { + payload.images_list = urls; + } else if (modelHasProperty(model, "image_url") && urls[0]) { + payload.image_url = urls[0]; + } + } + + if (params.image_url && modelHasProperty(model, "image_url")) { + payload.image_url = params.image_url; + } + + const aspectRatio = params.aspect_ratio as string | undefined; + const supportsAspect = modelHasProperty(model, "aspect_ratio"); + + if (aspectRatio && supportsAspect) { + payload.aspect_ratio = aspectRatio; + } else if (aspectRatio && !supportsAspect) { + const dims = ASPECT_TO_DIMENSIONS[aspectRatio]; + if (dims) { + if (modelHasProperty(model, "width")) payload.width = dims.width; + if (modelHasProperty(model, "height")) payload.height = dims.height; + } + } + + // Ensure prompt is included when required + if (model.required.includes("prompt") && params.prompt) { + payload.prompt = params.prompt; + } + + return { endpoint: model.endpoint, payload }; +} + +export function clearSchemaCache(): void { + cachedModels = null; + cachedSchemaPath = null; +} diff --git a/media-studio/packages/muapi-client/src/types.ts b/media-studio/packages/muapi-client/src/types.ts new file mode 100644 index 0000000..743c405 --- /dev/null +++ b/media-studio/packages/muapi-client/src/types.ts @@ -0,0 +1,60 @@ +export interface SchemaProperty { + name?: string; + type?: string; + description?: string; + default?: unknown; + enum?: string[]; + minValue?: number; + maxValue?: number; +} + +export interface SchemaModel { + name: string; + category: string; + variant?: string; + family?: string; + description: string; + input_schema: { + schemas: { + input_data: { + endpoint_url: string; + properties: Record; + required?: string[]; + }; + }; + }; +} + +export interface ModelInfo { + name: string; + category: string; + endpoint: string; + properties: Record; + required: string[]; + description: string; +} + +export interface ValidationResult { + valid: boolean; + errors: string[]; +} + +export interface MuapiClientOptions { + apiKey?: string; + baseUrl?: string; + preferCli?: boolean; + schemaPath?: string; + pollIntervalMs?: number; + pollMaxAttempts?: number; +} + +export interface CliRunOptions { + args: string[]; + apiKey?: string; + cwd?: string; +} + +export interface PredictPollOptions { + maxAttempts?: number; + intervalMs?: number; +} diff --git a/media-studio/packages/shared/package.json b/media-studio/packages/shared/package.json new file mode 100644 index 0000000..12b52cc --- /dev/null +++ b/media-studio/packages/shared/package.json @@ -0,0 +1,10 @@ +{ + "name": "@ultimate-multimodal/shared", + "version": "0.1.0", + "private": true, + "main": "./src/index.ts", + "types": "./src/index.ts", + "exports": { + ".": "./src/index.ts" + } +} diff --git a/media-studio/packages/shared/src/index.ts b/media-studio/packages/shared/src/index.ts new file mode 100644 index 0000000..5ef9f17 --- /dev/null +++ b/media-studio/packages/shared/src/index.ts @@ -0,0 +1,101 @@ +export type JobStatus = "pending" | "processing" | "completed" | "failed"; + +export interface JobHandle { + requestId: string; + status: JobStatus; +} + +export interface JobResult { + requestId: string; + status: JobStatus; + outputs: string[]; + error?: string; +} + +export interface ImageGenParams { + prompt: string; + model: string; + aspectRatio?: string; + numImages?: number; +} + +export interface ImageEditParams { + prompt: string; + model: string; + imageUrls: string[]; + aspectRatio?: string; + resolution?: string; +} + +export interface VideoI2VParams { + prompt: string; + model: string; + imageUrl: string; + aspectRatio?: string; + duration?: number; + generateAudio?: boolean; +} + +export interface VideoT2VParams { + prompt: string; + model: string; + aspectRatio?: string; + duration?: number; +} + +export type GenerateAction = + | { type: "image_generate"; params: ImageGenParams } + | { type: "image_edit"; params: ImageEditParams } + | { type: "video_from_image"; params: VideoI2VParams } + | { type: "video_generate"; params: VideoT2VParams }; + +export interface SkillInput { + name: string; + type: string; + required: boolean; + default?: string; + description: string; +} + +export interface SkillDefinition { + slug: string; + name: string; + description: string; + path: string; + inputs: SkillInput[]; + category: string; +} + +export interface Project { + id: string; + name: string; + workflow: string; + inputs: Record; + outputs: string[]; + createdAt: string; + updatedAt: string; +} + +export interface StoredJob { + id: string; + type: string; + model: string; + status: JobStatus; + outputs: string[]; + error?: string; + createdAt: string; + updatedAt: string; +} + +export interface AppSettings { + muapiApiKey?: string; + defaultImageModel: string; + defaultVideoModel: string; + outputDirectory: string; +} + +export const DEFAULT_SETTINGS: AppSettings = { + defaultImageModel: "flux-dev", + defaultVideoModel: "kling-v3.0-pro", + outputDirectory: "media_outputs", +}; diff --git a/media-studio/packages/workflow-engine/package.json b/media-studio/packages/workflow-engine/package.json new file mode 100644 index 0000000..ba8ce39 --- /dev/null +++ b/media-studio/packages/workflow-engine/package.json @@ -0,0 +1,15 @@ +{ + "name": "@ultimate-multimodal/workflow-engine", + "version": "0.1.0", + "private": true, + "main": "./src/index.ts", + "types": "./src/index.ts", + "exports": { + ".": "./src/index.ts", + "./server": "./src/server.ts" + }, + "dependencies": { + "@ultimate-multimodal/shared": "*", + "server-only": "^0.0.1" + } +} diff --git a/media-studio/packages/workflow-engine/src/client.ts b/media-studio/packages/workflow-engine/src/client.ts new file mode 100644 index 0000000..8814770 --- /dev/null +++ b/media-studio/packages/workflow-engine/src/client.ts @@ -0,0 +1,72 @@ +export function substitutePlaceholders( + template: string, + values: Record +): string { + return template.replace(/\{\{(\w+)\}\}/g, (_, key) => values[key] ?? `{{${key}}}`); +} + +export const UGC_WORKFLOWS = [ + { + slug: "muapi-ugc-video-factory", + path: "motion/ugc-video-factory", + title: "UGC Video Factory", + description: "Person + product + script → 10s vertical UGC video ad", + steps: ["Upload assets", "Director prompt", "Hero image", "Approve", "Generate video"], + models: { + hero: "nano-banana-pro-edit", + video: "seedance-2-vip-image-to-video", + }, + }, + { + slug: "muapi-ugc-ads-workflow", + path: "social/ugc-ads-workflow", + title: "UGC Ads Workflow", + description: "Selfie + product → composite image → script → video", + steps: ["Upload selfie & product", "Combine images", "Write script", "Animate"], + models: { + composite: "gpt-image-2-text-to-image", + video: "veo3.1-image-to-video", + }, + }, + { + slug: "muapi-ugc-lifestyle-try-on", + path: "motion/ugc-lifestyle-try-on", + title: "UGC Lifestyle Try-On", + description: "Product try-on lifestyle photos for social platforms", + steps: ["Upload product", "Try-on", "Lifestyle variant"], + models: { + tryon: "ai-dress-change", + lifestyle: "gpt4o-edit", + }, + }, +] as const; + +export function buildDirectorPrompt( + person: string, + product: string, + environment: string +): string { + return `Ultra-realistic lifestyle photography with ${person} and ${product} and ${environment}. + +If the product is wearable, the person wears the product naturally. +If the product is carried in the hand, the person holds the product naturally. +The product is clearly visible and is the main focus. Logo or text must be legible. +Natural modern look, minimalist style. Scene consistent with product use: ${environment}. +Soft natural daylight, clean aesthetic blurred background, high-end commercial lifestyle photography, +realistic textures, 4K quality, vertical 9:16 composition, social-media advertising style.`; +} + +export function buildUgcVideoPrompt(script: string): string { + return `Create a 10-second vertical UGC-style video (9:16). +A person interacts naturally with their setting and product. +Single uninterrupted shot. No cuts. No text on screen. +Person looks at camera with relaxed expression. +They say in a natural, conversational tone: "${script}" +Subtle hand gestures while speaking. End with a small smile or nod. +Style: authentic UGC, handheld phone feel, light natural movement, soft daylight, +shallow depth of field, TikTok/Reels aesthetic.`; +} + +export function buildUgcCompositePrompt(productName: string): string { + return `A natural, candid UGC-style photo of the influencer from the first reference image holding and showcasing the product from the second reference image. The influencer is smiling genuinely at the camera, holding the ${productName} up. Natural indoor lighting, lifestyle aesthetic, high quality.`; +} diff --git a/media-studio/packages/workflow-engine/src/index.ts b/media-studio/packages/workflow-engine/src/index.ts new file mode 100644 index 0000000..efa4f07 --- /dev/null +++ b/media-studio/packages/workflow-engine/src/index.ts @@ -0,0 +1,7 @@ +export { + UGC_WORKFLOWS, + buildDirectorPrompt, + buildUgcCompositePrompt, + buildUgcVideoPrompt, + substitutePlaceholders, +} from "./client"; diff --git a/media-studio/packages/workflow-engine/src/paths.ts b/media-studio/packages/workflow-engine/src/paths.ts new file mode 100644 index 0000000..ba282f6 --- /dev/null +++ b/media-studio/packages/workflow-engine/src/paths.ts @@ -0,0 +1,16 @@ +import { existsSync } from "fs"; +import { join } from "path"; + +function findSkillsRoot(): string { + const candidates = [ + join(process.cwd(), "..", "skills", "library"), + join(process.cwd(), "skills", "library"), + join(process.cwd(), "..", "..", "skills", "library"), + ]; + for (const c of candidates) { + if (existsSync(c)) return c; + } + return candidates[0]; +} + +export const SKILLS_ROOT = findSkillsRoot(); diff --git a/media-studio/packages/workflow-engine/src/server.ts b/media-studio/packages/workflow-engine/src/server.ts new file mode 100644 index 0000000..88b700b --- /dev/null +++ b/media-studio/packages/workflow-engine/src/server.ts @@ -0,0 +1,92 @@ +import "server-only"; + +import type { SkillDefinition, SkillInput } from "@ultimate-multimodal/shared"; +import { readdir, readFile, stat } from "fs/promises"; +import path from "path"; +import { SKILLS_ROOT } from "./paths"; + +function parseFrontmatter(content: string): Record { + const match = content.match(/^---\n([\s\S]*?)\n---/); + if (!match) return {}; + const result: Record = {}; + for (const line of match[1].split("\n")) { + const idx = line.indexOf(":"); + if (idx > 0) { + const key = line.slice(0, idx).trim(); + const val = line.slice(idx + 1).trim().replace(/^["']|["']$/g, ""); + result[key] = val; + } + } + return result; +} + +function parseInputsTable(content: string): SkillInput[] { + const inputs: SkillInput[] = []; + const section = content.match(/## Inputs[\s\S]*?\n\n([\s\S]*?)(?:\n## |\n---|\Z)/); + if (!section) return inputs; + + const rows = section[1].split("\n").filter((l) => l.startsWith("|") && !l.includes("---")); + for (const row of rows.slice(1)) { + const cols = row.split("|").map((c) => c.trim()).filter(Boolean); + if (cols.length < 4) continue; + const [name, type, required, defaultVal, description] = cols; + inputs.push({ + name: name.replace(/`/g, ""), + type: type.replace(/`/g, ""), + required: required.toLowerCase() === "yes", + default: defaultVal === "—" ? undefined : defaultVal.replace(/`/g, ""), + description: description ?? "", + }); + } + return inputs; +} + +function categoryFromPath(relPath: string): string { + const parts = relPath.split(path.sep); + return parts[0] ?? "general"; +} + +export async function findSkillFiles(dir: string = SKILLS_ROOT): Promise { + const results: string[] = []; + try { + const entries = await readdir(dir); + for (const entry of entries) { + const full = path.join(dir, entry); + const s = await stat(full); + if (s.isDirectory()) { + results.push(...(await findSkillFiles(full))); + } else if (entry === "SKILL.md") { + results.push(full); + } + } + } catch { + // skills dir may not exist in all contexts + } + return results; +} + +export async function parseSkillFile(filePath: string): Promise { + const content = await readFile(filePath, "utf-8"); + const fm = parseFrontmatter(content); + const rel = path.relative(SKILLS_ROOT, filePath); + const category = categoryFromPath(rel); + + return { + slug: fm.slug ?? path.basename(path.dirname(filePath)), + name: fm.name ?? fm.slug ?? "unknown", + description: fm.description ?? "", + path: rel, + inputs: parseInputsTable(content), + category, + }; +} + +export async function loadAllSkills(): Promise { + const files = await findSkillFiles(); + return Promise.all(files.map(parseSkillFile)); +} + +export async function loadSkillBySlug(slug: string): Promise { + const skills = await loadAllSkills(); + return skills.find((s) => s.slug === slug || s.path.includes(slug)) ?? null; +} diff --git a/media-studio/platform/app/(dashboard)/dashboard/jobs/page.tsx b/media-studio/platform/app/(dashboard)/dashboard/jobs/page.tsx new file mode 100644 index 0000000..4660a2c --- /dev/null +++ b/media-studio/platform/app/(dashboard)/dashboard/jobs/page.tsx @@ -0,0 +1,18 @@ +import { JobQueueTable } from "@/components/dashboard/JobQueueTable"; +import { getJobs } from "@/lib/storage"; + +export default async function DashboardJobsPage() { + const jobs = await getJobs(); + + return ( +
+

Job queue

+

+ Track async generation jobs and their status. +

+
+ +
+
+ ); +} diff --git a/media-studio/platform/app/(dashboard)/dashboard/page.tsx b/media-studio/platform/app/(dashboard)/dashboard/page.tsx new file mode 100644 index 0000000..01f1b86 --- /dev/null +++ b/media-studio/platform/app/(dashboard)/dashboard/page.tsx @@ -0,0 +1,62 @@ +import { getAccountBalance } from "@ultimate-multimodal/muapi-client"; +import { MetricCard } from "@/components/dashboard/MetricCard"; +import { StepCard } from "@/components/dashboard/MetricCard"; +import { getJobs, getProjects } from "@/lib/storage"; + +export default async function DashboardOverviewPage() { + const [jobs, projects, balance] = await Promise.all([ + getJobs(), + getProjects(), + getAccountBalance().catch(() => null), + ]); + + const activeJobs = jobs.filter((j) => j.status === "pending" || j.status === "processing").length; + const completedToday = jobs.filter((j) => { + const d = new Date(j.createdAt); + const today = new Date(); + return d.toDateString() === today.toDateString() && j.status === "completed"; + }).length; + + return ( +
+

+ Your media studio at a glance +

+

+ Monitor generations, projects, and API usage. +

+ +
+ + + + +
+ +
+ + + +
+
+ ); +} diff --git a/media-studio/platform/app/(dashboard)/dashboard/projects/page.tsx b/media-studio/platform/app/(dashboard)/dashboard/projects/page.tsx new file mode 100644 index 0000000..b6bd369 --- /dev/null +++ b/media-studio/platform/app/(dashboard)/dashboard/projects/page.tsx @@ -0,0 +1,39 @@ +import Link from "next/link"; +import { getProjects } from "@/lib/storage"; + +export default async function DashboardProjectsPage() { + const projects = await getProjects(); + + return ( +
+

Saved projects

+

+ UGC campaigns and generation outputs. +

+ + {projects.length === 0 ? ( +
+

No projects yet.

+ + Create your first UGC ad + +
+ ) : ( +
+ {projects.map((p) => ( +
+

{p.name}

+

{p.workflow}

+

+ {new Date(p.createdAt).toLocaleDateString()} +

+ {p.outputs.length > 0 && ( +

{p.outputs.length} output(s)

+ )} +
+ ))} +
+ )} +
+ ); +} diff --git a/media-studio/platform/app/(dashboard)/dashboard/settings/page.tsx b/media-studio/platform/app/(dashboard)/dashboard/settings/page.tsx new file mode 100644 index 0000000..d083d77 --- /dev/null +++ b/media-studio/platform/app/(dashboard)/dashboard/settings/page.tsx @@ -0,0 +1,124 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { PillButton } from "@/components/dashboard/PillButton"; +import type { AppSettings } from "@ultimate-multimodal/shared"; + +export default function DashboardSettingsPage() { + const [settings, setSettings] = useState({ + defaultImageModel: "flux-dev", + defaultVideoModel: "kling-v3.0-pro", + outputDirectory: "media_outputs", + }); + const [apiKey, setApiKey] = useState(""); + const [balance, setBalance] = useState(null); + const [saved, setSaved] = useState(false); + const [loading, setLoading] = useState(false); + + useEffect(() => { + fetch("/api/settings") + .then((r) => r.json()) + .then((data) => { + if (data.settings) setSettings(data.settings); + if (data.balance !== undefined) setBalance(data.balance); + }) + .catch(() => {}); + }, []); + + const handleSave = async () => { + setLoading(true); + setSaved(false); + try { + const res = await fetch("/api/settings", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + ...settings, + muapiApiKey: apiKey || settings.muapiApiKey, + }), + }); + const data = await res.json(); + if (!res.ok) throw new Error(data.error); + setBalance(data.balance ?? null); + setSaved(true); + setApiKey(""); + } catch { + // ignore + } finally { + setLoading(false); + } + }; + + return ( +
+

API settings

+

+ Configure your MuAPI key and default models. +

+ +
+
+ + setApiKey(e.target.value)} + /> +

+ Get your key at muapi.ai/dashboard +

+
+ + {balance !== null && ( +
+

Credit balance

+

{balance}

+
+ )} + +
+ + + setSettings({ ...settings, defaultImageModel: e.target.value }) + } + /> +
+ +
+ + + setSettings({ ...settings, defaultVideoModel: e.target.value }) + } + /> +
+ +
+ + + setSettings({ ...settings, outputDirectory: e.target.value }) + } + /> +
+ + + {loading ? "Saving..." : "Save settings"} + + + {saved && ( +

Settings saved successfully.

+ )} +
+
+ ); +} diff --git a/media-studio/platform/app/(dashboard)/dashboard/workflows/page.tsx b/media-studio/platform/app/(dashboard)/dashboard/workflows/page.tsx new file mode 100644 index 0000000..7655380 --- /dev/null +++ b/media-studio/platform/app/(dashboard)/dashboard/workflows/page.tsx @@ -0,0 +1,64 @@ +import { UGC_WORKFLOWS } from "@ultimate-multimodal/workflow-engine"; +import { loadAllSkills } from "@ultimate-multimodal/workflow-engine/server"; +import Link from "next/link"; + +export default async function DashboardWorkflowsPage() { + const allSkills = await loadAllSkills(); + + return ( +
+

Workflow recipes

+

+ {allSkills.length} recipes from Generative-Media-Skills +

+ +

UGC Pipelines

+
+ {UGC_WORKFLOWS.map((wf, i) => ( +
+ UGC +

{wf.title}

+

{wf.description}

+ + Open in Studio → + +
+ ))} +
+ +

All Recipes

+
+ {allSkills.slice(0, 20).map((skill) => ( +
+
+

{skill.name}

+

{skill.category}

+
+ {skill.inputs.length} inputs +
+ ))} +
+
+ ); +} diff --git a/media-studio/platform/app/(dashboard)/layout.tsx b/media-studio/platform/app/(dashboard)/layout.tsx new file mode 100644 index 0000000..8c64bfb --- /dev/null +++ b/media-studio/platform/app/(dashboard)/layout.tsx @@ -0,0 +1,18 @@ +import "../../styles/dashboard-theme.css"; +import { DashboardNav } from "@/components/dashboard/DashboardNav"; + +export default function DashboardLayout({ + children, +}: { + children: React.ReactNode; +}) { + return ( +
+ +
{children}
+
+ ); +} diff --git a/media-studio/platform/app/(studio)/layout.tsx b/media-studio/platform/app/(studio)/layout.tsx new file mode 100644 index 0000000..7fbf324 --- /dev/null +++ b/media-studio/platform/app/(studio)/layout.tsx @@ -0,0 +1,15 @@ +import "../../styles/studio-theme.css"; +import { StudioNav } from "@/components/studio/StudioNav"; + +export default function StudioLayout({ + children, +}: { + children: React.ReactNode; +}) { + return ( +
+ +
{children}
+
+ ); +} diff --git a/media-studio/platform/app/(studio)/page.tsx b/media-studio/platform/app/(studio)/page.tsx new file mode 100644 index 0000000..479ae93 --- /dev/null +++ b/media-studio/platform/app/(studio)/page.tsx @@ -0,0 +1,62 @@ +import Link from "next/link"; +import { PrimaryCTA } from "@/components/studio/PrimaryCTA"; +import { StatCounter } from "@/components/studio/FeatureChecklist"; + +export default function HomePage() { + return ( + <> +
+
+

+ Create UGC ads that feel real +

+

+ Person + product + script → vertical video ads powered by MuAPI and + 100+ generative models. +

+
+ + Start Creating + + + Image Studio + +
+
+
+
+

+ Get started free +

+

+ Upload a person photo and product image to generate your first UGC ad. +

+ + Create UGC Video + + + Configure API key → + +
+
+
+ +
+
+
+ + + + +
+
+
+ + ); +} diff --git a/media-studio/platform/app/(studio)/studio/image/page.tsx b/media-studio/platform/app/(studio)/studio/image/page.tsx new file mode 100644 index 0000000..82e6476 --- /dev/null +++ b/media-studio/platform/app/(studio)/studio/image/page.tsx @@ -0,0 +1,57 @@ +"use client"; + +import { useState } from "react"; +import { GenerationPanel } from "@/components/studio/GenerationPanel"; +import { JobProgress } from "@/components/studio/JobProgress"; +import { OutputGallery } from "@/components/studio/OutputGallery"; + +const IMAGE_MODELS = [ + { id: "flux-dev", label: "Flux Dev" }, + { id: "flux-schnell", label: "Flux Schnell" }, + { id: "hidream-fast", label: "HiDream Fast" }, + { id: "nano-banana-pro-edit", label: "Nano Banana Pro Edit" }, +]; + +export default function ImageStudioPage() { + const [requestId, setRequestId] = useState(null); + const [outputs, setOutputs] = useState<{ title: string; url: string }[]>([]); + + return ( +
+
+
+

Image Studio

+

+ Generate and edit images with MuAPI models. +

+
+
+
+
+ +
+ + setOutputs(urls.map((url, i) => ({ title: `Image ${i + 1}`, url }))) + } + /> +
+
+
+
+
+

Gallery

+
+ +
+
+
+
+ ); +} diff --git a/media-studio/platform/app/(studio)/studio/video/page.tsx b/media-studio/platform/app/(studio)/studio/video/page.tsx new file mode 100644 index 0000000..95c00bc --- /dev/null +++ b/media-studio/platform/app/(studio)/studio/video/page.tsx @@ -0,0 +1,41 @@ +"use client"; + +import { useState } from "react"; +import { GenerationPanel } from "@/components/studio/GenerationPanel"; +import { JobProgress } from "@/components/studio/JobProgress"; + +const VIDEO_MODELS = [ + { id: "kling-v3.0-pro", label: "Kling v3.0 Pro" }, + { id: "seedance-2-vip-image-to-video", label: "Seedance 2 VIP" }, + { id: "veo3.1-image-to-video", label: "Veo 3.1" }, +]; + +export default function VideoStudioPage() { + const [requestId, setRequestId] = useState(null); + + return ( +
+
+
+

Video Studio

+

+ Text-to-video generation for social and cinematic content. +

+
+
+
+
+ +
+ +
+
+
+
+ ); +} diff --git a/media-studio/platform/app/(studio)/ugc/ads-workflow/page.tsx b/media-studio/platform/app/(studio)/ugc/ads-workflow/page.tsx new file mode 100644 index 0000000..2935a8f --- /dev/null +++ b/media-studio/platform/app/(studio)/ugc/ads-workflow/page.tsx @@ -0,0 +1,130 @@ +"use client"; + +import { useState } from "react"; +import { buildUgcCompositePrompt } from "@ultimate-multimodal/workflow-engine"; +import { JobProgress } from "@/components/studio/JobProgress"; +import { MediaUploader } from "@/components/studio/MediaUploader"; +import { PrimaryCTA } from "@/components/studio/PrimaryCTA"; + +export default function UgcAdsWorkflowPage() { + const [humanUrl, setHumanUrl] = useState(""); + const [productUrl, setProductUrl] = useState(""); + const [productName, setProductName] = useState(""); + const [compositeUrl, setCompositeUrl] = useState(null); + const [compositeRequestId, setCompositeRequestId] = useState(null); + const [videoRequestId, setVideoRequestId] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + const combineImages = async () => { + if (!humanUrl || !productUrl || !productName) return; + setLoading(true); + setError(null); + try { + const res = await fetch("/api/generate", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + type: "image_edit", + params: { + prompt: buildUgcCompositePrompt(productName), + model: "gpt-image-2-text-to-image", + imageUrls: [humanUrl, productUrl], + aspectRatio: "9:16", + }, + }), + }); + const data = await res.json(); + if (!res.ok) throw new Error(data.error ?? "Combine failed"); + setCompositeRequestId(data.requestId); + } catch (e) { + setError(e instanceof Error ? e.message : "Failed"); + } finally { + setLoading(false); + } + }; + + const animateVideo = async () => { + if (!compositeUrl) return; + setLoading(true); + setError(null); + try { + const res = await fetch("/api/generate", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + type: "video_from_image", + params: { + prompt: `A UGC-style video. The influencer holds the ${productName}, smiling genuinely. Natural movements, talking to the camera, lifestyle vlog style.`, + model: "veo3.1-image-to-video", + imageUrl: compositeUrl, + aspectRatio: "9:16", + duration: 10, + }, + }), + }); + const data = await res.json(); + if (!res.ok) throw new Error(data.error ?? "Video failed"); + setVideoRequestId(data.requestId); + } catch (e) { + setError(e instanceof Error ? e.message : "Failed"); + } finally { + setLoading(false); + } + }; + + return ( +
+
+
+

UGC Ads Workflow

+

+ Selfie + product → composite image → animated UGC video +

+ +
+ + setProductName(e.target.value)} + placeholder="e.g. Blume SuperBalm in plum" + /> +
+ + + + + {error &&

{error}

} + + {!compositeUrl && ( + + {loading ? "Combining..." : "Combine Images"} + + )} + + { + if (outputs[0]) { + setCompositeUrl(outputs[0]); + setCompositeRequestId(null); + } + }} + /> + + {compositeUrl && ( +
+ {/* eslint-disable-next-line @next/next/no-img-element */} + Composite + + {loading ? "Animating..." : "Animate to Video"} + + +
+ )} +
+
+
+ ); +} diff --git a/media-studio/platform/app/(studio)/ugc/lifestyle-try-on/page.tsx b/media-studio/platform/app/(studio)/ugc/lifestyle-try-on/page.tsx new file mode 100644 index 0000000..0cd7454 --- /dev/null +++ b/media-studio/platform/app/(studio)/ugc/lifestyle-try-on/page.tsx @@ -0,0 +1,143 @@ +"use client"; + +import { useState } from "react"; +import { JobProgress } from "@/components/studio/JobProgress"; +import { MediaUploader } from "@/components/studio/MediaUploader"; +import { PrimaryCTA } from "@/components/studio/PrimaryCTA"; + +export default function UgcLifestyleTryOnPage() { + const [productUrl, setProductUrl] = useState(""); + const [productName, setProductName] = useState(""); + const [modelDesc, setModelDesc] = useState("woman, 25-30 years old, natural look, diverse"); + const [setting, setSetting] = useState("casual lifestyle, natural lighting"); + const [platform, setPlatform] = useState("instagram"); + const [tryonUrl, setTryonUrl] = useState(null); + const [requestId, setRequestId] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + const aspectMap: Record = { + instagram: "4:5", + tiktok: "9:16", + pinterest: "2:3", + amazon: "1:1", + }; + + const runTryOn = async () => { + if (!productUrl || !productName) return; + setLoading(true); + setError(null); + try { + const res = await fetch("/api/generate", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + type: "image_edit", + params: { + prompt: `${modelDesc} wearing the product naturally in a ${setting} environment. Authentic UGC-style photo, candid pose, natural expression.`, + model: "ai-dress-change", + imageUrls: [productUrl], + aspectRatio: aspectMap[platform] ?? "4:5", + }, + }), + }); + const data = await res.json(); + if (!res.ok) throw new Error(data.error ?? "Try-on failed"); + setRequestId(data.requestId); + } catch (e) { + setError(e instanceof Error ? e.message : "Failed"); + } finally { + setLoading(false); + } + }; + + const runLifestyle = async () => { + if (!tryonUrl) return; + setLoading(true); + setError(null); + try { + const res = await fetch("/api/generate", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + type: "image_edit", + params: { + prompt: `Make this look like authentic UGC content — add realistic environment context for ${setting}, adjust lighting to feel natural and unposed, subtle film grain, candid photography style. Keep product ${productName} clearly visible and well-lit.`, + model: "gpt4o-edit", + imageUrls: [tryonUrl], + aspectRatio: aspectMap[platform] ?? "4:5", + }, + }), + }); + const data = await res.json(); + if (!res.ok) throw new Error(data.error ?? "Lifestyle failed"); + setRequestId(data.requestId); + } catch (e) { + setError(e instanceof Error ? e.message : "Failed"); + } finally { + setLoading(false); + } + }; + + return ( +
+
+
+

UGC Lifestyle Try-On

+ +
+ + setProductName(e.target.value)} /> +
+ +
+ + setModelDesc(e.target.value)} /> +
+
+ + setSetting(e.target.value)} /> +
+
+ + +
+ + {error &&

{error}

} + + {!tryonUrl && ( + + {loading ? "Generating try-on..." : "Generate Try-On"} + + )} + + { + if (outputs[0]) { + setTryonUrl(outputs[0]); + setRequestId(null); + } + }} + /> + + {tryonUrl && ( +
+ {/* eslint-disable-next-line @next/next/no-img-element */} + Try-on + + {loading ? "Generating lifestyle..." : "Generate Lifestyle Variant"} + + +
+ )} +
+
+
+ ); +} diff --git a/media-studio/platform/app/(studio)/ugc/page.tsx b/media-studio/platform/app/(studio)/ugc/page.tsx new file mode 100644 index 0000000..2c56c28 --- /dev/null +++ b/media-studio/platform/app/(studio)/ugc/page.tsx @@ -0,0 +1,43 @@ +import Link from "next/link"; +import { UGC_WORKFLOWS } from "@ultimate-multimodal/workflow-engine"; + +const ROUTES: Record = { + "muapi-ugc-video-factory": "/ugc/video-factory", + "muapi-ugc-ads-workflow": "/ugc/ads-workflow", + "muapi-ugc-lifestyle-try-on": "/ugc/lifestyle-try-on", +}; + +export default function UgcIndexPage() { + return ( +
+
+
+

UGC Studio

+

+ Create authentic user-generated content ads — person + product videos, + lifestyle try-ons, and influencer-style campaigns. +

+
+
+
+
+ {UGC_WORKFLOWS.map((wf) => ( + +

+ UGC Workflow +

+

{wf.title}

+

+ {wf.description} +

+ + ))} +
+
+
+ ); +} diff --git a/media-studio/platform/app/(studio)/ugc/video-factory/page.tsx b/media-studio/platform/app/(studio)/ugc/video-factory/page.tsx new file mode 100644 index 0000000..4c09a41 --- /dev/null +++ b/media-studio/platform/app/(studio)/ugc/video-factory/page.tsx @@ -0,0 +1,161 @@ +"use client"; + +import { useState } from "react"; +import { + buildDirectorPrompt, + buildUgcVideoPrompt, +} from "@ultimate-multimodal/workflow-engine"; +import { DarkSplitSection, FeatureChecklist } from "@/components/studio/FeatureChecklist"; +import { JobProgress } from "@/components/studio/JobProgress"; +import { MediaUploader } from "@/components/studio/MediaUploader"; +import { PrimaryCTA } from "@/components/studio/PrimaryCTA"; + +const STEPS = [ + "Upload person & product", + "Director prompt", + "Hero image", + "Approve hero", + "Generate video", +]; + +const DEFAULT_SCRIPT = + "Okay… first of all, ship happens. And this hat is honestly my favorite. It also comes in navy and black, so you can pick your vibe."; +const DEFAULT_ENV = "study room, laptop in front of it"; + +export default function UgcVideoFactoryPage() { + const [personUrl, setPersonUrl] = useState(""); + const [productUrl, setProductUrl] = useState(""); + const [script, setScript] = useState(DEFAULT_SCRIPT); + const [environment, setEnvironment] = useState(DEFAULT_ENV); + const [heroUrl, setHeroUrl] = useState(null); + const [step, setStep] = useState(0); + const [requestId, setRequestId] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + const generateHero = async () => { + if (!personUrl || !productUrl) return; + setLoading(true); + setError(null); + try { + const prompt = buildDirectorPrompt("the person", "the product", environment); + const res = await fetch("/api/generate", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + type: "image_edit", + params: { + prompt, + model: "nano-banana-pro-edit", + imageUrls: [personUrl, productUrl], + aspectRatio: "9:16", + resolution: "1K", + }, + }), + }); + const data = await res.json(); + if (!res.ok) throw new Error(data.error ?? "Hero generation failed"); + setRequestId(data.requestId); + setStep(2); + } catch (e) { + setError(e instanceof Error ? e.message : "Failed"); + } finally { + setLoading(false); + } + }; + + const generateVideo = async () => { + if (!heroUrl) return; + setLoading(true); + setError(null); + try { + const prompt = buildUgcVideoPrompt(script); + const res = await fetch("/api/generate", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + type: "video_from_image", + params: { + prompt, + model: "seedance-2-vip-image-to-video", + imageUrl: heroUrl, + aspectRatio: "9:16", + duration: 10, + generateAudio: true, + }, + }), + }); + const data = await res.json(); + if (!res.ok) throw new Error(data.error ?? "Video generation failed"); + setRequestId(data.requestId); + setStep(4); + } catch (e) { + setError(e instanceof Error ? e.message : "Failed"); + } finally { + setLoading(false); + } + }; + + return ( +
+
+
+

UGC Video Factory

+

+ Person + product + script → 10s vertical UGC video ad +

+
+
+ + + + + +
+
+ { setPersonUrl(url); setStep(1); }} /> + +
+ +