{
    "formatVersion": 1,
    "documents": [
        {
            "group": "layout",
            "path": "src/types/layout.ts",
            "sha256": "b8ae6fa36bed14b38bb31dcf78c2ac02e204aa719f356aaff3f26c6a3a8a5b9a",
            "declarations": {
                "Platform": "export type Platform = \"ios\" | \"android\" | \"both\";",
                "DeviceType": "export type DeviceType = \"phone\" | \"tablet\" | \"desktop\" | \"watch\";",
                "TemplateType": "export type TemplateType = string;",
                "IllustrationAssetKind": "export type IllustrationAssetKind =\n  | \"illustration\"\n  | \"sticker\"\n  | \"icon\"\n  | \"app-icon\"\n  | \"badge\"\n  | \"qr-code\";",
                "Position": "export interface Position {\n  x: number;\n  y: number;\n}",
                "ScreenshotNode": "export interface ScreenshotNode {\n  id: string;\n  layerName?: string; // Optional user-facing name in the layer stack\n  path: string; // Relative layout path stored in DB (e.g., \"mobile/1234-image.png\")\n  signedUrl?: string; // Added at runtime by RSC for rendering\n  iosPath?: string; // Persisted — iOS screenshot path\n  iosSignedUrl?: string; // Runtime-only\n  androidPath?: string; // Persisted — Android screenshot path\n  androidSignedUrl?: string; // Runtime-only\n  position: Position; // CENTRE of the device, in the screen's canvas pixels\n  scale: number; // Device size factor (also sizes model3D devices)\n  rotation: number;\n  zIndex: number; // One stacking order per screen, shared with every other node type\n  overflow?: boolean; // Allow device to overflow to adjacent screens\n  tiltAngle?: number; // For tilted phone display (-30 to 30 degrees)\n  phoneId?: string; // 2D phone frame from phoneRegistry: 'iphone17' (iOS) | 'googlepixel' (Android); rendering follows the project platform, so pick other devices with model3D\n  tabletId?: string; // 2D tablet frame from tabletRegistry: 'ipad'\n  desktopId?: string; // 2D desktop frame from desktopRegistry: 'macbook-pro-16'\n  watchId?: string; // 2D watch frame from watchRegistry: 'apple-watch-41' | 'apple-watch-42' | 'apple-watch-46'\n  variant3D?:\n    | \"left\"\n    | \"left-2\"\n    | \"right\"\n    | \"right-2\"\n    | \"handheld\"\n    | \"handheld2\"\n    | \"handheld3\"\n    | \"flat\"\n    | \"none\"; // 3D rendering variant\n  fitMode?: ScreenshotFitMode; // Screenshot mapping inside frame (default: stretch)\n  showCamera?: boolean; // Whether to show the camera punch hole/notch (default: true)\n  cleanStatusBar?: boolean; // Replace the baked-in status bar with a clean 9:41 marketing bar; skipped when the top strip isn't a uniform background. Unset means the platform default (on for iOS, off for Android - the bar is Apple's), so always read it through resolveCleanStatusBar()\n  contentRotation?: 0 | 90 | 180 | 270; // Screenshot content rotation inside the frame, in degrees CW; undefined = auto (0 for portrait upload, 90 for landscape upload)\n  /**\n   * Opt in on an orientation/Tilt edit; legacy layouts keep their saved mapping.\n   * Content counter-rotates by the quarter turns between tiltAngle and\n   * initialTilt so it stays upright; fit \"auto\" renders a stretch fit as\n   * contain across an odd quarter turn, \"manual\" keeps fitMode.\n   */\n  contentOrientation?: {\n    version: 1;\n    initialTilt: number;\n    fit: \"auto\" | \"manual\";\n  };\n  contentTransform?: ScreenshotContentTransform; // Pan/zoom of the screenshot content inside the device screen; undefined = default fit\n  windowTitle?: string; // macOS window title bar text (desktop \"none\" variant)\n  opacity?: number; // Transparency 0-100 (default 100 = fully opaque)\n  hidden?: boolean; // Hide in editor/export layer rendering\n  locked?: boolean; // Prevent direct canvas interaction\n  shadow?: ShadowConfig; // Drop shadow configuration\n  model3D?: Model3DConfig; // Live Three.js glTF device (highest-priority device kind); overrides phone/tablet/desktop/watch frames\n}",
                "ScreenshotFitMode": "/**\n * How a screenshot maps onto the device screen.\n * - `stretch` distorts the image to fill the screen (no crop, wrong aspect)\n * - `cover` fills the screen at the correct aspect and crops the overflow\n * - `contain` fits the whole image at the correct aspect and fills the\n *   remainder with the colour of the image edge each bar touches\n *\n * The UI calls these Stretch / Crop / Fit. `cover` was labelled \"Fit\" until\n * Aug 2026, so layouts saved before then mean \"crop to fill\" by `cover`.\n */\nexport type ScreenshotFitMode = \"stretch\" | \"cover\" | \"contain\";",
                "ScreenshotContentTransform": "// Screen-space pan/zoom of the screenshot content inside the device frame.\n// Offsets are fractions of the screen area (positive x moves content right);\n// zoom >= 1 so the content always covers the screen without gaps.\nexport interface ScreenshotContentTransform {\n  x: number;\n  y: number;\n  zoom: number;\n}",
                "DeviceModelId": "// The 3D device models the renderer can load. Declared here (the dependency-free\n// base of the type graph) so the mockups state, the layout config and the API\n// schemas all narrow to the same union instead of restating it — adding a model\n// used to mean four synchronized edits with no compiler pressure.\nexport type DeviceModelId =\n  | \"iphone-17-pro\"\n  | \"google-pixel-10-pro\"\n  | \"ipad-pro\"\n  | \"macbook-pro-14\"\n  | \"iphone-duo\";",
                "Model3DConfig": "// Real 3D glTF device model (rendered offscreen via Three.js, composited into\n// the PIXI canvas as a sprite). Shares its look with the mockups editor.\nexport interface Model3DConfig {\n  modelId?: DeviceModelId;\n  /**\n   * Per-platform phone appearance; pose and scale remain shared.\n   * Flat modelId/finish remain the active rendering values for legacy clients.\n   * Switching platforms snapshots them before restoring the target selection.\n   */\n  devicesByPlatform?: Partial<\n    Record<\n      \"ios\" | \"android\",\n      {\n        modelId: DeviceModelId;\n        finish: Model3DConfig[\"finish\"];\n      }\n    >\n  >;\n  finish:\n    | \"silver\"\n    | \"black\"\n    | \"glacier\"\n    | \"burgundy\"\n    | \"cosmic-orange\"\n    | \"deep-blue\"\n    | \"pixel-obsidian\"\n    | \"pixel-porcelain\"\n    | \"pixel-moonstone\"\n    | \"duo-night-sky\"\n    | \"duo-star-white\";\n  rotationX: number; // Pitch (radians)\n  rotationY: number; // Yaw (radians)\n  rotationZ: number; // Roll (radians)\n  scale?: number; // Legacy model-space zoom. New layouts use ScreenshotNode.scale.\n  showDynamicIsland?: boolean; // Legacy per-node setting. Theme phone camera setting controls this now.\n  /**\n   * Hinge openness: 0 closed, 1 fully open. Only meaningful for articulated\n   * models (the MacBook lid, the iPhone Duo fold); undefined reads as fully\n   * open.\n   */\n  lidOpen?: number;\n}",
                "TiptapMark": "// Tiptap Rich Text JSON Types\nexport interface TiptapMark {\n  type: \"bold\" | \"italic\" | \"underline\" | \"textStyle\";\n  attrs?: {\n    color?: string;\n    colorRef?: ColorRole; // Optional link of `color` to a theme role\n    gradient?: Gradient | null;\n    fontFamily?: string;\n    fontSize?: string | number;\n    fontWeight?: string | number;\n  };\n}",
                "TiptapTextNode": "export interface TiptapTextNode {\n  type: \"text\";\n  text: string;\n  marks?: TiptapMark[];\n}",
                "TiptapHardBreak": "export interface TiptapHardBreak {\n  type: \"hardBreak\";\n}",
                "TiptapInlineNode": "export type TiptapInlineNode = TiptapTextNode | TiptapHardBreak;",
                "TiptapParagraph": "export interface TiptapParagraph {\n  type: \"paragraph\";\n  content?: TiptapInlineNode[];\n}",
                "TiptapJSON": "export interface TiptapJSON {\n  type: \"doc\";\n  content: TiptapParagraph[];\n  attrs?: {\n    defaultColor?: string;\n    defaultColorRef?: ColorRole; // Optional link of `defaultColor` to a theme role\n    defaultGradient?: Gradient;\n    defaultFontFamily?: string;\n    defaultFontSize?: number;\n    defaultFontWeight?: number;\n    /** Promo text sizes are in pixels at this composition width. */\n    promoFontSizeReferenceWidth?: number;\n  };\n}",
                "TextBoxContract": "/**\n * Fixed template-owned text container.\n *\n * `referenceContent` is the exact rapid-development/mock copy for this node.\n * The renderer measures it with the active font and width, then keeps that\n * measured height while fitting generated copy inside the same box.\n */\nexport interface TextBoxContract {\n  mode: \"shrink-to-fit\";\n  referenceContent: TiptapJSON;\n  minFontScale: number;\n  /** Identifies template text boxes that intentionally share one geometry. */\n  symmetryGroup?: string;\n  /**\n   * When false the fit ignores the copy below it. Set once the user has moved\n   * the text by hand: a dragged headline should pass over its neighbours, not\n   * shrink into the gap above them.\n   */\n  avoidNeighbors?: boolean;\n}",
                "TextFitContract": "export interface TextFitContract {\n  mode: \"shrink-to-fit\";\n  minFontScale: number;\n}",
                "TextNode": "export interface TextNode {\n  id: string;\n  layerName?: string; // Optional user-facing name in the layer stack\n  richContent: TiptapJSON; // Rich text content (Tiptap format) - required, contains all content and style properties\n  position: Position; // Top-left of the text box: x is its LEFT edge (align positions lines inside `width`)\n  zIndex: number;\n  align?: \"left\" | \"center\" | \"right\";\n  type?: \"title\" | \"subtitle\" | \"body\" | \"brands\";\n  lineHeight?: number; // Line spacing multiplier (e.g., 1.2)\n  width?: number; // Optional max width for text wrapping\n  textBox?: TextBoxContract; // Template-owned box used to fit generated copy\n  overflow?: boolean; // Allow text to overflow to adjacent screens\n  rotation?: number; // Rotation in degrees\n  opacity?: number; // Transparency 0-100 (default 100 = fully opaque)\n  hidden?: boolean; // Hide in editor/export layer rendering\n  locked?: boolean; // Prevent direct canvas interaction\n  shadow?: ShadowConfig; // Drop shadow configuration\n  backgroundColor?: string; // Optional text background fill\n  backgroundPaddingX?: number; // Horizontal background padding in pixels\n  backgroundPaddingY?: number; // Vertical background padding in pixels\n  backgroundCornerRadius?: number; // Rounded background corners in pixels\n  colorRefs?: ColorRefs; // Optional links from color fields (e.g. backgroundColor) to theme roles\n}",
                "HeaderNode": "export interface HeaderNode {\n  id: string;\n  position: Position;\n  width: number;\n  height: number;\n  backgroundColor: string;\n  zIndex: number;\n}",
                "Gradient": "export interface Gradient {\n  type: \"linear\" | \"radial\";\n  colors: string[];\n  direction?: number;\n}",
                "PanoramaAttribution": "export interface PanoramaAttribution {\n  photographer: string;\n  photographerUrl: string;\n  source?: \"pexels\" | \"unsplash\"; // defaults to \"pexels\" for backward compat\n  pexelsUrl?: string;\n  pexelsId?: number;\n  unsplashUrl?: string;\n  unsplashId?: string;\n}",
                "PanoramaBackground": "export interface PanoramaBackground {\n  imageUrl: string;\n  storagePath?: string; // Path in Supabase storage for deletion\n  verticalOffset?: number; // 0-100, where 50 is centered (default)\n  fitMode?: \"cover\" | \"contain\"; // default: 'cover'\n  blur?: number; // Blur intensity 0-20 (default: 0 = no blur)\n  attribution?: PanoramaAttribution; // Pexels photo attribution\n}",
                "ThemeColors": "export interface ThemeColors {\n  primary: string; // Dominant surface/background color\n  secondary: string; // Supporting surface/background color\n  accent: string; // Distinctive brand/action color; equals primary when none is observed\n  background?: string; // Background color (defaults to primary if not set)\n  text: string; // Primary text color\n  textSecondary: string; // Secondary text color\n  // Derived roles — computed deterministically from the five base roles by\n  // deriveThemeRoles (themePalette.ts), never chosen by the model.\n  panel?: string; // Adaptive block surface: accent when visibly distinct from primary, else the ink\n  onPanel?: string; // Ink guaranteed readable on `panel`\n  onAccent?: string; // Ink guaranteed readable on accent-colored surfaces\n  tile?: string; // Adaptive card surface: secondary when visibly elevated above primary, else `panel`\n  onTile?: string; // Ink guaranteed readable on `tile`\n}",
                "ThemePaletteMode": "export type ThemePaletteMode = \"v1\" | \"v2\";",
                "ThemePaletteOptions": "export interface ThemePaletteOptions {\n  v1: ThemeColors;\n  v2: ThemeColors;\n}",
                "BASE_COLOR_ROLES": "/** The five model-chosen palette roles; every other role is derived from them. */\nexport const BASE_COLOR_ROLES = [\n  \"primary\",\n  \"secondary\",\n  \"accent\",\n  \"text\",\n  \"textSecondary\",\n] as const;",
                "DERIVED_COLOR_ROLES": "/** Roles computed from the base roles by `deriveThemeRoles`. */\nexport const DERIVED_COLOR_ROLES = [\n  \"panel\",\n  \"onPanel\",\n  \"onAccent\",\n  \"tile\",\n  \"onTile\",\n] as const;",
                "COLOR_ROLES": "/** Every linkable theme role. The single source the ColorRole type derives from. */\nexport const COLOR_ROLES = [\n  ...BASE_COLOR_ROLES,\n  ...DERIVED_COLOR_ROLES,\n] as const;",
                "ColorRole": "/**\n * A theme palette role that a color field can be linked to. When a field is\n * linked to a role, theme edits drive it by that link instead of by matching\n * its raw hex value — so two roles that share a color stay independent.\n */\nexport type ColorRole = (typeof COLOR_ROLES)[number];",
                "ColorRefs": "/**\n * Optional links from a node's color fields (keyed by field name, e.g.\n * \"backgroundColor\", \"textColor\", \"gradient0\") to theme roles. Absent = the\n * field is a custom, unlinked color.\n */\nexport type ColorRefs = Partial<Record<string, ColorRole>>;",
                "ShadowConfig": "export interface ShadowConfig {\n  color: string; // Hex color (e.g. \"#000000\")\n  blur: number; // Blur radius 0-50\n  offsetX: number; // Horizontal offset -50 to 50\n  offsetY: number; // Vertical offset -50 to 50\n}",
                "BaseNode": "// Base interface for all editable nodes (no screenIndex - elements belong to their Screen)\nexport interface BaseNode {\n  id: string;\n  layerName?: string; // Optional user-facing name in the layer stack\n  position: Position;\n  zIndex: number;\n  overflow?: boolean; // Allow element to overflow to adjacent screens\n  rotation?: number; // Rotation in degrees\n  opacity?: number; // Transparency 0-100 (default 100 = fully opaque)\n  hidden?: boolean; // Hide in editor/export layer rendering\n  locked?: boolean; // Prevent direct canvas interaction\n  shadow?: ShadowConfig; // Drop shadow configuration\n  colorRefs?: ColorRefs; // Optional links from this node's color fields to theme roles\n}",
                "LayerGroup": "export interface LayerGroup {\n  id: string;\n  name: string;\n  screenIndex: number;\n  nodeIds: string[];\n  visible?: boolean;\n  locked?: boolean;\n  collapsed?: boolean;\n}",
                "PillNode": "// Pill (tag/button style element)\nexport interface PillNode extends BaseNode {\n  type: \"pill\";\n  richContent: TiptapJSON; // Rich text content (Tiptap format)\n  backgroundColor: string;\n  textColor: string;\n  fontSize?: number;\n  align?: \"left\" | \"center\" | \"right\";\n  lineHeight?: number;\n  width: number;\n  height: number;\n  textFit?: TextFitContract;\n  cornerRadius?: number; // Corner radius for the pill (default is fully rounded)\n  borderColor?: string; // Border color (default: white)\n  borderWidth?: number; // Border thickness in pixels (default: 0 = hidden)\n  borderOpacity?: number; // Border opacity from 0-100 (default: 100)\n  showArrow?: boolean; // Show arrow icon on the right\n}",
                "BadgeNode": "// Badge (circular seal/count)\nexport interface BadgeNode extends BaseNode {\n  type: \"badge\";\n  text: string;\n  subtext?: string;\n  size: number;\n  backgroundColor?: string;\n  textColor?: string;\n}",
                "BlobNode": "// Blob (organic decorative shape)\nexport interface BlobNode extends BaseNode {\n  type: \"blob\";\n  width: number;\n  height: number;\n  color: string;\n}",
                "RatingNode": "// Star Rating display\nexport interface RatingNode extends BaseNode {\n  type: \"rating\";\n  rating: number;\n  label?: string;\n  labelColor?: string; // Label text color\n  size?: number; // Overall size - scales label and stars proportionally\n  starsBelow?: boolean; // If true, stars appear below the label (centered)\n}",
                "LogoNode": "// Logo element\nexport interface LogoNode extends BaseNode {\n  type: \"logo\";\n  text?: string; // Text-based logo\n  path?: string; // Relative path stored in DB (e.g., \"logo/1234-logo.png\")\n  signedUrl?: string; // Added at runtime by RSC for rendering\n  imageUrl?: string; // Legacy URL field for migration compatibility\n  fontSize?: number;\n  align?: \"left\" | \"center\" | \"right\";\n  width?: number; // Optional fixed width for the logo box\n  cornerRadius?: number; // Corner radius in pixels for image logos\n}",
                "IllustrationNode": "// Illustration element (decorative images)\nexport interface IllustrationNode extends BaseNode {\n  type: \"illustration\";\n  assetKind?: IllustrationAssetKind;\n  path?: string; // Relative path stored in DB (e.g., \"illustrations/1234-ill.png\")\n  signedUrl?: string; // Added at runtime by RSC for rendering\n  imageUrl?: string; // Legacy URL field for migration compatibility\n  scale?: number; // Scale factor for resizing\n  // Logical width at scale 1. New library insertions use this to distinguish\n  // full illustrations from compact icons while legacy nodes keep the 150px\n  // SVG fallback in the renderer.\n  referenceWidth?: number;\n  width?: number;\n  height?: number;\n  rotation?: number; // Rotation in degrees\n  tiltAngle?: number; // 3D perspective tilt (-30 to 30 degrees)\n  primaryColor?: string; // Primary color for SVG illustrations (CSS filter-based tinting)\n  cornerRadius?: number; // Normalized (0–1) or legacy source-pixel radius for raster illustrations like app icons\n  anchor?: { x: number; y: number }; // Anchor point (default 0.5, 0.5 = center)\n  // Normalized crop rect (each 0–1) over the source image. Absent/full = no crop.\n  // The illustration resizes to the cropped region (see createIllustration).\n  cropRegion?: CropRegion;\n}",
                "CropRegion": "/** Normalized (0–1) crop rectangle over an illustration's source image. */\nexport interface CropRegion {\n  x: number;\n  y: number;\n  width: number;\n  height: number;\n}",
                "MagnifierNode": "// Magnifier element (zoomed screenshot region overlay)\nexport interface MagnifierNode extends BaseNode {\n  type: \"magnifier\";\n  // Source region on screenshot (relative coordinates 0-1)\n  sourceRegion: {\n    x: number; // Left position (0-1)\n    y: number; // Top position (0-1)\n    width: number; // Width (0-1)\n    height: number; // Height (0-1)\n  };\n  // Reference to which screenshot this magnifies\n  screenshotId: string;\n  // Overlay properties\n  scale: number; // Zoom scale (e.g., 2.0 = 200%)\n  cornerRadius: number; // Rounded corner radius in pixels\n  borderWidth: number; // Border thickness in pixels\n  borderColor: string; // Border color (hex)\n  shadowEnabled?: boolean; // Optional drop shadow\n}",
                "EmojiNode": "// Emoji element (decorative emoji display)\nexport interface EmojiNode extends BaseNode {\n  type: \"emoji\";\n  emoji: string; // The emoji character(s) e.g., \"🚀\", \"✨\", \"🎉\"\n  size: number; // Font size in pixels\n  rotation?: number; // Rotation in degrees\n}",
                "Screen": "// Screen interface - contains all elements for a single screen\nexport interface Screen {\n  id: string;\n  name?: string; // Optional user-facing artboard name\n  index: number; // Display order (0-based)\n  screenType: number; // Determines which render function to use (0-4, typically). Preserved on duplicate/reorder.\n  screenshots: ScreenshotNode[]; // Screenshots/phones for this screen (supports multiple)\n  texts: TextNode[]; // Text elements for this screen\n  pills?: PillNode[]; // Pills for this screen\n  badges?: BadgeNode[]; // Badges for this screen\n  blobs?: BlobNode[]; // Blobs for this screen\n  ratings?: RatingNode[]; // Ratings for this screen\n  logo?: LogoNode; // Logo for this screen\n  illustrations?: IllustrationNode[]; // Illustrations for this screen\n  magnifiers?: MagnifierNode[]; // Magnifier overlays for this screen\n  emojis?: EmojiNode[]; // Emoji elements for this screen\n  backgroundColor?: string; // Per-screen background color (overrides template default)\n  backgroundGradient?: Gradient; // Per-screen background gradient (overrides template default)\n  colorRefs?: ColorRefs; // Optional links from background/gradient colors to theme roles\n  backgroundImage?: {\n    url: string;\n    storagePath?: string; // Path in Supabase storage for deletion\n    blur?: number; // Blur intensity 0-20 (default: 0 = no blur)\n    verticalOffset?: number; // 0-100, where 50 is centered (default)\n    horizontalOffset?: number; // 0-100, where 50 is centered (default)\n    attribution?: PanoramaAttribution; // Reuse attribution type for Pexels images\n  };\n}",
                "Layout": "export interface Layout {\n  /**\n   * In format_layouts only: keep this phone size's screenshot choices while\n   * deriving the design from the main layout. Without it the format layout is\n   * an independent design. A format layout whose template differs from the\n   * main layout's is ignored (the format follows main again).\n   */\n  phoneFormatImagesOnly?: boolean;\n  template: TemplateType;\n  platform: Platform;\n  screens: Screen[]; // Screen-based structure (replaces all flat arrays)\n  headers?: HeaderNode[]; // Headers are shared across screens (if needed)\n  canvasWidth: number;\n  canvasHeight: number;\n  backgroundColor: string;\n  backgroundGradient?: Gradient;\n  colorRefs?: ColorRefs; // Optional links from layout background/gradient colors to theme roles\n  panoramaBackground?: PanoramaBackground; // Panorama image that spans across all screens\n  themeColors?: ThemeColors;\n  /** Optional generated alternatives for layouts without a palette switcher. */\n  themePaletteOptions?: ThemePaletteOptions;\n  themePaletteMode?: ThemePaletteMode;\n  orientation?: \"portrait\" | \"landscape\"; // Canvas orientation for phone/tablet layouts; undefined = portrait\n  /** Skip the inter-screen gap in overflowing artwork; undefined keeps it enabled. */\n  screenshotGap?: boolean;\n  layerGroups?: LayerGroup[]; // Optional editor-only layer grouping metadata\n}",
                "CustomFont": "export interface CustomFont {\n  name: string; // Display name (derived from filename)\n  path: string; // Relative storage path like \"fonts/1234-myfont.ttf\"\n  /** CSS weight descriptor. Legacy entries infer this from the filename. */\n  weight?: string;\n  /** CSS style descriptor. Legacy entries infer this from the filename. */\n  style?: \"normal\" | \"italic\";\n}",
                "ResolvedCustomFont": "/** A custom font file resolved to a fetchable URL for remote rendering. */\nexport interface ResolvedCustomFont {\n  name: string;\n  url: string;\n  weight: string;\n  style: \"normal\" | \"italic\";\n}",
                "QRCodeAsset": "export interface QRCodeAsset {\n  id: string;\n  label: string;\n  targetUrl: string;\n  source: \"app_store\" | \"google_play\" | \"custom\";\n  path: string;\n  signedUrl?: string;\n  foregroundColor?: string;\n  backgroundColor?: string;\n  style?: \"classic\" | \"dots\";\n  createdAt: string;\n}",
                "AppMetadata": "export interface AppMetadata {\n  appName: string;\n  category: string;\n  appDescription: string;\n  logoUrl?: string; // Runtime: signed URL for display\n  logoPath?: string; // Stored: relative path like \"logo/1234-logo.png\"\n  logoRemoved?: boolean; // Explicit removal disables legacy orphan recovery.\n  /** @deprecated Use logoPath instead */\n  logoFilename?: string;\n  platform?: Platform;\n  defaultDeviceType?: DeviceType;\n  detectedLanguage?: string;\n  screenshotCache?: {\n    mobileCount: number;\n    tabletCount: number;\n    desktopCount?: number;\n    firstMobilePath: string | null;\n  };\n  customFonts?: CustomFont[];\n  /** Google Font families explicitly added to this project's font library. */\n  googleFonts?: string[];\n  primaryStoreProvider?: \"app_store\" | \"google_play\";\n  appleAppId?: string;\n  googlePlayPackageName?: string;\n  qrCodes?: QRCodeAsset[];\n  competitorAppIds?: number[];\n  googlePlayCompetitorAppIds?: string[];\n}",
                "CopyInsights": "export interface CopyInsights {\n  promotionalText: {\n    explanation: string; // Why this promotional text is effective\n    competitorAnalysis: string; // How it compares to competitors\n    strengths: string[]; // Key strengths of this copy\n  };\n  shortDescription: {\n    explanation: string;\n    competitorAnalysis: string;\n    strengths: string[];\n  };\n  fullDescription: {\n    explanation: string;\n    competitorAnalysis: string;\n    strengths: string[];\n  };\n  keywords: {\n    explanation: string; // Why these keywords are chosen\n    searchVolume: string; // Estimated search potential\n    strengths: string[];\n  };\n}",
                "ASO_TRANSLATION_FIELDS": "export const ASO_TRANSLATION_FIELDS = [\n  \"appName\",\n  \"promotionalText\",\n  \"shortDescription\",\n  \"fullDescription\",\n  \"keywords\",\n  \"whatsNew\",\n] as const;",
                "ASOTranslationField": "export type ASOTranslationField = (typeof ASO_TRANSLATION_FIELDS)[number];",
                "ASOCopyTranslation": "export interface ASOCopyTranslation {\n  /** Optional, user-authored release notes. Never invented by ASO generation. */\n  whatsNew?: string;\n  /** Optional locale-specific store name/title. Falls back to project metadata. */\n  appName?: string;\n  promotionalText: string; // Editable promotional text\n  shortDescription: string; // 1-2 lines, primary benefit only\n  fullDescription: string; // Ready-to-paste store description with no internal section labels\n  keywords: string; // Comma-separated keywords for App Store (no spaces after commas)\n  insights: CopyInsights; // Insights explaining why the copy is effective\n}",
                "ASOCopy": "export interface ASOCopy extends ASOCopyTranslation {\n  category: string; // Suggested App Store category\n  subcategory: string; // Suggested subcategory (required)\n  competitorAppIds?: Array<number | string>; // Store IDs of analyzed competitors\n  competitorStoreProvider?: \"app_store\" | \"google_play\";\n  competitorTargets?: Array<{\n    appId: number | string;\n    storeProvider: \"app_store\" | \"google_play\";\n  }>;\n  translations?: Record<string, ASOCopyTranslation>; // Localized ASO copy keyed by language code\n  /** Per-language hash of the source copy each translation was produced from —\n   * lets the translate route skip languages whose source hasn't changed. */\n  translationSourceHashes?: Record<string, string>;\n  /** Source fingerprints for each successfully translated field and language. */\n  translationFieldSourceHashes?: Record<\n    string,\n    Partial<Record<ASOTranslationField, string>>\n  >;\n}",
                "extractTranslationCopy": "/** Extract translation-relevant fields from an ASOCopy object. */\nexport function extractTranslationCopy(asoCopy: ASOCopy): ASOCopyTranslation {\n  return {\n    ...(asoCopy.whatsNew !== undefined ? { whatsNew: asoCopy.whatsNew } : {}),\n    promotionalText: asoCopy.promotionalText,\n    shortDescription: asoCopy.shortDescription,\n    fullDescription: asoCopy.fullDescription,\n    keywords: asoCopy.keywords,\n    insights: asoCopy.insights,\n  };\n}"
            }
        },
        {
            "group": "layout",
            "path": "src/shared/validation/layout-schema.ts",
            "sha256": "58f40812d06fc519605f6bdd7d0c4fb6bb7bf9df2226cb25cd7cf8afe844478b",
            "declarations": {
                "colorSchema": "const colorSchema = z.string().min(1);",
                "positionSchema": "const positionSchema = z\n  .object({\n    x: z.number(),\n    y: z.number(),\n  })\n  .passthrough();",
                "shadowSchema": "const shadowSchema = z\n  .object({\n    color: colorSchema,\n    blur: z.number(),\n    offsetX: z.number(),\n    offsetY: z.number(),\n  })\n  .passthrough();",
                "baseNodeSchema": "const baseNodeSchema = z\n  .object({\n    id: z.string().min(1),\n    position: positionSchema,\n    zIndex: z.number().int(),\n    overflow: z.boolean().optional(),\n    rotation: z.number().optional(),\n    opacity: z.number().optional(),\n    hidden: z.boolean().optional(),\n    locked: z.boolean().optional(),\n    shadow: shadowSchema.optional(),\n    colorRefs: z.record(z.string(), z.string()).optional(),\n  })\n  .passthrough();",
                "tiptapMarkSchema": "const tiptapMarkSchema = z\n  .object({\n    type: z.string().min(1),\n    attrs: z.record(z.string(), z.unknown()).optional(),\n  })\n  .passthrough();",
                "tiptapInlineNodeSchema": "const tiptapInlineNodeSchema: z.ZodType<Record<string, unknown>> = z\n  .object({\n    type: z.string().min(1),\n    text: z.string().optional(),\n    marks: z.array(tiptapMarkSchema).optional(),\n  })\n  .passthrough();",
                "tiptapBlockNodeSchema": "const tiptapBlockNodeSchema: z.ZodType<Record<string, unknown>> = z\n  .object({\n    type: z.string().min(1),\n    content: z.array(tiptapInlineNodeSchema).optional(),\n  })\n  .passthrough();",
                "tiptapJSONSchema": "export const tiptapJSONSchema: z.ZodType<TiptapJSON> = z\n  .object({\n    type: z.literal(\"doc\"),\n    content: z.array(tiptapBlockNodeSchema),\n    attrs: z.record(z.string(), z.unknown()).optional(),\n  })\n  .passthrough() as unknown as z.ZodType<TiptapJSON>;",
                "screenshotNodeSchema": "const screenshotNodeSchema = baseNodeSchema\n  .extend({\n    path: z.string().optional(),\n    signedUrl: z.string().optional(),\n    iosPath: z.string().optional(),\n    iosSignedUrl: z.string().optional(),\n    androidPath: z.string().optional(),\n    androidSignedUrl: z.string().optional(),\n    scale: z.number().positive(),\n    rotation: z.number(),\n    tiltAngle: z.number().optional(),\n    phoneId: z.string().optional(),\n    tabletId: z.string().optional(),\n    desktopId: z.string().optional(),\n    watchId: z.string().optional(),\n    variant3D: z.string().optional(),\n    fitMode: z.enum([\"stretch\", \"cover\", \"contain\"]).optional(),\n    showCamera: z.boolean().optional(),\n    cleanStatusBar: z.boolean().optional(),\n    contentRotation: z\n      .union([z.literal(0), z.literal(90), z.literal(180), z.literal(270)])\n      .optional(),\n    contentOrientation: z\n      .object({\n        version: z.literal(1),\n        initialTilt: z.number(),\n        fit: z.enum([\"auto\", \"manual\"]),\n      })\n      .optional(),\n    windowTitle: z.string().optional(),\n    model3D: z.record(z.string(), z.unknown()).optional(),\n  })\n  .passthrough();",
                "textNodeSchema": "const textNodeSchema = baseNodeSchema\n  .extend({\n    richContent: tiptapJSONSchema,\n    align: z.enum([\"left\", \"center\", \"right\"]).optional(),\n    type: z.enum([\"title\", \"subtitle\", \"body\", \"brands\"]).optional(),\n    lineHeight: z.number().optional(),\n    width: z.number().optional(),\n    backgroundColor: colorSchema.optional(),\n    backgroundPaddingX: z.number().optional(),\n    backgroundPaddingY: z.number().optional(),\n    backgroundCornerRadius: z.number().optional(),\n  })\n  .passthrough();",
                "pillNodeSchema": "const pillNodeSchema = baseNodeSchema\n  .extend({\n    type: z.literal(\"pill\").optional(),\n    richContent: tiptapJSONSchema,\n    backgroundColor: colorSchema,\n    textColor: colorSchema,\n    width: z.number(),\n    height: z.number(),\n    borderColor: colorSchema.optional(),\n    borderWidth: z.number().min(0).optional(),\n    borderOpacity: z.number().min(0).max(100).optional(),\n  })\n  .passthrough();",
                "badgeNodeSchema": "const badgeNodeSchema = baseNodeSchema\n  .extend({\n    type: z.literal(\"badge\").optional(),\n    text: z.string(),\n    subtext: z.string().optional(),\n    size: z.number(),\n  })\n  .passthrough();",
                "simpleNodeSchema": "const simpleNodeSchema = baseNodeSchema.passthrough();",
                "headerNodeSchema": "const headerNodeSchema = z\n  .object({\n    id: z.string().min(1),\n    position: positionSchema,\n    width: z.number().positive(),\n    height: z.number().positive(),\n    backgroundColor: colorSchema,\n    zIndex: z.number().int(),\n  })\n  .passthrough();",
                "gradientSchema": "const gradientSchema = z\n  .object({\n    type: z.enum([\"linear\", \"radial\"]),\n    colors: z.array(colorSchema).min(2),\n    direction: z.number().optional(),\n  })\n  .passthrough();",
                "backgroundImageSchema": "const backgroundImageSchema = z\n  .object({\n    url: z.string(),\n    storagePath: z.string().optional(),\n    blur: z.number().optional(),\n    verticalOffset: z.number().optional(),\n    horizontalOffset: z.number().optional(),\n    attribution: z.record(z.string(), z.unknown()).optional(),\n  })\n  .passthrough();",
                "screenSchema": "export const screenSchema = z\n  .object({\n    id: z.string().min(1),\n    index: z.number().int(),\n    screenType: z.number().int(),\n    screenshots: z.array(screenshotNodeSchema),\n    texts: z.array(textNodeSchema),\n    pills: z.array(pillNodeSchema).optional(),\n    badges: z.array(badgeNodeSchema).optional(),\n    blobs: z.array(simpleNodeSchema).optional(),\n    ratings: z.array(simpleNodeSchema).optional(),\n    logo: simpleNodeSchema.optional(),\n    illustrations: z.array(simpleNodeSchema).optional(),\n    magnifiers: z.array(simpleNodeSchema).optional(),\n    emojis: z.array(simpleNodeSchema).optional(),\n    backgroundColor: colorSchema.optional(),\n    backgroundGradient: gradientSchema.optional(),\n    colorRefs: z.record(z.string(), z.string()).optional(),\n    backgroundImage: backgroundImageSchema.optional(),\n  })\n  .passthrough();",
                "layoutSchema": "export const layoutSchema: z.ZodType<Layout> = z\n  .object({\n    template: z.string(),\n    platform: appPlatformSchema,\n    screens: z.array(screenSchema),\n    headers: z.array(headerNodeSchema).optional(),\n    canvasWidth: z.number().int().positive(),\n    canvasHeight: z.number().int().positive(),\n    backgroundColor: colorSchema,\n    backgroundGradient: gradientSchema.optional(),\n    colorRefs: z.record(z.string(), z.string()).optional(),\n    panoramaBackground: z.record(z.string(), z.unknown()).optional(),\n    themeColors: z\n      .object({\n        primary: colorSchema,\n        secondary: colorSchema,\n        // Layouts saved before the accent requirement (pre Jul 2026 template\n        // refactor) have no accent — accept them and fall back to primary,\n        // instead of failing every save of a legacy project with a 500.\n        accent: colorSchema.optional(),\n        background: colorSchema.optional(),\n        text: colorSchema,\n        textSecondary: colorSchema,\n      })\n      .passthrough()\n      .transform((themeColors) => ({\n        ...themeColors,\n        accent: themeColors.accent ?? themeColors.primary,\n      }))\n      .optional(),\n    screenshotGap: z.boolean().optional(),\n    phoneFormatImagesOnly: z.boolean().optional(),\n    orientation: z.enum([\"portrait\", \"landscape\"]).optional(),\n    layerGroups: z.array(z.record(z.string(), z.unknown())).optional(),\n  })\n  .passthrough() as z.ZodType<Layout>;"
            }
        },
        {
            "group": "layout",
            "path": "src/features/editor-export/phoneFormats.ts",
            "sha256": "719cd0e97f8c3cd55394c06ea429f95bb20ead2765b69bba024603db80abb107",
            "declarations": {
                "PHONE_FORMAT_IDS": "/**\n * Phone screenshot formats: every App Store / Google Play size the phone layout\n * is exported at, previewable and editable one by one.\n *\n * `main` is the layout the editor has always had (the `mobile_layout` column).\n * Every other format starts out derived from it and only gets a layout of its\n * own (`translations.format_layouts[id]`) once its design is edited. Replacing\n * screenshot images shares normal phone content; Duo inner/outer keep their\n * own images with `phoneFormatImagesOnly` while their design stays derived.\n * Until then it follows the main layout, so a project that never touches the\n * picker exports exactly as before.\n */\nexport const PHONE_FORMAT_IDS = [\"main\", ...CUSTOM_PHONE_FORMAT_IDS] as const;",
                "PhoneFormatId": "export type PhoneFormatId = (typeof PHONE_FORMAT_IDS)[number];",
                "CustomPhoneFormatId": "/** The formats that can hold a layout of their own. */\nexport type CustomPhoneFormatId = Exclude<PhoneFormatId, \"main\">;",
                "PhoneFormatLayouts": "export type PhoneFormatLayouts = Partial<Record<CustomPhoneFormatId, Layout>>;",
                "PhoneFormatKind": "type PhoneFormatKind = \"main\" | \"scaled\" | \"duo\";",
                "PhoneFormat": "export interface PhoneFormat {\n  id: PhoneFormatId;\n  platform: \"ios\" | \"android\";\n  label: string;\n  /** Toolbar trigger text, kept to a word or two. */\n  shortLabel: string;\n  /** Devices the size covers, for the picker's secondary line. */\n  devices: string;\n  width: number;\n  height: number;\n  kind: PhoneFormatKind;\n  /** Duo formats: the fold the device is shown at (1 open, 0 closed). */\n  lidOpen?: number;\n}",
                "ios": "const ios = (size: IosDisplaySize) => iosDisplaySizes[size];",
                "duo": "const duo = (size: IphoneDuoDisplaySize) => iphoneDuoDisplaySizes[size];",
                "android": "const android = (size: AndroidDisplaySize) => androidDisplaySizes[size];",
                "PHONE_FORMATS": "/** Picker order: iOS sizes largest first, then the Duo, then Android. */\nexport const PHONE_FORMATS: readonly PhoneFormat[] = [\n  {\n    id: \"main\",\n    platform: \"ios\",\n    label: '6.9\" Display',\n    shortLabel: \"6.9″\",\n    devices: \"iPhone 18 Pro Max, Air, 17 Pro Max\",\n    ...dims(ios(\"6.9\")),\n    kind: \"main\",\n  },\n  {\n    id: \"ios-6.5\",\n    platform: \"ios\",\n    label: '6.5\" Display',\n    shortLabel: \"6.5″\",\n    devices: \"iPhone 14 Plus, 13 Pro Max\",\n    ...dims(ios(\"6.5\")),\n    kind: \"scaled\",\n  },\n  {\n    id: \"ios-6.3\",\n    platform: \"ios\",\n    label: '6.3\" Display',\n    shortLabel: \"6.3″\",\n    devices: \"iPhone 18 Pro, 17 Pro, 16 Pro\",\n    ...dims(ios(\"6.3\")),\n    kind: \"scaled\",\n  },\n  {\n    id: \"ios-6.1\",\n    platform: \"ios\",\n    label: '6.1\" Display',\n    shortLabel: \"6.1″\",\n    devices: \"iPhone 16, 15, 14\",\n    ...dims(ios(\"6.1\")),\n    kind: \"scaled\",\n  },\n  {\n    id: \"ios-duo-inner\",\n    platform: \"ios\",\n    label: \"iPhone Duo · Open\",\n    shortLabel: \"Duo Open\",\n    devices: '7.6\" inner display',\n    ...dims(duo(\"duo-inner\")),\n    kind: \"duo\",\n    lidOpen: 1,\n  },\n  {\n    id: \"ios-duo-outer\",\n    platform: \"ios\",\n    label: \"iPhone Duo · Closed\",\n    shortLabel: \"Duo Closed\",\n    devices: '5.4\" outer display',\n    ...dims(duo(\"duo-outer\")),\n    kind: \"duo\",\n    lidOpen: 0,\n  },\n  {\n    id: \"main\",\n    platform: \"android\",\n    label: \"Phone\",\n    shortLabel: \"Phone\",\n    devices: \"Main layout\",\n    ...dims(android(\"preview\")),\n    kind: \"main\",\n  },\n  {\n    id: \"android-play-high\",\n    platform: \"android\",\n    label: \"Google Play 9:16\",\n    shortLabel: \"Play 9:16\",\n    devices: \"High resolution\",\n    ...dims(android(\"play-high\")),\n    kind: \"scaled\",\n  },\n];",
                "dims": "function dims(size: { width: number; height: number }) {\n  return { width: size.width, height: size.height };\n}",
                "isPhoneFormatId": "export function isPhoneFormatId(value: unknown): value is PhoneFormatId {\n  return (\n    typeof value === \"string\" &&\n    (PHONE_FORMAT_IDS as readonly string[]).includes(value)\n  );\n}",
                "isCustomPhoneFormatId": "function isCustomPhoneFormatId(value: unknown): value is CustomPhoneFormatId {\n  return value !== \"main\" && isPhoneFormatId(value);\n}",
                "getPhoneFormat": "export function getPhoneFormat(\n  id: PhoneFormatId,\n  platform: \"ios\" | \"android\",\n): PhoneFormat | null {\n  return (\n    PHONE_FORMATS.find(\n      (format) => format.id === id && format.platform === platform,\n    ) ?? null\n  );\n}",
                "phoneFormatForExportSize": "/** The format an export preset renders from, or null for \"the main layout\". */\nexport function phoneFormatForExportSize(\n  platform: \"ios\" | \"android\",\n  sizeId: string,\n): CustomPhoneFormatId | null {\n  if (platform === \"ios\") {\n    if (sizeId === \"duo-inner\") return \"ios-duo-inner\";\n    if (sizeId === \"duo-outer\") return \"ios-duo-outer\";\n    if (sizeId === \"6.5\" || sizeId === \"6.3\" || sizeId === \"6.1\") {\n      return `ios-${sizeId}`;\n    }\n    return null;\n  }\n  return sizeId === \"play-high\" ? \"android-play-high\" : null;\n}",
                "sanitizePhoneFormatLayouts": "/** Parse the stored map, dropping unknown keys and malformed entries. */\nexport function sanitizePhoneFormatLayouts(value: unknown): PhoneFormatLayouts {\n  if (!value || typeof value !== \"object\" || Array.isArray(value)) return {};\n  const result: PhoneFormatLayouts = {};\n  for (const [key, layout] of Object.entries(value)) {\n    if (!isCustomPhoneFormatId(key)) continue;\n    if (!layout || typeof layout !== \"object\") continue;\n    if (!Array.isArray((layout as Layout).screens)) continue;\n    result[key] = layout as Layout;\n  }\n  return result;\n}",
                "isPhoneFormatLayoutCurrent": "/** A custom layout made under another template no longer applies. */\nfunction isPhoneFormatLayoutCurrent(\n  custom: Layout | undefined,\n  main: Layout,\n): custom is Layout {\n  return (\n    Boolean(custom) &&\n    (custom!.template || \"default\") === (main.template || \"default\")\n  );\n}",
                "hasCustomPhoneFormatLayout": "/** Image choices alone do not make a format's design independent. */\nexport function hasCustomPhoneFormatLayout(\n  custom: Layout | undefined,\n  main: Layout,\n): custom is Layout {\n  return (\n    isPhoneFormatLayoutCurrent(custom, main) && !custom.phoneFormatImagesOnly\n  );\n}",
                "resolvePhoneFormatLayout": "/**\n * The layout a format shows: its own if it has a current one, otherwise the\n * main layout carried over to the format's canvas.\n */\nexport function resolvePhoneFormatLayout(\n  main: Layout,\n  formatLayouts: PhoneFormatLayouts | null | undefined,\n  formatId: PhoneFormatId,\n  platform: \"ios\" | \"android\",\n): Layout {\n  if (formatId === \"main\") return main;\n  const custom = formatLayouts?.[formatId];\n  if (isPhoneFormatLayoutCurrent(custom, main)) {\n    return custom.phoneFormatImagesOnly\n      ? applyPhoneFormatImages(\n          derivePhoneFormatLayout(main, formatId, platform),\n          custom,\n        )\n      : custom;\n  }\n  return derivePhoneFormatLayout(main, formatId, platform);\n}",
                "derivePhoneFormatLayout": "/** The main layout carried over to a format's canvas (no custom layout). */\nexport function derivePhoneFormatLayout(\n  main: Layout,\n  formatId: PhoneFormatId,\n  platform: \"ios\" | \"android\",\n): Layout {\n  const format = getPhoneFormat(formatId, platform);\n  if (!format || format.kind === \"main\") return main;\n  const isLandscape = (main.orientation ?? \"portrait\") === \"landscape\";\n  const width = isLandscape ? format.height : format.width;\n  const height = isLandscape ? format.width : format.height;\n  if (format.kind === \"duo\") {\n    return deriveIphoneDuoLayout(main, {\n      width,\n      height,\n      lidOpen: format.lidOpen ?? 1,\n    });\n  }\n  return (\n    syncLayoutDevicesToPlatform(\n      scaleLayoutToTarget(main, width, height, { platform }),\n      platform,\n    ) ?? main\n  );\n}",
                "phoneVisibleHeight": "/**\n * How tall a phone device is drawn, in canvas pixels. 2D frames and 3D models\n * have different natural bounds at the same `scale`, so measure each by its own.\n * Seeded rather than measured bounds: this runs on the export server too, and\n * a derived layout has to come out the same there as in the editor.\n */\nfunction phoneVisibleHeight(\n  screenshot: ScreenshotNode,\n  canvasWidth: number,\n): number | null {\n  const modelId = screenshot.model3D?.modelId;\n  if (screenshot.model3D) {\n    const bounds = getSeededModel3DFrameBounds(\n      modelId ?? \"iphone-17-pro\",\n      canvasWidth,\n    );\n    return bounds.height * screenshot.scale;\n  }\n  const frame = get2DFrameBounds(screenshot);\n  return frame ? frame.height * screenshot.scale : null;\n}",
                "PHONE_BODY_PER_SCREEN": "/** Phone body height per screen height, from the flat frame (2708 / 2627). */\nconst PHONE_BODY_PER_SCREEN = 1.03;",
                "phoneScreenInFrame": "/**\n * Where a 2D frame's phone actually is: its height and its centre's offset\n * from the frame image's centre, in frame pixels at scale 1. Null for 3D\n * models and frames without mapped screen corners.\n */\nfunction phoneScreenInFrame(node: ScreenshotNode): {\n  phoneHeight: number;\n  screenWidth: number;\n  offset: { x: number; y: number };\n} | null {\n  if (node.model3D) return null;\n  const variant: PhoneVariant3D =\n    node.variant3D === \"none\" ? \"flat\" : (node.variant3D ?? \"flat\");\n  const frame = get2DFrameBounds(node);\n  const corners = getPhoneSpec(node.phoneId || PhoneId.iPhone17ProMax)\n    ?.variants3D?.[variant]?.screenCorners3D;\n  if (!frame || !corners) return null;\n  const { topLeft, topRight, bottomLeft, bottomRight } = corners;\n  const edge = (a: { x: number; y: number }, b: { x: number; y: number }) =>\n    Math.hypot(a.x - b.x, a.y - b.y);\n  const screenHeight =\n    (edge(topLeft, bottomLeft) + edge(topRight, bottomRight)) / 2;\n  return {\n    phoneHeight: screenHeight * PHONE_BODY_PER_SCREEN,\n    screenWidth: (edge(topLeft, topRight) + edge(bottomLeft, bottomRight)) / 2,\n    offset: {\n      x:\n        (topLeft.x + topRight.x + bottomLeft.x + bottomRight.x) / 4 -\n        frame.width / 2,\n      y:\n        (topLeft.y + topRight.y + bottomLeft.y + bottomRight.y) / 4 -\n        frame.height / 2,\n    },\n  };\n}",
                "duoNaturalHeight": "/**\n * The Duo's on-canvas height at layout scale 1. The silhouette is recorded\n * open and square to the camera (landscape); closed it is as tall and about\n * half as wide, and posed open-portrait its height is the open width.\n */\nfunction duoNaturalHeight(canvasWidth: number, portraitOpen: boolean): number {\n  const open = getSeededModel3DFrameBounds(\"iphone-duo\", canvasWidth);\n  return portraitOpen ? open.width : open.height;\n}",
                "DUO_FINISHES": "const DUO_FINISHES = getFinishKeysForDeviceModel(\"iphone-duo\");",
                "DUO_OPEN_MAX_HEIGHT_SHARE": "// In seeded-silhouette units, which run ~10% under what the renderer draws\n// (the phone seeds were hand-tuned small), so 0.6 lands near two thirds.\nconst DUO_OPEN_MAX_HEIGHT_SHARE = 0.8;",
                "DUO_CLOSED_MAX_HEIGHT_SHARE": "const DUO_CLOSED_MAX_HEIGHT_SHARE = 0.6;",
                "DUO_INNER_SCREEN": "/** Display / body fractions of the Duo GLB (inner 15.78 x 11.04 of 16.47 x 11.72; cover 7.73 x 11.18 of 8.67 x 11.72). */\nconst DUO_INNER_SCREEN = { long: 15.78 / 16.47, short: 11.04 / 11.72 };",
                "DUO_COVER_SCREEN": "const DUO_COVER_SCREEN = { width: 7.73 / 8.67, height: 11.18 / 11.72 };",
                "DUO_CLOSED_ASPECT": "const DUO_CLOSED_ASPECT = 8.67 / 11.72;",
                "duoScreenSize": "/**\n * The Duo's display on the canvas at a layout scale, in view orientation:\n * unfolded it is posed portrait (the landscape panel turned a quarter),\n * folded it shows the portrait cover display.\n */\nfunction duoScreenSize(\n  canvasWidth: number,\n  open: boolean,\n  scale: number,\n): { width: number; height: number } {\n  const bounds = getSeededModel3DFrameBounds(\"iphone-duo\", canvasWidth);\n  if (open) {\n    return {\n      width: bounds.height * DUO_INNER_SCREEN.short * scale,\n      height: bounds.width * DUO_INNER_SCREEN.long * scale,\n    };\n  }\n  return {\n    width: bounds.height * DUO_CLOSED_ASPECT * DUO_COVER_SCREEN.width * scale,\n    height: bounds.height * DUO_COVER_SCREEN.height * scale,\n  };\n}",
                "phoneScreenSize": "/** A phone's display on the canvas, in layout pixels. */\nfunction phoneScreenSize(\n  node: ScreenshotNode,\n  canvasWidth: number,\n): { width: number; height: number } | null {\n  if (node.model3D) {\n    const modelId = node.model3D.modelId ?? \"iphone-17-pro\";\n    const bounds = getSeededModel3DFrameBounds(modelId, canvasWidth);\n    const insets = DEVICE_MODEL_SPECS[modelId].screenInsets;\n    return {\n      width: bounds.width * insets.width * node.scale,\n      height: bounds.height * insets.height * node.scale,\n    };\n  }\n  const screen = phoneScreenInFrame(node);\n  if (screen) {\n    return {\n      width: screen.screenWidth * node.scale,\n      height: (screen.phoneHeight / PHONE_BODY_PER_SCREEN) * node.scale,\n    };\n  }\n  const frame = get2DFrameBounds(node);\n  return frame\n    ? {\n        width: frame.width * 0.9 * node.scale,\n        height: frame.height * 0.9 * node.scale,\n      }\n    : null;\n}",
                "DuoScreenMapping": "/**\n * How a phone's display maps onto the Duo that replaces it: where each sat,\n * and how much the display grew on each axis (the Duo's displays are wider\n * for their height, and the screenshot stretches to them).\n */\ninterface DuoScreenMapping {\n  phoneCenter: { x: number; y: number };\n  duoCenter: { x: number; y: number };\n  scaleX: number;\n  scaleY: number;\n  tilt: number;\n}",
                "remapMagnifier": "/**\n * Magnifiers enlarge a region of a phone's screenshot. Their size is in\n * screenshot pixels and their place is relative to the phone, so carrying\n * them over like free elements leaves them phone-sized and off the content.\n * Re-anchor each to its Duo: same offset from the display centre, scaled by\n * how the display grew, through the device's tilt.\n */\nfunction remapMagnifier(\n  magnifier: MagnifierNode,\n  mapping: DuoScreenMapping,\n): MagnifierNode {\n  const { tilt } = mapping;\n  const dx = magnifier.position.x - mapping.phoneCenter.x;\n  const dy = magnifier.position.y - mapping.phoneCenter.y;\n  // Into the device's frame, stretch, and back out.\n  const lx = dx * Math.cos(-tilt) - dy * Math.sin(-tilt);\n  const ly = dx * Math.sin(-tilt) + dy * Math.cos(-tilt);\n  const sx = lx * mapping.scaleX;\n  const sy = ly * mapping.scaleY;\n  const zoom = mapping.scaleY;\n  return {\n    ...magnifier,\n    position: {\n      x: mapping.duoCenter.x + sx * Math.cos(tilt) - sy * Math.sin(tilt),\n      y: mapping.duoCenter.y + sx * Math.sin(tilt) + sy * Math.cos(tilt),\n    },\n    scale: magnifier.scale * zoom,\n    borderWidth: magnifier.borderWidth * zoom,\n    cornerRadius: magnifier.cornerRadius * zoom,\n  };\n}",
                "duoFinishFor": "function duoFinishFor(\n  model: Model3DConfig | undefined,\n): Model3DConfig[\"finish\"] {\n  const remembered = model?.devicesByPlatform?.ios;\n  if (\n    remembered?.modelId === \"iphone-duo\" &&\n    DUO_FINISHES.includes(remembered.finish)\n  ) {\n    return remembered.finish;\n  }\n  if (model && DUO_FINISHES.includes(model.finish)) return model.finish;\n  // Match the house look: light iPhone finishes get the light Duo.\n  const light = model?.finish === \"silver\" || model?.finish === \"glacier\";\n  return light\n    ? \"duo-star-white\"\n    : getDefaultFinishForDeviceModel(\"iphone-duo\");\n}",
                "Positioned": "type Positioned = { position: { x: number; y: number } };",
                "placeScreen": "/** Moves every positioned node of a uniformly scaled screen with `place`. */\nfunction placeScreen(\n  original: Layout[\"screens\"][number],\n  scaled: Layout[\"screens\"][number],\n  place: (position: { x: number; y: number }) => { x: number; y: number },\n): Layout[\"screens\"][number] {\n  const move = <T extends Positioned>(nodes: T[] | undefined, from?: T[]) =>\n    nodes?.map((node, index) => ({\n      ...node,\n      position: place((from ?? nodes)[index]!.position),\n    }));\n  return {\n    ...scaled,\n    screenshots: move(scaled.screenshots, original.screenshots)!,\n    texts: move(scaled.texts, original.texts)!,\n    pills: move(scaled.pills, original.pills),\n    badges: move(scaled.badges, original.badges),\n    ratings: move(scaled.ratings, original.ratings),\n    illustrations: move(scaled.illustrations, original.illustrations),\n    magnifiers: move(scaled.magnifiers, original.magnifiers),\n    logo:\n      scaled.logo && original.logo\n        ? { ...scaled.logo, position: place(original.logo.position) }\n        : scaled.logo,\n  };\n}",
                "deriveIphoneDuoLayout": "/**\n * The main layout re-staged for the iPhone Duo: the canvas takes the Duo's App\n * Store size, everything else is carried over the way the iPad layout is, and\n * every phone becomes a 3D Duo — unfolded and turned portrait for the inner\n * display, folded for the outer one — kept at the share of the canvas height\n * the phone had.\n */\nfunction deriveIphoneDuoLayout(\n  main: Layout,\n  target: { width: number; height: number; lidOpen: number },\n): Layout {\n  const baseWidth = main.canvasWidth || 1290;\n  const baseHeight = main.canvasHeight || 2796;\n  const scaleX = target.width / baseWidth;\n  const scaleY = target.height / baseHeight;\n  // The Duo canvases have another aspect than a phone's (the inner one is\n  // 1.56x as wide, the outer one 73% as tall). Stretching positions per axis\n  // while sizes follow one factor pulled compositions apart: a grid of\n  // widgets spread out with gaps between the cards. So each screen's\n  // composition is scaled as a whole by the tighter axis and centred on the\n  // new canvas, keeping every element where it was relative to the others.\n  const fontScale = Math.min(scaleX, scaleY);\n  const marginX = target.width - baseWidth * fontScale;\n  const marginY = (target.height - baseHeight * fontScale) / 2;\n  // Positions are local to the owning screen but may lie on a neighbour\n  // (overflow); centre each on the screen it is drawn over.\n  const placeX = (x: number) =>\n    x * fontScale + (Math.floor(x / baseWidth) + 0.5) * marginX;\n  const place = (position: { x: number; y: number }) => ({\n    x: placeX(position.x),\n    y: position.y * fontScale + marginY,\n  });\n  const open = target.lidOpen >= 0.5;\n\n  const toDuo = (\n    original: ScreenshotNode,\n    scaled: ScreenshotNode,\n    mappings: Map<string, DuoScreenMapping>,\n  ): ScreenshotNode => {\n    // Tablets, laptops and watches stay what they are, sized like the type.\n    if (original.tabletId || original.desktopId || original.watchId) {\n      return { ...scaled, scale: original.scale * fontScale };\n    }\n    // An authored Duo is already a composition, including deliberately mixed\n    // open/closed devices. Carry it over without re-posing or re-fitting it.\n    if (original.model3D?.modelId === \"iphone-duo\") {\n      return {\n        ...scaled,\n        variant3D: original.variant3D,\n        model3D: { ...original.model3D },\n        scale: original.scale * (fontScale / scaleX),\n      };\n    }\n    // A frame image can hold more than the phone (a hand, perspective), so\n    // size and place the Duo by the phone's own screen where it is known.\n    const screen = phoneScreenInFrame(original);\n    const phoneHeight = screen\n      ? screen.phoneHeight * original.scale\n      : phoneVisibleHeight(original, baseWidth);\n    // The node's position is the frame image's centre; the phone may sit off\n    // it (high-left in the handheld art). Move to the phone, through the\n    // frame's tilt, then onto the target canvas like every other position.\n    const tilt = ((original.tiltAngle ?? 0) * Math.PI) / 180;\n    const position = screen\n      ? place({\n          x:\n            original.position.x +\n            (screen.offset.x * Math.cos(tilt) -\n              screen.offset.y * Math.sin(tilt)) *\n              original.scale,\n          y:\n            original.position.y +\n            (screen.offset.x * Math.sin(tilt) +\n              screen.offset.y * Math.cos(tilt)) *\n              original.scale,\n        })\n      : scaled.position;\n    const naturalHeight = duoNaturalHeight(target.width, open);\n    // The phone's share of the canvas height, carried over. Folded, the Duo\n    // is far wider for its height than a phone, so it stops short of the\n    // canvas edges sooner.\n    const share = phoneHeight\n      ? Math.min(\n          open ? DUO_OPEN_MAX_HEIGHT_SHARE : DUO_CLOSED_MAX_HEIGHT_SHARE,\n          phoneHeight / baseHeight,\n        )\n      : 0;\n    const scale =\n      share > 0 && naturalHeight > 0\n        ? (share * target.height) / naturalHeight\n        : scaled.scale;\n    const duoScale = Math.min(5, Math.max(0.1, scale));\n    const phoneScreen = phoneScreenSize(original, baseWidth);\n    if (phoneScreen && phoneScreen.width > 0 && phoneScreen.height > 0) {\n      const duoScreen = duoScreenSize(target.width, open, duoScale);\n      const offset = screen?.offset ?? { x: 0, y: 0 };\n      mappings.set(original.id, {\n        phoneCenter: {\n          x:\n            original.position.x +\n            (offset.x * Math.cos(tilt) - offset.y * Math.sin(tilt)) *\n              original.scale,\n          y:\n            original.position.y +\n            (offset.x * Math.sin(tilt) + offset.y * Math.cos(tilt)) *\n              original.scale,\n        },\n        duoCenter: position,\n        scaleX: duoScreen.width / phoneScreen.width,\n        scaleY: duoScreen.height / phoneScreen.height,\n        tilt,\n      });\n    }\n    const model = original.model3D;\n    const finish = duoFinishFor(model);\n    return {\n      ...scaled,\n      position,\n      // Clamped to the editor's 3D scale range (500%).\n      scale: duoScale,\n      // Variant perspectives are 2D-frame art. The tilt is kept: it is the\n      // node's roll on the canvas for 3D models too.\n      variant3D: undefined,\n      model3D: {\n        ...(model ?? {}),\n        modelId: \"iphone-duo\",\n        finish,\n        rotationX: model?.rotationX ?? 0,\n        rotationY: model?.rotationY ?? 0,\n        // Ordinary phone frames carry their authored canvas tilt. Preserve\n        // that conversion; already-authored Duo nodes bypass it above.\n        rotationZ: (model?.rotationZ ?? 0) - (open ? Math.PI / 2 : 0),\n        lidOpen: target.lidOpen,\n        devicesByPlatform: {\n          ...model?.devicesByPlatform,\n          ios: { modelId: \"iphone-duo\", finish },\n        },\n      },\n    };\n  };\n\n  return {\n    ...main,\n    platform: \"ios\",\n    canvasWidth: target.width,\n    canvasHeight: target.height,\n    screens: main.screens.map((screen) => {\n      const uniform = scaleLayoutScreen(screen, {\n        scaleX: fontScale,\n        scaleY: fontScale,\n        fontScale,\n        screenshotScale: 1,\n        illustrationScale: 1,\n        screenshotDeviceUpdates: {},\n        platform: \"ios\",\n      });\n      const scaled = placeScreen(screen, uniform, place);\n      const mappings = new Map<string, DuoScreenMapping>();\n      const screenshots = scaled.screenshots.map((node, index) =>\n        toDuo(screen.screenshots[index]!, node, mappings),\n      );\n      // scaleLayoutScreen's illustrationScale is an absolute override (the\n      // iPad templates pin authored sizes with it), so passing it through set\n      // every illustration to 1: a 0.5 widget doubled, a 2.27 photo shrank.\n      // Scale each by the type's factor instead; its width, height and\n      // reference width stay, since the drawn size is those times the scale.\n      const illustrations = screen.illustrations?.map(\n        (illustration, index) => ({\n          ...scaled.illustrations![index]!,\n          scale: (illustration.scale || 1) * fontScale,\n          width: illustration.width,\n          height: illustration.height,\n        }),\n      );\n      // A magnifier without a (still present) link reads the screen's first\n      // screenshot, as the renderer does.\n      const fallback = screen.screenshots[0]?.id;\n      return {\n        ...scaled,\n        screenshots,\n        ...(illustrations && { illustrations }),\n        ...(screen.magnifiers && {\n          magnifiers: screen.magnifiers.map((magnifier, index) => {\n            const mapping =\n              mappings.get(magnifier.screenshotId) ??\n              (fallback ? mappings.get(fallback) : undefined);\n            return mapping\n              ? remapMagnifier(magnifier, mapping)\n              : scaled.magnifiers![index]!;\n          }),\n        }),\n      };\n    }),\n  };\n}"
            }
        },
        {
            "group": "layout",
            "path": "src/shared/utils/screenshotOrientation.ts",
            "sha256": "0e7f78cdb68fbc9e7d67d0478ee1fba56ff2df7cd5cc76f5c6c05ab19e6a4ff7",
            "declarations": {
                "nearestQuarterTurn": "/** Nearest quarter-turn, with half turns rounded away from zero on both sides. */\nexport function nearestQuarterTurn(degrees: number): number {\n  return Math.sign(degrees) * Math.round(Math.abs(degrees) / 90);\n}",
                "OrientedScreenshot": "type OrientedScreenshot = Pick<\n  ScreenshotNode,\n  \"tiltAngle\" | \"contentOrientation\"\n>;",
                "getScreenshotTiltQuarterTurns": "/** Old saved layouts retain their authored mapping until an orientation edit. */\nexport function getScreenshotTiltQuarterTurns(\n  screenshot: OrientedScreenshot,\n): number {\n  if (screenshot.contentOrientation?.version !== 1) return 0;\n  return nearestQuarterTurn(\n    (screenshot.tiltAngle ?? 0) - screenshot.contentOrientation.initialTilt,\n  );\n}",
                "getScreenshotEffectiveFitMode": "/** Contain is a reversible orientation default, never a rewrite of authored fit. */\nexport function getScreenshotEffectiveFitMode(\n  screenshot: OrientedScreenshot & Pick<ScreenshotNode, \"fitMode\">,\n): ScreenshotFitMode {\n  const authored = screenshot.fitMode ?? \"stretch\";\n  return authored === \"stretch\" &&\n    screenshot.contentOrientation?.fit === \"auto\" &&\n    Math.abs(getScreenshotTiltQuarterTurns(screenshot) % 2) === 1\n    ? \"contain\"\n    : authored;\n}",
                "getScreenshotFitUpdates": "/** An explicit fit selection takes precedence over future orientation defaults. */\nexport function getScreenshotFitUpdates(\n  screenshot: OrientedScreenshot,\n  fitMode: ScreenshotFitMode,\n): Partial<ScreenshotNode> {\n  return {\n    fitMode,\n    contentOrientation: {\n      ...(screenshot.contentOrientation ?? {\n        version: 1,\n        initialTilt: screenshot.tiltAngle ?? 0,\n      }),\n      fit: \"manual\",\n    },\n  };\n}"
            }
        },
        {
            "group": "layout",
            "path": "src/features/shared-editor/lib/layout/fontFamily.ts",
            "sha256": "a5f70723490e988b447951af596c413274a7eecb5fcd2c91d0e5833e5779c86f",
            "declarations": {
                "FONT_FAMILIES": "/**\n * Available font families for the editor\n */\nexport const FONT_FAMILIES = [\n  // Sans-serif fonts\n  { value: \"Poppins\", label: \"Poppins\" },\n  { value: \"Inter\", label: \"Inter\" },\n  { value: \"Montserrat\", label: \"Montserrat\" },\n  { value: \"Open Sans\", label: \"Open Sans\" },\n  { value: \"Roboto\", label: \"Roboto\" },\n  { value: \"Lato\", label: \"Lato\" },\n  { value: \"Space Grotesk\", label: \"Space Grotesk\" },\n  { value: \"Nunito\", label: \"Nunito\" },\n  { value: \"Baloo 2\", label: \"Baloo 2\" },\n  // Serif fonts\n  { value: \"Playfair Display\", label: \"Playfair\" },\n  { value: \"Georgia\", label: \"Georgia\" },\n  { value: \"Merriweather\", label: \"Merriweather\" },\n  { value: \"Lora\", label: \"Lora\" },\n  { value: \"Times New Roman\", label: \"Times\" },\n  // Display fonts\n  { value: \"Bebas Neue\", label: \"Bebas Neue\" },\n  { value: \"Oswald\", label: \"Oswald\" },\n];",
                "DEFAULT_FONT_FAMILY": "/**\n * Default font family when none is specified\n */\nconst DEFAULT_FONT_FAMILY = \"Poppins\";",
                "collectFontsFromRichContent": "/**\n * Collect all font families from a TiptapJSON rich content object\n */\n/**\n * Every font a document references — the document default plus any `textStyle`\n * mark. Exported so \"is this selection mixed?\" is answered the same way here,\n * in the global font control and in the multi-selection panel.\n */\nexport function collectFontsFromRichContent(\n  richContent: TiptapJSON,\n  fonts: Set<string>,\n): void {\n  // Check document-level default font\n  if (richContent.attrs?.defaultFontFamily) {\n    fonts.add(richContent.attrs.defaultFontFamily);\n  }\n\n  // Traverse content to find fonts in textStyle marks\n  for (const paragraph of richContent.content || []) {\n    if (!paragraph.content) continue;\n\n    for (const node of paragraph.content) {\n      if (node.type !== \"text\" || !node.marks) continue;\n\n      for (const mark of node.marks) {\n        if (mark.type === \"textStyle\" && mark.attrs?.fontFamily) {\n          fonts.add(mark.attrs.fontFamily);\n        }\n      }\n    }\n  }\n}",
                "detectGlobalFontFamily": "/**\n * Detect the global font family across all text elements in a layout.\n * Returns the font name if all elements use the same font, or null if mixed.\n */\nexport function detectGlobalFontFamily(layout: Layout): string | null {\n  const fonts = new Set<string>();\n\n  for (const screen of layout.screens) {\n    // Check texts\n    for (const text of screen.texts || []) {\n      collectFontsFromRichContent(text.richContent, fonts);\n    }\n\n    // Check pills\n    for (const pill of screen.pills || []) {\n      if (pill.richContent) {\n        collectFontsFromRichContent(pill.richContent, fonts);\n      }\n    }\n  }\n\n  // If no fonts found, return default\n  if (fonts.size === 0) {\n    return DEFAULT_FONT_FAMILY;\n  }\n\n  // If exactly one font, return it\n  if (fonts.size === 1) {\n    return [...fonts][0];\n  }\n\n  // Mixed fonts\n  return null;\n}",
                "updateRichContentFontFamily": "/**\n * Update the font family for a single rich content object\n */\nexport function updateRichContentFontFamily(\n  richContent: TiptapJSON,\n  fontFamily: string,\n): TiptapJSON {\n  // Update document-level default\n  const updatedAttrs = {\n    ...richContent.attrs,\n    defaultFontFamily: fontFamily,\n  };\n\n  // Update all textStyle marks in content\n  const updatedContent = richContent.content.map((paragraph) => {\n    if (!paragraph.content) return paragraph;\n\n    const updatedParagraphContent = paragraph.content.map((node) => {\n      if (node.type !== \"text\") return node;\n\n      // If no marks, add textStyle with fontFamily\n      if (!node.marks || node.marks.length === 0) {\n        return {\n          ...node,\n          marks: [{ type: \"textStyle\" as const, attrs: { fontFamily } }],\n        };\n      }\n\n      // Update existing marks\n      let hasTextStyle = false;\n      const updatedMarks = node.marks.map((mark) => {\n        if (mark.type === \"textStyle\") {\n          hasTextStyle = true;\n          return {\n            ...mark,\n            attrs: {\n              ...mark.attrs,\n              fontFamily,\n            },\n          };\n        }\n        return mark;\n      });\n\n      // Add textStyle if not present\n      if (!hasTextStyle) {\n        updatedMarks.push({\n          type: \"textStyle\" as const,\n          attrs: { fontFamily },\n        });\n      }\n\n      return { ...node, marks: updatedMarks };\n    });\n\n    return { ...paragraph, content: updatedParagraphContent };\n  });\n\n  return {\n    ...richContent,\n    attrs: updatedAttrs,\n    content: updatedContent,\n  };\n}",
                "updateAllElementsFontFamily": "/**\n * Update font family for all text elements in a layout\n */\nexport function updateAllElementsFontFamily(\n  layout: Layout,\n  fontFamily: string,\n): Layout {\n  const updatedScreens = layout.screens.map((screen) => {\n    const updatedScreen = { ...screen };\n\n    // Update texts\n    updatedScreen.texts = screen.texts.map((text) => ({\n      ...text,\n      richContent: updateRichContentFontFamily(text.richContent, fontFamily),\n    }));\n\n    // Update pills\n    if (screen.pills) {\n      updatedScreen.pills = screen.pills.map((pill) => ({\n        ...pill,\n        richContent: updateRichContentFontFamily(pill.richContent, fontFamily),\n      }));\n    }\n\n    return updatedScreen;\n  });\n\n  return {\n    ...layout,\n    screens: updatedScreens,\n  };\n}",
                "ScriptSupport": "// ---------------------------------------------------------------------------\n// Font–script compatibility\n// ---------------------------------------------------------------------------\n\ntype ScriptSupport =\n  | \"bengali\"\n  | \"gujarati\"\n  | \"kannada\"\n  | \"malayalam\"\n  | \"oriya\"\n  | \"gurmukhi\"\n  | \"tamil\"\n  | \"telugu\"\n  | \"cyrillic\"\n  | \"latin-ext\"\n  | \"devanagari\"\n  | \"vietnamese\"\n  | \"greek\"\n  | \"hebrew\"\n  | \"thai\"\n  | \"arabic\"\n  | \"cjk-ja\"\n  | \"cjk-ko\"\n  | \"cjk-sc\"\n  | \"cjk-tc\";",
                "FONT_SCRIPT_SUPPORT": "/**\n * Scripts each built-in font supports (beyond basic latin).\n * Missing key = latin only. No built-in font supports CJK or Arabic —\n * those always require a dedicated fallback font loaded on demand.\n */\nconst FONT_SCRIPT_SUPPORT: Record<string, ScriptSupport[]> = {\n  Inter: [\"cyrillic\", \"greek\", \"vietnamese\", \"latin-ext\"],\n  Montserrat: [\"cyrillic\", \"vietnamese\", \"latin-ext\"],\n  \"Open Sans\": [\"cyrillic\", \"greek\", \"vietnamese\", \"latin-ext\"],\n  Roboto: [\"cyrillic\", \"greek\", \"vietnamese\", \"latin-ext\"],\n  Nunito: [\"cyrillic\", \"vietnamese\", \"latin-ext\"],\n  \"Playfair Display\": [\"cyrillic\", \"vietnamese\", \"latin-ext\"],\n  Merriweather: [\"cyrillic\", \"vietnamese\", \"latin-ext\"],\n  Lora: [\"cyrillic\", \"vietnamese\", \"latin-ext\"],\n  Oswald: [\"cyrillic\", \"vietnamese\", \"latin-ext\"],\n  Poppins: [\"devanagari\", \"latin-ext\"],\n  \"Baloo 2\": [\"devanagari\", \"vietnamese\", \"latin-ext\"],\n  \"Space Grotesk\": [\"vietnamese\", \"latin-ext\"],\n  Lato: [\"latin-ext\"],\n  \"Bebas Neue\": [\"latin-ext\"],\n  // CJK/Arabic fallback fonts (loaded on demand)\n  \"Noto Sans JP\": [\"cjk-ja\", \"latin-ext\"],\n  \"Noto Sans KR\": [\"cjk-ko\", \"latin-ext\"],\n  \"Noto Sans SC\": [\"cjk-sc\", \"latin-ext\"],\n  \"Noto Sans TC\": [\"cjk-tc\", \"latin-ext\"],\n  \"Noto Sans Arabic\": [\"arabic\", \"latin-ext\"],\n  \"Noto Sans Hebrew\": [\"hebrew\", \"latin-ext\"],\n  \"Noto Sans Thai\": [\"thai\", \"latin-ext\"],\n  \"Noto Sans Bengali\": [\"bengali\", \"latin-ext\"],\n  \"Noto Sans Gujarati\": [\"gujarati\", \"latin-ext\"],\n  \"Noto Sans Kannada\": [\"kannada\", \"latin-ext\"],\n  \"Noto Sans Malayalam\": [\"malayalam\", \"latin-ext\"],\n  \"Noto Sans Oriya\": [\"oriya\", \"latin-ext\"],\n  \"Noto Sans Gurmukhi\": [\"gurmukhi\", \"latin-ext\"],\n  \"Noto Sans Tamil\": [\"tamil\", \"latin-ext\"],\n  \"Noto Sans Telugu\": [\"telugu\", \"latin-ext\"],\n};",
                "CYRILLIC_LANGUAGES": "/** Languages that require Cyrillic glyphs. */\nconst CYRILLIC_LANGUAGES = new Set([\n  \"ru\",\n  \"uk\",\n  \"bg\",\n  \"sr\",\n  \"mk\",\n  \"be\",\n  \"kk\",\n  \"ky\",\n  \"mn\",\n  \"tg\",\n  \"uz\",\n]);",
                "GREEK_LANGUAGES": "/** Languages that require Greek glyphs. */\nconst GREEK_LANGUAGES = new Set([\"el\"]);",
                "DEVANAGARI_LANGUAGES": "/** Languages that require Devanagari glyphs. */\nconst DEVANAGARI_LANGUAGES = new Set([\"hi\", \"mr\", \"ne\", \"sa\"]);",
                "CJK_LANGUAGE_MAP": "/** Languages that require CJK glyphs. */\nconst CJK_LANGUAGE_MAP: Record<string, ScriptSupport> = {\n  ja: \"cjk-ja\",\n  ko: \"cjk-ko\",\n  zh: \"cjk-sc\", // default Chinese → Simplified\n  \"zh-cn\": \"cjk-sc\",\n  \"zh-tw\": \"cjk-tc\",\n  \"zh-hk\": \"cjk-tc\",\n};",
                "ARABIC_LANGUAGES": "/** Languages that require Arabic glyphs. */\nconst ARABIC_LANGUAGES = new Set([\"ar\", \"fa\", \"ur\"]);",
                "SCRIPT_FALLBACK_FONTS": "/** Default CJK/Arabic fallback fonts — clean sans-serif Noto family. */\nconst SCRIPT_FALLBACK_FONTS: Record<string, string> = {\n  bengali: \"Noto Sans Bengali\",\n  gujarati: \"Noto Sans Gujarati\",\n  kannada: \"Noto Sans Kannada\",\n  malayalam: \"Noto Sans Malayalam\",\n  oriya: \"Noto Sans Oriya\",\n  gurmukhi: \"Noto Sans Gurmukhi\",\n  tamil: \"Noto Sans Tamil\",\n  telugu: \"Noto Sans Telugu\",\n\n  \"cjk-ja\": \"Noto Sans JP\",\n  \"cjk-ko\": \"Noto Sans KR\",\n  \"cjk-sc\": \"Noto Sans SC\",\n  \"cjk-tc\": \"Noto Sans TC\",\n  arabic: \"Noto Sans Arabic\",\n  hebrew: \"Noto Sans Hebrew\",\n  thai: \"Noto Sans Thai\",\n};",
                "SPECIAL_FONT_NAMES": "/** Set of special font names (CJK/Arabic) that are loaded on demand, not via next/font CSS variables. */\nexport const SPECIAL_FONT_NAMES = new Set(Object.values(SCRIPT_FALLBACK_FONTS));",
                "FONT_FALLBACKS": "/** Visually similar fallback font for each font that lacks a script. */\nconst FONT_FALLBACKS: Record<string, Record<string, string>> = {\n  // Poppins (geometric sans) → Montserrat or Nunito (also geometric sans with Cyrillic)\n  Poppins: { cyrillic: \"Montserrat\", greek: \"Inter\" },\n  \"Baloo 2\": { cyrillic: \"Nunito\", greek: \"Open Sans\" },\n  \"Space Grotesk\": { cyrillic: \"Inter\", greek: \"Inter\" },\n  Lato: { cyrillic: \"Open Sans\", greek: \"Open Sans\" },\n  \"Bebas Neue\": { cyrillic: \"Oswald\", greek: \"Inter\" },\n};",
                "INDIC_LANGUAGE_SCRIPTS": "const INDIC_LANGUAGE_SCRIPTS: Record<string, ScriptSupport> = {\n  bn: \"bengali\",\n  gu: \"gujarati\",\n  kn: \"kannada\",\n  ml: \"malayalam\",\n  or: \"oriya\",\n  pa: \"gurmukhi\",\n  ta: \"tamil\",\n  te: \"telugu\",\n};",
                "scriptForLanguage": "function scriptForLanguage(lang: string): ScriptSupport | null {\n  const lower = lang.toLowerCase();\n  // Check CJK (needs full locale match for zh-CN vs zh-TW)\n  if (CJK_LANGUAGE_MAP[lower]) return CJK_LANGUAGE_MAP[lower];\n  const base = lower.split(\"-\")[0];\n  if (base === \"he\") return \"hebrew\";\n  if (base === \"th\") return \"thai\";\n  if (base === \"vi\") return \"vietnamese\";\n  if (INDIC_LANGUAGE_SCRIPTS[base]) return INDIC_LANGUAGE_SCRIPTS[base];\n  if (CJK_LANGUAGE_MAP[base]) return CJK_LANGUAGE_MAP[base];\n  // Arabic\n  if (ARABIC_LANGUAGES.has(base)) return \"arabic\";\n  // Cyrillic / Greek / Devanagari\n  if (CYRILLIC_LANGUAGES.has(base)) return \"cyrillic\";\n  if (GREEK_LANGUAGES.has(base)) return \"greek\";\n  if (DEVANAGARI_LANGUAGES.has(base)) return \"devanagari\";\n  return null;\n}",
                "languageHasScriptRequirements": "/** Whether a language needs compatibility checks beyond basic Latin glyphs. */\nexport function languageHasScriptRequirements(language: string): boolean {\n  return scriptForLanguage(language) !== null;\n}",
                "resolveFontForLanguage": "/**\n * Returns a compatible font for the given font + target language.\n * If the font already supports the language's script, returns the same font.\n * Otherwise returns the closest visual fallback that does support it.\n *\n * For CJK and Arabic, always returns the dedicated Noto Sans variant\n * because no built-in editor font covers those scripts.\n */\nexport function resolveFontForLanguage(\n  fontFamily: string,\n  language: string,\n): string {\n  const requiredScript = scriptForLanguage(language);\n  if (!requiredScript) return fontFamily; // Latin — every font works\n\n  // CJK and Arabic always need a dedicated font\n  const scriptFallback = SCRIPT_FALLBACK_FONTS[requiredScript];\n  if (scriptFallback) {\n    const supported = FONT_SCRIPT_SUPPORT[fontFamily];\n    if (supported?.includes(requiredScript)) return fontFamily;\n    return scriptFallback;\n  }\n\n  const supported = FONT_SCRIPT_SUPPORT[fontFamily];\n  if (supported?.includes(requiredScript)) return fontFamily;\n\n  // Need a fallback for Cyrillic/Greek\n  const fallback = FONT_FALLBACKS[fontFamily]?.[requiredScript];\n  return fallback || (requiredScript === \"devanagari\" ? \"Poppins\" : \"Inter\");\n}",
                "resolveFontForText": "/**\n * Resolve a font from the actual characters being rendered. This is used when\n * stored language metadata is missing or incorrect, which would otherwise let\n * the browser and Lambda choose different platform fallback fonts.\n */\nexport function resolveFontForText(fontFamily: string, text: string): string {\n  if (/\\p{Script=Hebrew}/u.test(text))\n    return resolveFontForLanguage(fontFamily, \"he\");\n  if (/\\p{Script=Thai}/u.test(text))\n    return resolveFontForLanguage(fontFamily, \"th\");\n  if (/\\p{Script=Bengali}/u.test(text))\n    return resolveFontForLanguage(fontFamily, \"bn\");\n  if (/\\p{Script=Gujarati}/u.test(text))\n    return resolveFontForLanguage(fontFamily, \"gu\");\n  if (/\\p{Script=Kannada}/u.test(text))\n    return resolveFontForLanguage(fontFamily, \"kn\");\n  if (/\\p{Script=Malayalam}/u.test(text))\n    return resolveFontForLanguage(fontFamily, \"ml\");\n  if (/\\p{Script=Oriya}/u.test(text))\n    return resolveFontForLanguage(fontFamily, \"or\");\n  if (/\\p{Script=Gurmukhi}/u.test(text))\n    return resolveFontForLanguage(fontFamily, \"pa\");\n  if (/\\p{Script=Tamil}/u.test(text))\n    return resolveFontForLanguage(fontFamily, \"ta\");\n  if (/\\p{Script=Telugu}/u.test(text))\n    return resolveFontForLanguage(fontFamily, \"te\");\n  if (/\\p{Script=Cyrillic}/u.test(text)) {\n    return resolveFontForLanguage(fontFamily, \"ru\");\n  }\n  if (/\\p{Script=Greek}/u.test(text)) {\n    return resolveFontForLanguage(fontFamily, \"el\");\n  }\n  if (/\\p{Script=Arabic}/u.test(text)) {\n    return resolveFontForLanguage(fontFamily, \"ar\");\n  }\n  if (/\\p{Script=Devanagari}/u.test(text)) {\n    return resolveFontForLanguage(fontFamily, \"hi\");\n  }\n  if (/\\p{Script=Hiragana}|\\p{Script=Katakana}/u.test(text)) {\n    return resolveFontForLanguage(fontFamily, \"ja\");\n  }\n  if (/\\p{Script=Hangul}/u.test(text)) {\n    return resolveFontForLanguage(fontFamily, \"ko\");\n  }\n  if (/\\p{Script=Han}/u.test(text)) {\n    // Han alone cannot distinguish Chinese regions, Japanese or Korean.\n    // Retain an already compatible authored CJK family instead of replacing\n    // Traditional Chinese or a kanji-only brand with Simplified Chinese.\n    if (\n      FONT_SCRIPT_SUPPORT[fontFamily]?.some((script) =>\n        script.startsWith(\"cjk-\"),\n      )\n    ) {\n      return fontFamily;\n    }\n    return resolveFontForLanguage(fontFamily, \"zh-cn\");\n  }\n  // These letters require the Vietnamese subset rather than Latin/Latin-ext.\n  // NFC also handles authored decomposed vowels and combining tone marks.\n  if (/[ƠơƯư\\u1EA0-\\u1EF9]/u.test(text.normalize(\"NFC\"))) {\n    return resolveFontForLanguage(fontFamily, \"vi\");\n  }\n  return fontFamily;\n}",
                "getRequiredFontForLanguage": "/**\n * Returns the font name that needs to be loaded for a given language,\n * or null if no special font is needed.\n */\nexport function getRequiredFontForLanguage(language: string): string | null {\n  const script = scriptForLanguage(language);\n  if (!script) return null;\n  return SCRIPT_FALLBACK_FONTS[script] || null;\n}"
            }
        },
        {
            "group": "transforms",
            "path": "src/server/mcp/layout-transform-contract.ts",
            "sha256": "fff2e6a23948f7e3bf6aaa359fa8141325e9e470b9849fce8893c3a38a0e7f5f",
            "declarations": {
                "TRANSFORM_NODE_TYPES": "const TRANSFORM_NODE_TYPES = [\n  \"layout\",\n  \"screen\",\n  \"header\",\n  \"panoramaBackground\",\n  \"screenshot\",\n  \"text\",\n  \"pill\",\n  \"badge\",\n  \"blob\",\n  \"rating\",\n  \"logo\",\n  \"illustration\",\n  \"magnifier\",\n  \"emoji\",\n  \"backgroundImage\",\n] as const;",
                "TRANSFORM_LAYOUT_KINDS": "export const TRANSFORM_LAYOUT_KINDS = [\"mobile\", \"tablet\", \"desktop\"] as const;",
                "transformOperationSchema": "/**\n * Transform vocabulary shared by the MCP and the public API.\n *\n * - `update_node` merges `changes` into every matched node. Keys may use dot\n *   notation for nested fields. A `null` value REMOVES that field (or nested\n *   field), e.g. `{backgroundGradient: null}`, `{model3D: null}` back to a 2D\n *   frame, `{shadow: null}`, `{iosPath: null}`.\n * - nodeType `layout` edits layout-level fields of every selected layout:\n *   backgroundColor, backgroundGradient, colorRefs, orientation, screenshotGap,\n *   phoneFormatImagesOnly, layerGroups, plus `themeColors` (partial role map,\n *   re-colors linked elements like the editor's Theme panel) and\n *   `themePaletteMode` (\"v1\"/\"v2\", applies the stored palette options).\n * - `reorder` permutes the zIndex values the listed nodes already hold, so the\n *   stacking against other node types is unchanged. zIndex is ONE stacking\n *   order per screen shared by every node type (texts, screenshots, pills...).\n * - `bring_to_front` / `send_to_back` move the matched node(s) above / below\n *   every other node on their screen, across node types.\n * - `platform` scopes a screenshot `path` change to one platform override\n *   (iosPath/androidPath). Without it a new `path` applies to both platforms\n *   and clears existing iosPath/androidPath overrides.\n */\nexport const transformOperationSchema = z.object({\n  type: z.enum([\n    \"update_node\",\n    \"delete_node\",\n    \"add_node\",\n    \"reorder\",\n    \"replace_color\",\n    \"bring_to_front\",\n    \"send_to_back\",\n  ]),\n  target: z.object({\n    nodeType: z.enum(TRANSFORM_NODE_TYPES).optional(),\n    nodeId: z.string().optional(),\n    selector: z.string().optional(),\n    screens: z\n      .union([z.literal(\"all\"), z.array(z.number().int().min(0))])\n      .optional(),\n  }),\n  changes: z.record(z.string(), z.unknown()).default({}),\n  platform: z.enum([\"ios\", \"android\"]).optional(),\n});",
                "TransformNodeType": "export type TransformNodeType = (typeof TRANSFORM_NODE_TYPES)[number];",
                "TransformLayoutKind": "export type TransformLayoutKind = (typeof TRANSFORM_LAYOUT_KINDS)[number];",
                "TransformOperation": "export type TransformOperation = z.infer<typeof transformOperationSchema>;"
            }
        },
        {
            "group": "transforms",
            "path": "src/server/export/screenshotPreviewPlan.ts",
            "sha256": "a7cd0d313bff81e7f9cfadd9d72ee4c30fe40770227894509a3ae971e9ece770",
            "declarations": {
                "PREVIEW_DISPLAY_SIZES": "/**\n * Pure planning for a rendered screenshot preview (MCP `preview_screenshots`):\n * which export target to draw and how to split the requested screens into\n * Lambda stills. A preview is the export renderer at a store size, downscaled,\n * so what an agent sees is what an export would produce.\n */\n\nexport const PREVIEW_DISPLAY_SIZES = [\n  \"6.9\",\n  \"6.5\",\n  \"6.3\",\n  \"6.1\",\n  \"duo-inner\",\n  \"duo-outer\",\n  \"13\",\n  \"12.9\",\n  \"11\",\n  \"10.5\",\n  \"play-high\",\n  \"preview\",\n] as const;",
                "PreviewDisplaySize": "export type PreviewDisplaySize = (typeof PREVIEW_DISPLAY_SIZES)[number];",
                "PREVIEW_SCREENS_PER_STILL": "/** Screens per Lambda still; 3D devices are slow under SwiftShader. */\nconst PREVIEW_SCREENS_PER_STILL = 10;",
                "PREVIEW_3D_SCREENS_PER_STILL": "const PREVIEW_3D_SCREENS_PER_STILL = 2;",
                "sizesFor": "function sizesFor(platform: ExportPlatform, device: ExportDevice): string[] {\n  if (device === \"phone\")\n    return platform === \"ios\"\n      ? [...Object.keys(iosDisplaySizes), ...Object.keys(iphoneDuoDisplaySizes)]\n      : Object.keys(androidDisplaySizes);\n  if (device === \"tablet\" && platform === \"ios\")\n    return Object.keys(ipadDisplaySizes);\n  return [];\n}",
                "resolvePreviewSize": "/** Default size: the store's primary size; canvas size where none applies. */\nexport function resolvePreviewSize(\n  platform: ExportPlatform,\n  device: ExportDevice,\n  displaySize?: PreviewDisplaySize,\n): ScreenshotExportSize {\n  const allowed = sizesFor(platform, device);\n  if (displaySize) {\n    if (!allowed.includes(displaySize)) {\n      throw new Error(\n        allowed.length\n          ? `displaySize \"${displaySize}\" does not apply to ${platform} ${device}. Use one of: ${allowed.join(\", \")}.`\n          : `${platform} ${device} previews render at the layout's canvas size; omit displaySize.`,\n      );\n    }\n    return { kind: \"preset\", id: displaySize as never };\n  }\n  if (device === \"phone\")\n    return { kind: \"preset\", id: platform === \"ios\" ? \"6.9\" : \"play-high\" };\n  if (device === \"tablet\" && platform === \"ios\")\n    return { kind: \"preset\", id: \"13\" };\n  return { kind: \"canvas\" };\n}",
                "previewTarget": "export function previewTarget(input: {\n  platform: ExportPlatform;\n  device: ExportDevice;\n  language: string;\n  displaySize?: PreviewDisplaySize;\n}): ScreenshotExportTarget {\n  return {\n    platform: input.platform,\n    device: input.device,\n    language: input.language,\n    size: resolvePreviewSize(input.platform, input.device, input.displaySize),\n  };\n}",
                "selectPreviewScreens": "/** Requested screen indexes, validated against the layout. All by default. */\nexport function selectPreviewScreens(\n  layout: Layout,\n  requested?: number[],\n): number[] {\n  const count = layout.screens.length;\n  if (count === 0) throw new Error(\"This layout has no screens to preview\");\n  if (!requested?.length) return Array.from({ length: count }, (_, i) => i);\n  const invalid = requested.filter(\n    (index) => !Number.isInteger(index) || index < 0 || index >= count,\n  );\n  if (invalid.length)\n    throw new Error(\n      `Screen index ${invalid.join(\", \")} is out of range: this layout has screens 0-${count - 1}.`,\n    );\n  return [...new Set(requested)].sort((a, b) => a - b);\n}",
                "groupPreviewScreens": "/** Group screens into parallel stills; 3D screens get smaller groups. */\nexport function groupPreviewScreens(\n  layout: Layout,\n  screens: number[],\n): number[][] {\n  const has3D = (index: number) =>\n    (layout.screens[index]?.screenshots ?? []).some((shot) =>\n      Boolean(shot.model3D),\n    );\n  const groups: number[][] = [];\n  let current: number[] = [];\n  let currentIs3D = false;\n  for (const index of screens) {\n    const is3D = has3D(index);\n    const limit =\n      is3D || currentIs3D\n        ? PREVIEW_3D_SCREENS_PER_STILL\n        : PREVIEW_SCREENS_PER_STILL;\n    if (current.length >= limit || (current.length && is3D !== currentIs3D)) {\n      groups.push(current);\n      current = [];\n    }\n    if (current.length === 0) currentIs3D = is3D;\n    current.push(index);\n  }\n  if (current.length) groups.push(current);\n  return groups;\n}",
                "previewDimensions": "/** Downscaled size whose longest edge is at most `maxEdge`; never upscales. */\nexport function previewDimensions(\n  width: number,\n  height: number,\n  maxEdge: number,\n): { width: number; height: number } {\n  const scale = Math.min(1, maxEdge / Math.max(width, height, 1));\n  return {\n    width: Math.max(1, Math.round(width * scale)),\n    height: Math.max(1, Math.round(height * scale)),\n  };\n}"
            }
        },
        {
            "group": "graphics",
            "path": "src/features/graphics-editor/types.ts",
            "sha256": "63c5c3e1df230694672a678734a17c43b769323755eba9b7893cde580c848f1a",
            "declarations": {
                "SOCIAL_FORMAT_ORDER": "export const SOCIAL_FORMAT_ORDER = SOCIAL_FORMATS;",
                "SOCIAL_FORMAT_GROUP_ORDER": "export const SOCIAL_FORMAT_GROUP_ORDER = [\n  \"social_media\",\n  \"app_store\",\n  \"mobile_ads\",\n] as const;",
                "SocialFormatGroup": "export type SocialFormatGroup = (typeof SOCIAL_FORMAT_GROUP_ORDER)[number];",
                "EXPORT_RESOLUTIONS": "export const EXPORT_RESOLUTIONS = [\"1x\", \"2x\", \"3x\"] as const;",
                "ExportResolution": "export type ExportResolution = (typeof EXPORT_RESOLUTIONS)[number];",
                "EXPORT_RESOLUTION_MULTIPLIER": "export const EXPORT_RESOLUTION_MULTIPLIER: Record<ExportResolution, number> = {\n  \"1x\": 1,\n  \"2x\": 2,\n  \"3x\": 3,\n};",
                "SocialFormatDefinition": "export interface SocialFormatDefinition {\n  id: SocialFormat;\n  label: string;\n  groups: SocialFormatGroup[];\n  width: number;\n  height: number;\n  ratioLabel: string;\n  exportSlug: string;\n  /** Placement specs require the authored pixel size, not a retina multiple. */\n  nativeExportOnly?: boolean;\n  /** Recommended upload ceiling for the placement, in kilobytes. */\n  maxFileSizeKb?: number;\n  /**\n   * The placement rejects an alpha channel (Google Play: \"JPEG or 24-bit PNG,\n   * no alpha\"). Browser canvases only encode RGBA PNGs, so export either an\n   * opaque JPEG or a PNG with the alpha channel stripped. Use \"png\" when the\n   * placement accepts nothing else (Apple's 3840x1646 header is PNG-only).\n   */\n  opaqueExport?: \"jpeg\" | \"png\";\n}",
                "SocialLayoutMap": "export type SocialLayoutMap = Record<SocialFormat, Layout>;",
                "SavedSocialGraphic": "export interface SavedSocialGraphic {\n  format: SocialFormat;\n  layout: Layout;\n}",
                "SavedSocialGraphicsPayload": "export interface SavedSocialGraphicsPayload {\n  generationId: string;\n  variantId?: string;\n  language?: string;\n  socialTemplateId: string;\n  socialPrimaryFormat: SocialFormat;\n  graphics: SavedSocialGraphic[];\n}",
                "LoadSocialGraphicsResponse": "export interface LoadSocialGraphicsResponse {\n  variantId?: string | null;\n  language?: string | null;\n  sourceLanguage?: string;\n  availableLanguages?: string[];\n  socialTemplateId: string | null;\n  socialPrimaryFormat: SocialFormat | null;\n  graphics: SavedSocialGraphic[];\n}"
            }
        },
        {
            "group": "graphics",
            "path": "src/features/graphics-editor/lib/formats.ts",
            "sha256": "aac8480ec0e9e84f717c9de8868730a61ae6f0da03fcde341906af55632fcb33",
            "declarations": {
                "SOCIAL_FORMAT_GROUP_LABELS": "export const SOCIAL_FORMAT_GROUP_LABELS: Record<SocialFormatGroup, string> = {\n  social_media: \"Social Media\",\n  app_store: \"App Store\",\n  mobile_ads: \"Mobile Ads\",\n};",
                "SOCIAL_FORMATS": "export const SOCIAL_FORMATS: Record<SocialFormat, SocialFormatDefinition> = {\n  og: {\n    id: \"og\",\n    label: \"OG Image\",\n    groups: [\"social_media\"],\n    width: 1200,\n    height: 630,\n    ratioLabel: \"1.91:1\",\n    exportSlug: \"og\",\n  },\n  x_post: {\n    id: \"x_post\",\n    label: \"Social Media Post\",\n    groups: [\"social_media\"],\n    width: 1200,\n    height: 675,\n    ratioLabel: \"16:9\",\n    exportSlug: \"social-post\",\n  },\n  instagram_story: {\n    id: \"instagram_story\",\n    label: \"Instagram Story\",\n    groups: [\"social_media\", \"mobile_ads\"],\n    width: 1080,\n    height: 1920,\n    ratioLabel: \"9:16\",\n    exportSlug: \"instagram-story\",\n    nativeExportOnly: true,\n    maxFileSizeKb: 700,\n  },\n  instagram_post: {\n    id: \"instagram_post\",\n    label: \"Instagram Post\",\n    groups: [\"social_media\"],\n    width: 1080,\n    height: 1440,\n    ratioLabel: \"3:4\",\n    exportSlug: \"instagram-post\",\n  },\n  play_store_feature: {\n    id: \"play_store_feature\",\n    label: \"Play Store Feature Graphic\",\n    groups: [\"app_store\"],\n    width: 1024,\n    height: 500,\n    ratioLabel: \"2.05:1\",\n    exportSlug: \"play-store-feature-graphic\",\n    // Play Console wants exactly 1024x500 and flags anything else as\n    // \"cropping required\"; a 2x retina export is rejected, not scaled.\n    nativeExportOnly: true,\n    opaqueExport: \"jpeg\",\n  },\n  x_header: {\n    id: \"x_header\",\n    label: \"X Banner\",\n    groups: [\"social_media\"],\n    width: 1500,\n    height: 500,\n    ratioLabel: \"3:1\",\n    exportSlug: \"x-banner\",\n  },\n  linkedin_banner: {\n    id: \"linkedin_banner\",\n    label: \"LinkedIn Banner\",\n    groups: [\"social_media\"],\n    width: 1584,\n    height: 396,\n    ratioLabel: \"4:1\",\n    exportSlug: \"linkedin-banner\",\n  },\n  app_store_event_card: {\n    id: \"app_store_event_card\",\n    label: \"In-App Event Card\",\n    groups: [\"app_store\"],\n    width: 1920,\n    height: 1080,\n    ratioLabel: \"16:9\",\n    exportSlug: \"app-store-event-card\",\n    nativeExportOnly: true,\n  },\n  app_store_event_details: {\n    id: \"app_store_event_details\",\n    label: \"In-App Event Details\",\n    groups: [\"app_store\"],\n    width: 1080,\n    height: 1920,\n    ratioLabel: \"9:16\",\n    exportSlug: \"app-store-event-details\",\n    nativeExportOnly: true,\n  },\n  // Apple's creative assets (App Store Connect asset library, October 2026).\n  // Exact pixel sizes, no alpha: Apple matches the file to a spec by its\n  // dimensions and file type and rejects transparency. The header spec only\n  // lists .png (the help page's \"jpeg\" is wrong); a JPEG fails as \"invalid\n  // dimensions\". Search results accept JPEG and PNG.\n  app_store_header: {\n    id: \"app_store_header\",\n    label: \"Product Page Header\",\n    groups: [\"app_store\"],\n    width: 3840,\n    height: 1646,\n    ratioLabel: \"21:9\",\n    exportSlug: \"app-store-header\",\n    nativeExportOnly: true,\n    opaqueExport: \"png\",\n  },\n  app_store_search: {\n    id: \"app_store_search\",\n    label: \"Search Results Creative\",\n    groups: [\"app_store\"],\n    width: 1920,\n    height: 1280,\n    ratioLabel: \"3:2\",\n    exportSlug: \"app-store-search\",\n    nativeExportOnly: true,\n    opaqueExport: \"jpeg\",\n  },\n  ad_banner: {\n    id: \"ad_banner\",\n    label: \"Banner\",\n    groups: [\"mobile_ads\"],\n    width: 300,\n    height: 50,\n    ratioLabel: \"6:1\",\n    exportSlug: \"ad-banner\",\n    nativeExportOnly: true,\n    maxFileSizeKb: 200,\n  },\n  ad_mobile_banner: {\n    id: \"ad_mobile_banner\",\n    label: \"Mobile Banner\",\n    groups: [\"mobile_ads\"],\n    width: 320,\n    height: 50,\n    ratioLabel: \"6.4:1\",\n    exportSlug: \"mobile-banner\",\n    nativeExportOnly: true,\n    maxFileSizeKb: 200,\n  },\n  ad_tablet_banner_720: {\n    id: \"ad_tablet_banner_720\",\n    label: \"Tablet Banner (720)\",\n    groups: [\"mobile_ads\"],\n    width: 720,\n    height: 90,\n    ratioLabel: \"8:1\",\n    exportSlug: \"tablet-banner-720\",\n    nativeExportOnly: true,\n    maxFileSizeKb: 250,\n  },\n  ad_tablet_banner_728: {\n    id: \"ad_tablet_banner_728\",\n    label: \"Tablet Banner (728)\",\n    groups: [\"mobile_ads\"],\n    width: 728,\n    height: 90,\n    ratioLabel: \"8.09:1\",\n    exportSlug: \"tablet-banner-728\",\n    nativeExportOnly: true,\n    maxFileSizeKb: 250,\n  },\n  ad_mrec: {\n    id: \"ad_mrec\",\n    label: \"Medium Rectangle (MREC)\",\n    groups: [\"mobile_ads\"],\n    width: 300,\n    height: 250,\n    ratioLabel: \"6:5\",\n    exportSlug: \"mrec\",\n    nativeExportOnly: true,\n    maxFileSizeKb: 500,\n  },\n  ad_phone_portrait: {\n    id: \"ad_phone_portrait\",\n    label: \"Phone Portrait\",\n    groups: [\"mobile_ads\"],\n    width: 320,\n    height: 480,\n    ratioLabel: \"2:3\",\n    exportSlug: \"phone-portrait\",\n    nativeExportOnly: true,\n    maxFileSizeKb: 700,\n  },\n  ad_phone_landscape: {\n    id: \"ad_phone_landscape\",\n    label: \"Phone Landscape\",\n    groups: [\"mobile_ads\"],\n    width: 480,\n    height: 320,\n    ratioLabel: \"3:2\",\n    exportSlug: \"phone-landscape\",\n    nativeExportOnly: true,\n    maxFileSizeKb: 700,\n  },\n  ad_fullscreen_landscape: {\n    id: \"ad_fullscreen_landscape\",\n    label: \"HD Landscape\",\n    groups: [\"mobile_ads\"],\n    width: 1920,\n    height: 1080,\n    ratioLabel: \"16:9\",\n    exportSlug: \"fullscreen-landscape\",\n    nativeExportOnly: true,\n    maxFileSizeKb: 700,\n  },\n  ad_tablet_portrait: {\n    id: \"ad_tablet_portrait\",\n    label: \"Tablet Portrait\",\n    groups: [\"mobile_ads\"],\n    width: 768,\n    height: 1024,\n    ratioLabel: \"3:4\",\n    exportSlug: \"tablet-portrait\",\n    nativeExportOnly: true,\n    maxFileSizeKb: 700,\n  },\n  ad_tablet_landscape: {\n    id: \"ad_tablet_landscape\",\n    label: \"Tablet Landscape\",\n    groups: [\"mobile_ads\"],\n    width: 1024,\n    height: 768,\n    ratioLabel: \"4:3\",\n    exportSlug: \"tablet-landscape\",\n    nativeExportOnly: true,\n    maxFileSizeKb: 700,\n  },\n};",
                "SOCIAL_FORMAT_LIST": "export const SOCIAL_FORMAT_LIST: SocialFormatDefinition[] =\n  SOCIAL_FORMAT_ORDER.map((format) => SOCIAL_FORMATS[format]);",
                "SOCIAL_FORMAT_GROUPS": "export const SOCIAL_FORMAT_GROUPS = SOCIAL_FORMAT_GROUP_ORDER.map((id) => ({\n  id,\n  label: SOCIAL_FORMAT_GROUP_LABELS[id],\n  formats: SOCIAL_FORMAT_LIST.filter((format) => format.groups.includes(id)),\n}));",
                "getPrimarySocialFormatGroup": "export function getPrimarySocialFormatGroup(\n  format: SocialFormat,\n): SocialFormatGroup {\n  return SOCIAL_FORMATS[format].groups[0];\n}"
            }
        },
        {
            "group": "graphics",
            "path": "src/features/graphics-editor/lib/socialLayoutValidation.ts",
            "sha256": "e989a1e2764905d47ebf85e26dcc47cfaae5e269247215c2cf767f22a9fc2fd3",
            "declarations": {
                "SocialLayoutIssue": "export interface SocialLayoutIssue {\n  path: Array<string | number>;\n  message: string;\n}",
                "socialLayoutIssues": "/**\n * Structural rules every social layout must satisfy before it is stored: one\n * screen, drawn on the format's own canvas. The renderer and editor otherwise\n * crop, letterbox or silently ignore extra screens, so an API caller gets a\n * specific message instead of a design that \"saved\" but looks wrong.\n */\nexport function socialLayoutIssues(\n  format: SocialFormat,\n  layout: unknown,\n): SocialLayoutIssue[] {\n  const definition = SOCIAL_FORMATS[format];\n  if (!definition || !layout || typeof layout !== \"object\") return [];\n  const candidate = layout as {\n    screens?: unknown;\n    canvasWidth?: unknown;\n    canvasHeight?: unknown;\n  };\n  const issues: SocialLayoutIssue[] = [];\n  const screenCount = Array.isArray(candidate.screens)\n    ? candidate.screens.length\n    : 0;\n  if (screenCount !== 1) {\n    issues.push({\n      path: [\"screens\"],\n      message: `A social layout for ${format} must contain exactly one screen (received ${screenCount}). Put every element on screens[0].`,\n    });\n  }\n  if (candidate.canvasWidth !== definition.width) {\n    issues.push({\n      path: [\"canvasWidth\"],\n      message: `canvasWidth must be ${definition.width} for ${format} (${definition.width}x${definition.height}); received ${String(candidate.canvasWidth)}.`,\n    });\n  }\n  if (candidate.canvasHeight !== definition.height) {\n    issues.push({\n      path: [\"canvasHeight\"],\n      message: `canvasHeight must be ${definition.height} for ${format} (${definition.width}x${definition.height}); received ${String(candidate.canvasHeight)}.`,\n    });\n  }\n  return issues;\n}",
                "addSocialLayoutIssues": "/** Report socialLayoutIssues as zod issues under `prefix` (e.g. [\"layout\"]). */\nexport function addSocialLayoutIssues(\n  ctx: z.RefinementCtx,\n  format: SocialFormat,\n  layout: unknown,\n  prefix: Array<string | number>,\n): void {\n  for (const issue of socialLayoutIssues(format, layout)) {\n    ctx.addIssue({\n      code: \"custom\",\n      path: [...prefix, ...issue.path],\n      message: issue.message,\n    });\n  }\n}"
            }
        },
        {
            "group": "graphics",
            "path": "src/features/graphics-editor/lib/platformSwap.ts",
            "sha256": "cc58b535144a51bc40a7eee399e3f6c9af6f73854f35218d4581400490cff227",
            "declarations": {
                "GraphicsPlatform": "export type GraphicsPlatform = \"ios\" | \"android\";",
                "applyGraphicsPlatform": "/**\n * Re-skin one social layout for a store platform: device frames and 3D models\n * follow the platform, and a lone store badge swaps to the matching store. A\n * layout that deliberately shows both badges keeps them.\n *\n * Shared by the editor's platform toggle, its export, and the server (MCP\n * platform switch and headless render) so all of them produce the same file.\n */\nexport function applyGraphicsPlatform(\n  layout: Layout,\n  platform: GraphicsPlatform,\n): Layout {\n  const targetBadge = platform === \"ios\" ? \"app_store\" : \"google_play\";\n  const badgeCount = layout.screens.reduce(\n    (count, screen) =>\n      count +\n      (screen.illustrations ?? []).filter(\n        (ill) => getStoreBadgeVariant(ill) !== null,\n      ).length,\n    0,\n  );\n  const shouldSwapBadges = badgeCount < 2;\n\n  const swapBadge = (ill: IllustrationNode): IllustrationNode => {\n    const variant = getStoreBadgeVariant(ill);\n    if (!variant || variant === targetBadge) return ill;\n    const target = getStoreBadgeSwapTarget(variant);\n    return { ...ill, path: target.path, signedUrl: target.signedUrl };\n  };\n\n  const platformLayout = syncLayoutDevicesToPlatform(layout, platform)!;\n  return {\n    ...platformLayout,\n    screens: platformLayout.screens.map((screen) => ({\n      ...screen,\n      ...(shouldSwapBadges &&\n        screen.illustrations && {\n          illustrations: screen.illustrations.map(swapBadge),\n        }),\n    })),\n  };\n}"
            }
        },
        {
            "group": "graphics",
            "path": "src/features/graphics-editor/lib/exportLayouts.ts",
            "sha256": "894cd462cfa08700b1562e62004a98bd7c3d9b6e62175c1d389847f825110bf8",
            "declarations": {
                "prepareGraphicsExportLayouts": "/** Match lazy format selection, but only using this language's layouts. */\n\nexport function prepareGraphicsExportLayouts(\n  layouts: Partial<SocialLayoutMap>,\n  formats: SocialFormat[],\n  language: string,\n  preferredFormat?: SocialFormat | null,\n): Partial<SocialLayoutMap> {\n  const prepared = { ...layouts };\n  const source =\n    (preferredFormat ? layouts[preferredFormat] : undefined) ||\n    Object.values(layouts).find(Boolean);\n  for (const format of formats) {\n    if (prepared[format]) continue;\n    if (!source) {\n      throw new Error(\n        `${language} is missing ${SOCIAL_FORMATS[format].label}. Translate that graphics language again before export.`,\n      );\n    }\n    prepared[format] = deriveGraphicsFormat(source, format);\n  }\n  return prepared;\n}",
                "deriveGraphicsFormat": "/**\n * Build `format` from another format's layout the way the editor's format\n * switch and \"Sync to all formats\" do: the template's own composition for\n * the target, falling back to proportional rescaling.\n */\nexport function deriveGraphicsFormat(source: Layout, format: SocialFormat) {\n  return normalizeLayerZIndexes(\n    deriveLayoutFromTemplate(source, format) ??\n      deriveLayoutForFormat(source, SOCIAL_FORMATS[format]),\n  );\n}",
                "syncGraphicsFromFormat": "/** Server-side \"Sync to all formats\": rebuild `targets` from `source`. */\nexport function syncGraphicsFromFormat(\n  source: Layout,\n  sourceFormat: SocialFormat,\n  targets: readonly SocialFormat[],\n): Partial<SocialLayoutMap> {\n  const synced: Partial<SocialLayoutMap> = {};\n  for (const format of targets) {\n    if (format === sourceFormat) continue;\n    synced[format] = deriveGraphicsFormat(source, format);\n  }\n  return synced;\n}",
                "graphicsLayoutMap": "/** Saved layouts of one language as a format map. */\nexport function graphicsLayoutMap(\n  graphics: LoadSocialGraphicsResponse[\"graphics\"],\n): Partial<SocialLayoutMap> {\n  return Object.fromEntries(\n    graphics.map((graphic) => [graphic.format, graphic.layout]),\n  ) as Partial<SocialLayoutMap>;\n}",
                "selectGraphicsFormat": "/**\n * One format of a saved variant. A format the variant never saved (older\n * variants predate App Store header/search and the ad sizes) is derived from\n * the primary format exactly as the editor does when the user opens it, and\n * flagged in `derivedFormats` so the caller knows it is not stored yet.\n */\nexport function selectGraphicsFormat(\n  data: LoadSocialGraphicsResponse,\n  format: SocialFormat,\n): LoadSocialGraphicsResponse & { derivedFormats: SocialFormat[] } {\n  const saved = data.graphics.filter((graphic) => graphic.format === format);\n  if (saved.length > 0 || data.graphics.length === 0) {\n    return { ...data, graphics: saved, derivedFormats: [] };\n  }\n  const prepared = prepareGraphicsExportLayouts(\n    graphicsLayoutMap(data.graphics),\n    [format],\n    data.language || \"default\",\n    data.socialPrimaryFormat,\n  );\n  return {\n    ...data,\n    graphics: [{ format, layout: prepared[format]! }],\n    derivedFormats: [format],\n  };\n}"
            }
        },
        {
            "group": "graphics",
            "path": "src/app/api/mcp/transform-graphics/apiSchema.ts",
            "sha256": "c86728e279f8a29a1d38c12f8a58b9fc6680f84caf596578e4000e2482c4c323",
            "declarations": {
                "transformGraphicsRequestSchema": "export const transformGraphicsRequestSchema = z\n  .object({\n    generationId: z.string().uuid(),\n    variantId: z.string().uuid().optional(),\n    language: z.string().trim().min(1).max(32).optional(),\n    formats: z\n      .array(socialFormatSchema)\n      .max(SOCIAL_FORMAT_ORDER.length)\n      .refine((values) => new Set(values).size === values.length, {\n        message: \"Duplicate formats are not allowed\",\n      })\n      .optional()\n      .describe(\n        \"Without syncFrom: the formats the operations edit (default: the variant's primary format). With syncFrom: the formats rebuilt from it (default: every other format).\",\n      ),\n    operations: z\n      .array(transformOperationSchema)\n      .max(100)\n      .default([])\n      .describe(\n        \"Layout transform operations (same contract as screenshot transforms). With syncFrom they edit only the syncFrom format before it is propagated.\",\n      ),\n    syncFrom: socialFormatSchema\n      .optional()\n      .describe(\n        \"Editor's 'Sync to all formats': rebuild the target formats from this format's layout, using each template's own composition per format.\",\n      ),\n  })\n  .strict()\n  .refine((input) => input.operations.length > 0 || input.syncFrom, {\n    message: \"Pass at least one operation, syncFrom, or both.\",\n    path: [\"operations\"],\n  });"
            }
        },
        {
            "group": "graphics",
            "path": "src/app/api/graphics/settings/apiSchema.ts",
            "sha256": "fb972a6f5b103e090e78c879b9054a944f48cc03e20ea40a0f7644a10cabb090",
            "declarations": {
                "graphicsSettingsSchema": "export const graphicsSettingsSchema = z\n  .object({\n    generationId: z.string().uuid(),\n    variantId: z.string().uuid().optional(),\n    primaryFormat: socialFormatSchema\n      .optional()\n      .describe(\n        \"Make this format the variant's default (the one the editor opens first). Applies to every language.\",\n      ),\n    platform: z\n      .enum([\"ios\", \"android\"])\n      .optional()\n      .describe(\n        \"Re-skin every saved format and language for this store: device frames/3D models and a lone store badge. Single-platform projects switch their platform too.\",\n      ),\n  })\n  .strict()\n  .refine((input) => input.primaryFormat || input.platform, {\n    message: \"Pass primaryFormat, platform, or both.\",\n    path: [\"primaryFormat\"],\n  });"
            }
        },
        {
            "group": "graphics",
            "path": "src/server/graphics/renderSchema.ts",
            "sha256": "cc305d84c3dd0ed10009ba5b0af27659ba115456f16d1304e6b184f687461696",
            "declarations": {
                "graphicsRenderSchema": "/** Shared by POST /graphics/preview and POST /graphics/export. */\nexport const graphicsRenderSchema = z\n  .object({\n    generationId: z.string().uuid(),\n    variantId: z.string().uuid().optional(),\n    formats: z\n      .array(socialFormatSchema)\n      .min(1)\n      .max(SOCIAL_FORMAT_ORDER.length)\n      .refine((values) => new Set(values).size === values.length, {\n        message: \"Duplicate formats are not allowed\",\n      })\n      .describe(\n        \"Formats to render. Formats the variant never saved are derived from its primary format, as the editor does.\",\n      ),\n    languages: z\n      .array(languageCodeSchema)\n      .min(1)\n      .max(10)\n      .optional()\n      .describe(\"Saved graphics languages. Default: the source language.\"),\n    platform: z\n      .enum([\"ios\", \"android\"])\n      .optional()\n      .describe(\n        \"Store platform for device frames and store badges. Default: the project's platform.\",\n      ),\n  })\n  .strict();"
            }
        },
        {
            "group": "promoVideo",
            "path": "src/features/promovideo/lib/remotion/config/schema.ts",
            "sha256": "3678627c2a2f4dfd12c459da6bfb942ee9ff1e9d90c58afe14585930d7e011da",
            "declarations": {
                "KenBurnsIntensitySchema": "// =============================================================================\n// KEN BURNS CONFIGURATION\n// =============================================================================\n\nconst KenBurnsIntensitySchema = z.preprocess(\n  (value) => {\n    if (value === undefined || value === null) return null;\n    if (value === \"medium\") return \"moderate\";\n    if (value === \"subtle\" || value === \"moderate\" || value === \"dramatic\") {\n      return value;\n    }\n    return null;\n  },\n  z.enum([\"subtle\", \"moderate\", \"dramatic\"]).nullable(),\n);",
                "KenBurnsDirectionSchema": "const KenBurnsDirectionSchema = z.preprocess(\n  (value) => {\n    if (value === undefined || value === null) return null;\n    if (value === \"zoom-in\" || value === \"zoom-out\") return value;\n    if (value === \"pan-left\" || value === \"pan-right\") return value;\n    return null;\n  },\n  z.enum([\"zoom-in\", \"zoom-out\", \"pan-left\", \"pan-right\"]).nullable(),\n);",
                "KenBurnsPresetSchema": "const KenBurnsPresetSchema = z.preprocess(\n  (value) => {\n    if (value === undefined || value === null) return null;\n    if (value === \"zoom-in\") return \"slow-zoom-in\";\n    if (value === \"zoom-out\") return \"slow-zoom-out\";\n    if (\n      value === \"slow-zoom-in\" ||\n      value === \"slow-zoom-out\" ||\n      value === \"pan-left\" ||\n      value === \"pan-right\" ||\n      value === \"zoom-in-pan-right\" ||\n      value === \"zoom-out-pan-left\" ||\n      value === \"subtle-drift\"\n    ) {\n      return value;\n    }\n    return null;\n  },\n  z\n    .enum([\n      \"slow-zoom-in\",\n      \"slow-zoom-out\",\n      \"pan-left\",\n      \"pan-right\",\n      \"zoom-in-pan-right\",\n      \"zoom-out-pan-left\",\n      \"subtle-drift\",\n    ])\n    .nullable(),\n);",
                "KenBurnsNumberSchema": "const KenBurnsNumberSchema = z.preprocess((value) => {\n  if (value === undefined) return null;\n  return value;\n}, z.number().nullable());",
                "KenBurnsConfigSchema": "const KenBurnsConfigSchema = z.object({\n  enabled: z.boolean().optional(),\n  intensity: KenBurnsIntensitySchema.optional(),\n  direction: KenBurnsDirectionSchema.optional(),\n  preset: KenBurnsPresetSchema.optional(),\n  scaleStart: KenBurnsNumberSchema.optional(),\n  scaleEnd: KenBurnsNumberSchema.optional(),\n  panStartX: KenBurnsNumberSchema.optional(),\n  panEndX: KenBurnsNumberSchema.optional(),\n  panStartY: KenBurnsNumberSchema.optional(),\n  panEndY: KenBurnsNumberSchema.optional(),\n});",
                "AtmosphereIntensitySchema": "// =============================================================================\n// ATMOSPHERE CONFIGURATION\n// =============================================================================\n\nconst AtmosphereIntensitySchema = z.preprocess(\n  (value) => (value === \"high\" ? \"dramatic\" : value),\n  z.enum([\"subtle\", \"medium\", \"dramatic\"]),\n);",
                "AtmosphereConfigSchema": "/**\n * Legacy per-scene effects. They no longer render; a video without\n * `theme.ambient` turns its background light on when any scene asked for\n * particles or a gradient (see resolveAmbientLevel).\n */\nconst AtmosphereConfigSchema = z.object({\n  particles: z.boolean().nullable(),\n  gradient: z.boolean().nullable(),\n  glare: z.boolean().nullable(),\n  intensity: AtmosphereIntensitySchema.nullable(),\n});",
                "TextPositionSchema": "// =============================================================================\n// SHARED STYLE CONFIGURATIONS\n// =============================================================================\n\nconst TextPositionSchema = z.object({\n  x: z.number().min(0).max(100),\n  y: z.number().min(0).max(100),\n  // New drags preserve the element's flow slot. Legacy x/y-only positions\n  // remain absolute so existing saved compositions are not reinterpreted.\n  originX: z.number().optional(),\n  originY: z.number().optional(),\n});",
                "RichTextContentSchema": "/**\n * Rich text content (Tiptap `doc` JSON). Loosely typed here — the shape is\n * validated/normalized by the editor (`RichTextEditor` + richTextUtils). Used\n * for free-form text overlays and as the optional rich source of per-field text.\n */\nconst RichTextContentSchema = z.any();",
                "TextStyleSchema": "const TextStyleSchema = z\n  .object({\n    fontSizeScale: z.number().min(0.5).max(2).nullable(),\n    /** Percentage of the scene's available text width; absent preserves auto layout. */\n    width: z.number().min(5).max(100).nullable().optional(),\n    fontWeight: z\n      .enum([\"light\", \"regular\", \"medium\", \"bold\", \"black\"])\n      .nullable(),\n    color: z.string().nullable(),\n    textAlign: z.enum([\"left\", \"center\", \"right\"]).nullable(),\n    position: TextPositionSchema.nullable(),\n    animationSpeed: z.number().min(0.5).max(2).nullable(),\n    // Optional rich (Tiptap) version of the field's text. When present AND it\n    // carries real formatting marks, the renderer draws it statically (with a\n    // fade); otherwise the plain string + animation path is used. The plain\n    // field stays the canonical value (labels, LLM, animations).\n    richContent: RichTextContentSchema.nullable().optional(),\n    // Per-element text animation override. `null`/absent = inherit the scene's\n    // `content.textAnimation`; \"none\" = force no animation on this element.\n    textAnimation: z\n      .enum([\n        \"none\",\n        \"word-reveal\",\n        \"fade\",\n        \"character-cascade\",\n        \"scale-emphasis\",\n        \"typewriter\",\n        \"word-highlight\",\n      ])\n      .nullable(),\n  })\n  .partial();",
                "PhonePositionSchema": "const PhonePositionSchema = z.object({\n  x: z.number().min(0).max(100),\n  y: z.number().min(0).max(100),\n  originX: z.number().optional(),\n  originY: z.number().optional(),\n});",
                "TextStyle": "export type TextStyle = z.infer<typeof TextStyleSchema>;",
                "SceneVideoSchema": "// Optional video clip that replaces the static screenshot. Feature scenes place\n// it inside a device frame; closeup scenes preserve their focus-region zoom.\n// The original optimized source stays intact and the selected window is stored\n// non-destructively so editor preview and export use the same timing information.\nconst SceneVideoSchema = z.object({\n  /** Ephemeral signed URL, re-signed on load/export. */\n  url: z.string().nullable().optional(),\n  /** Project-relative storage path, e.g. \"promo-media/123-clip.webm\". Authoritative. */\n  storagePath: z.string().nullable().optional(),\n  /** Optimized source clip length in seconds (from metadata). */\n  durationSeconds: z.number().nullable().optional(),\n  /** Start offset into the source clip, in seconds (crop selection). */\n  trimStartSeconds: z.number().nullable().optional(),\n  /** Length of the selected source window, in seconds. */\n  trimDurationSeconds: z.number().positive().nullable().optional(),\n  /** Looping is opt-in; clips never repeat merely to fill a scene. */\n  loop: z.boolean().nullable().optional(),\n});",
                "SceneVideo": "export type SceneVideo = z.infer<typeof SceneVideoSchema>;",
                "MediaRotationSchema": "const MediaRotationSchema = z.union([\n  z.literal(0),\n  z.literal(90),\n  z.literal(180),\n  z.literal(270),\n]);",
                "DevicePresentationSchema": "const DevicePresentationSchema = z.object({\n  frameDeviceId: z.enum([\"iphone17\", \"googlepixel\", \"ipad\", \"macbook-pro-16\"]),\n  frameVariant: z.enum([\n    \"flat\",\n    \"left\",\n    \"left-2\",\n    \"right\",\n    \"right-2\",\n    \"handheld\",\n    \"handheld2\",\n    \"handheld3\",\n    \"none\",\n  ]),\n  finish: z\n    .enum([\n      \"silver\",\n      \"black\",\n      \"glacier\",\n      \"burgundy\",\n      \"cosmic-orange\",\n      \"deep-blue\",\n      \"pixel-obsidian\",\n      \"pixel-porcelain\",\n      \"pixel-moonstone\",\n      \"duo-night-sky\",\n      \"duo-star-white\",\n    ])\n    .optional(),\n  rotationX: z.number().finite().optional(),\n  rotationY: z.number().finite().optional(),\n  rotationZ: z.number().finite().optional(),\n  lidOpen: z.number().min(0).max(1).optional(),\n});",
                "SceneDeviceSchema": "const SceneDeviceSchema = z.object({\n  presentation: DevicePresentationSchema.optional(),\n  mediaRotation: MediaRotationSchema.optional(),\n  /** Absent on legacy devices, which keep the video-level baked frame. */\n  deviceModelId: z.enum(DEVICE_MODEL_IDS).optional(),\n  screenshotIndex: z.number().int().min(0),\n  /** Added devices retain their own chassis; omitted on legacy devices. */\n  deviceId: z\n    .enum([\"iphone17\", \"googlepixel\", \"ipad\", \"macbook-pro-16\"])\n    .optional(),\n  /** Clip shown in this device's frame instead of its screenshot. */\n  video: SceneVideoSchema.nullable().optional(),\n  frameVariant: z.enum([\"flat\", \"right\", \"left\"]).default(\"flat\"),\n  position: PhonePositionSchema.default({ x: 50, y: 60 }),\n  scale: z.number().min(0.2).max(4).default(1),\n  rotation: z.number().min(-180).max(180).default(0),\n  opacity: z.number().min(0).max(1).default(1),\n  /** Combined in/out animation preset. */\n  animation: z\n    .enum([\n      \"none\",\n      \"fade\",\n      \"feature-pop\",\n      \"float-up\",\n      \"slide-left\",\n      \"slide-right\",\n      \"zoom-in\",\n      \"drift\",\n      \"pulse\",\n      \"cascade\",\n    ])\n    .default(\"fade\")\n    .optional(),\n  animationDelay: z.number().min(0).max(3).default(0).optional(),\n  /** Legacy field from the split in/out controls. Parsed for old configs only. */\n  outAnimation: z\n    .enum([\n      \"none\",\n      \"fade\",\n      \"feature-pop\",\n      \"float-up\",\n      \"slide-left\",\n      \"slide-right\",\n      \"zoom-in\",\n      \"drift\",\n      \"pulse\",\n      \"cascade\",\n    ])\n    .default(\"none\")\n    .optional(),\n  outAnimationDelay: z.number().min(0).max(3).default(0).optional(),\n});",
                "SceneDevice": "export type SceneDevice = z.infer<typeof SceneDeviceSchema>;",
                "TextAnimationSchema": "// Text animation types - shared across scene types\nconst TextAnimationSchema = z\n  .enum([\n    \"word-reveal\",\n    \"fade\",\n    \"character-cascade\",\n    \"scale-emphasis\",\n    \"typewriter\",\n    \"word-highlight\",\n  ])\n  .nullable()\n  .optional();",
                "TextAnimation": "export type TextAnimation = z.infer<typeof TextAnimationSchema>;",
                "TransitionConfigSchema": "// =============================================================================\n// TRANSITION CONFIGURATION\n// =============================================================================\n\nconst TransitionConfigSchema = z.object({\n  /**\n   * One of PROMO_TRANSITION_TYPES (lib/transitions.ts): none, cross-fade,\n   * slide-left, slide-right, wipe-left, wipe-right, push-up, push-down, zoom.\n   * Kept a string so legacy saved values still load; saves reject new\n   * unknown values. Horizontal names describe content motion (requires the\n   * top-level `transitionNaming: \"content-motion\"` marker).\n   */\n  type: z.string(),\n  duration: z.number().nullable(), // frames\n  /**\n   * Carry this scene's 3D device into the next scene: one device turns\n   * through the transition and lands in the next shot instead of each scene\n   * entering its own. Only honoured between two 3D feature scenes that use the\n   * same device model (see `planDeviceCarries`).\n   */\n  carryDevice: z.boolean().optional(),\n});",
                "VideoBackgroundGradientSchema": "// =============================================================================\n// THEME\n// =============================================================================\n\nconst VideoBackgroundGradientSchema = z\n  .object({\n    type: z.enum([\"linear\", \"radial\"]),\n    colors: z.array(z.string()).min(2),\n    direction: z.number().min(0).max(360).optional(),\n  })\n  .optional()\n  .nullable();",
                "VideoThemeSchema": "const VideoThemeSchema = z.object({\n  colors: z.object({\n    primary: z.string(),\n    secondary: z.string(),\n    background: z.string(),\n    text: z.string(),\n    textSecondary: z.string(),\n  }),\n  backgroundGradient: VideoBackgroundGradientSchema.optional(),\n  /**\n   * Living background behind every scene: soft colour light that drifts\n   * through the whole video. Unset on older videos (derived from their\n   * per-scene atmosphere).\n   */\n  ambient: z.enum([\"none\", \"subtle\", \"rich\"]).nullable().optional(),\n  typography: z.object({\n    fontFamily: z.string(),\n    titleSize: z.number(),\n    subtitleSize: z.number(),\n  }),\n});",
                "VideoTheme": "export type VideoTheme = z.infer<typeof VideoThemeSchema>;",
                "HookContentSchema": "// =============================================================================\n// SCENE CONTENT TYPES\n// =============================================================================\n\nconst HookContentSchema = z.object({\n  appName: z.string().nullable(),\n  appNameImageUrl: z.string().nullable().optional(),\n  tagline: z.string().nullable(),\n  logoUrl: z.string().nullable(),\n  showLogo: z.boolean().nullable(),\n  appNameStyle: TextStyleSchema.nullable().optional(),\n  taglineStyle: TextStyleSchema.nullable().optional(),\n  logoPosition: PhonePositionSchema.nullable().optional(),\n  logoScale: z.number().min(0.5).max(2).nullable().optional(),\n  /** Padding inside the logo card, 0–0.45 (lower = tighter around the icon). */\n  logoPadding: z.number().min(0).max(0.45).nullable().optional(),\n  textAnimation: TextAnimationSchema,\n});",
                "HookContent": "export type HookContent = z.infer<typeof HookContentSchema>;",
                "FeatureContentSchema": "const FeatureContentSchema = z.object({\n  mediaRotation: MediaRotationSchema.optional(),\n  screenshotIndex: z.number().int().min(0),\n  video: SceneVideoSchema.nullable().optional(),\n  frameVariant: z.enum([\"flat\", \"right\", \"left\"]).nullable(),\n  title: z.string(),\n  subtitle: z.string().nullable(),\n  titleStyle: TextStyleSchema.nullable().optional(),\n  subtitleStyle: TextStyleSchema.nullable().optional(),\n  phonePosition: PhonePositionSchema.nullable().optional(),\n  phoneScale: z.number().min(0.5).max(2.5).nullable().optional(),\n  phoneRotation: z.number().min(-90).max(90).nullable().optional(),\n  textAnimation: TextAnimationSchema,\n  /** Disable the phone's zoom-through exit animation at the end of the scene. */\n  disablePhoneOut: z.boolean().nullable().optional(),\n  /**\n   * Legacy: feature scenes always render the 3D device now. \"frame\" scenes\n   * keep their choreography and frame variant as the 3D motion and angle.\n   */\n  devicePresentation: z.enum([\"frame\", \"model-3d\"]).nullable().optional(),\n  /**\n   * Curated 3D device motion (see motion3d/promoMotion), or \"custom\" for the\n   * keyframes in `modelCustomMotion`.\n   */\n  modelAnimation: z\n    .enum([...PROMO_MOTION_IDS, \"custom\"])\n    .nullable()\n    .optional(),\n  /** AI-described motion: keyframes in normalized scene time (0 → 1). */\n  modelCustomMotion: z\n    .object({\n      label: z.string(),\n      description: z.string().optional(),\n      keyframes: z\n        .array(\n          z.object({\n            id: z.string(),\n            time: z.number().min(0).max(1),\n            x: z.number(),\n            y: z.number(),\n            rotationX: z.number(),\n            rotationY: z.number(),\n            rotationZ: z.number(),\n            scale: z.number(),\n            lidOpen: z.number().min(0).max(1).optional(),\n            easing: z.enum([\"linear\", \"ease-in\", \"ease-out\", \"ease-in-out\"]),\n          }),\n        )\n        .min(2),\n    })\n    .nullable()\n    .optional(),\n  /** Chassis finish for the selected iPhone or Pixel GLB. */\n  modelLidOpen: z.number().min(0).max(1).optional(),\n  modelFinish: z\n    .enum([\n      \"silver\",\n      \"black\",\n      \"glacier\",\n      \"burgundy\",\n      \"cosmic-orange\",\n      \"deep-blue\",\n      \"pixel-obsidian\",\n      \"pixel-porcelain\",\n      \"pixel-moonstone\",\n      \"duo-night-sky\",\n      \"duo-star-white\",\n    ])\n    .nullable()\n    .optional(),\n});",
                "FeatureContent": "export type FeatureContent = z.infer<typeof FeatureContentSchema>;",
                "TEXT_ONLY_LAYOUTS": "const TEXT_ONLY_LAYOUTS = [\"centered\", \"statement\"] as const;",
                "TextOnlyContentSchema": "const TextOnlyContentSchema = z.object({\n  headline: z.string(),\n  /** \"centered\" title card (default) or \"statement\" (the headline as large as the frame allows). */\n  layout: z.enum(TEXT_ONLY_LAYOUTS).nullable().optional(),\n  /** A word or short phrase of the headline drawn in the accent colour. */\n  emphasis: z.string().nullable().optional(),\n  subheadline: z.string().nullable(),\n  textAnimation: TextAnimationSchema,\n  headlineStyle: TextStyleSchema.nullable().optional(),\n  subheadlineStyle: TextStyleSchema.nullable().optional(),\n});",
                "TextOnlyContent": "export type TextOnlyContent = z.infer<typeof TextOnlyContentSchema>;",
                "CloseupContentSchema": "const CloseupContentSchema = z.object({\n  mediaRotation: MediaRotationSchema.optional(),\n  screenshotIndex: z.number().int().min(0),\n  video: SceneVideoSchema.nullable().optional(),\n  focusRegion: z\n    .object({\n      x: z.number().min(0).max(100),\n      y: z.number().min(0).max(100),\n      width: z.number().min(1).max(100),\n      height: z.number().min(1).max(100),\n    })\n    .nullable(),\n  caption: z.string().nullable(),\n  captionStyle: TextStyleSchema.nullable().optional(),\n  frameVariant: z.enum([\"flat\", \"right\", \"left\"]).nullable().optional(),\n  phonePosition: PhonePositionSchema.nullable().optional(),\n  phoneScale: z.number().min(0.5).max(2.5).nullable().optional(),\n  // Where the zoomed focus window sits on screen (center, in % of the frame).\n  // Defaults to centered / lower-third when unset. Dragged on the preview.\n  focusPosition: PhonePositionSchema.nullable().optional(),\n  textAnimation: TextAnimationSchema,\n});",
                "CloseupContent": "export type CloseupContent = z.infer<typeof CloseupContentSchema>;",
                "MultiPhoneContentSchema": "const MultiPhoneContentSchema = z.object({\n  // Preserve the authored arrangement when adding slots; explicit layout picks reset it.\n  layoutDeviceCount: z.number().int().min(1).max(6).nullable().optional(),\n  devicePresentations: z\n    .array(DevicePresentationSchema.nullable())\n    .max(6)\n    .nullable()\n    .optional(),\n  mediaRotations: z\n    .array(MediaRotationSchema.nullable())\n    .max(6)\n    .nullable()\n    .optional(),\n  deviceModels: z\n    .array(z.enum(DEVICE_MODEL_IDS).nullable())\n    .max(6)\n    .nullable()\n    .optional(),\n  screenshotIndexes: z.array(z.number().int().min(0)).min(1).max(6),\n  layout: z.enum([\"cascade\", \"fan\", \"side-by-side\", \"custom\"]),\n  title: z.string().nullable(),\n  titleStyle: TextStyleSchema.nullable().optional(),\n  // Per-element `.catch(\"flat\")` self-heals legacy/sparse arrays: editing a\n  // non-zero phone index used to persist holes (e.g. [null, null, \"left\"]),\n  // which JSON-serialize to null and would otherwise reject the ENTIRE config\n  // and drop the user into the onboarding tutorial. Coerce bad entries instead.\n  frameVariants: z\n    .array(z.enum([\"flat\", \"left\", \"right\"]).catch(\"flat\"))\n    .max(6)\n    .nullable()\n    .optional(),\n  /**\n   * Per-phone video clips, by phone index. A slot holding a clip renders it in\n   * the frame instead of its screenshot, so a multi-phone scene can mix stills\n   * and recordings. Sparse like the arrays above: index alignment is what maps\n   * a clip to a phone.\n   */\n  videos: z.array(SceneVideoSchema.nullable()).max(6).nullable().optional(),\n  /** Per-phone manual position overrides (from dragging), by phone index. */\n  phonePositions: z\n    .array(PhonePositionSchema.nullable())\n    .max(6)\n    .nullable()\n    .optional(),\n  phoneScales: z\n    .array(\n      z\n        .number()\n        .min(0.4)\n        .max(2.5)\n        .nullable()\n        .transform((value) => value ?? 1),\n    )\n    .max(6)\n    .nullable()\n    .optional(),\n  phoneRotations: z\n    .array(\n      z\n        .number()\n        .min(-90)\n        .max(90)\n        .nullable()\n        .transform((value) => value ?? 0),\n    )\n    .max(6)\n    .nullable()\n    .optional(),\n  textAnimation: TextAnimationSchema,\n});",
                "MultiPhoneContent": "export type MultiPhoneContent = z.infer<typeof MultiPhoneContentSchema>;",
                "CTAContentSchema": "const CTAContentSchema = z.object({\n  appName: z.string().nullable(),\n  appNameImageUrl: z.string().nullable().optional(),\n  downloadText: z.string().nullable(),\n  badge: z.enum([\"appStore\", \"googlePlay\", \"both\", \"none\"]).nullable(),\n  /** Manual badge placement (from dragging); centered in flow when unset. */\n  badgePosition: TextPositionSchema.nullable().optional(),\n  badgeScale: z.number().min(0.3).max(2).nullable().optional(),\n  logoUrl: z.string().nullable().optional(),\n  showLogo: z.boolean().nullable().optional(),\n  logoPosition: PhonePositionSchema.nullable().optional(),\n  logoScale: z.number().min(0.5).max(2).nullable().optional(),\n  /** Padding inside the logo card, 0–0.45 (lower = tighter around the icon). */\n  logoPadding: z.number().min(0).max(0.45).nullable().optional(),\n  appNameStyle: TextStyleSchema.nullable().optional(),\n  downloadTextStyle: TextStyleSchema.nullable().optional(),\n  textAnimation: TextAnimationSchema,\n});",
                "CTAContent": "export type CTAContent = z.infer<typeof CTAContentSchema>;",
                "IllustrationOverlaySchema": "// =============================================================================\n// ILLUSTRATION OVERLAY\n// =============================================================================\n\nconst IllustrationOverlaySchema = z.object({\n  url: z.string(),\n  storagePath: z.string().optional(),\n  x: z.number().min(0).max(100).default(50),\n  y: z.number().min(0).max(100).default(50),\n  scale: z.number().min(0.1).max(4).default(1),\n  opacity: z.number().min(0).max(1).default(1),\n  rotation: z.number().min(-180).max(180).default(0),\n});",
                "IllustrationOverlay": "export type IllustrationOverlay = z.infer<typeof IllustrationOverlaySchema>;",
                "TextOverlaySchema": "// =============================================================================\n// FREE-FORM TEXT OVERLAY\n// =============================================================================\n\n// Free-form rich text placed anywhere on a scene (added via the \"Add Text\"\n// button). Position/scale/rotation/opacity mirror IllustrationOverlay so it\n// plugs into the same drag system; `richContent` holds the Tiptap doc.\nconst TextOverlaySchema = z.object({\n  lineHeight: z.number().min(0.8).max(2).optional(),\n  richContent: RichTextContentSchema,\n  /** Plain-text mirror of richContent (for labels / accessibility). */\n  text: z.string().optional(),\n  x: z.number().min(0).max(100).default(50),\n  y: z.number().min(0).max(100).default(50),\n  scale: z.number().min(0.1).max(4).default(1),\n  rotation: z.number().min(-180).max(180).default(0),\n  opacity: z.number().min(0).max(1).default(1),\n  /** Wrap width as a percentage of the frame width. */\n  width: z.number().min(5).max(100).default(60),\n  textAlign: z.enum([\"left\", \"center\", \"right\"]).default(\"center\"),\n  /** Multiplier applied to the base overlay font size. */\n  fontSizeScale: z.number().min(0.2).max(4).default(1),\n  textAnimation: TextAnimationSchema,\n});",
                "TextOverlay": "export type TextOverlay = z.infer<typeof TextOverlaySchema>;",
                "BaseScenePropsSchema": "// =============================================================================\n// BASE SCENE PROPERTIES (shared by all scene types)\n// =============================================================================\n\nconst BaseScenePropsSchema = z.object({\n  /** Editor-only timeline label; never rendered into the video. */\n  label: z.string().max(80).optional(),\n  duration: z.number().min(1).max(15).nullable(),\n  choreography: z.string().nullable(), // choreography preset ID\n  kenBurns: KenBurnsConfigSchema.nullable().optional(),\n  atmosphere: AtmosphereConfigSchema.nullable().optional(),\n  transition: TransitionConfigSchema.nullable().optional(),\n  animationSpeed: z.number().min(0.5).max(2).nullable().optional(),\n  illustrations: z.array(IllustrationOverlaySchema).optional(),\n  textOverlays: z.array(TextOverlaySchema).optional(),\n  devices: z.array(SceneDeviceSchema).max(12).optional(),\n  hiddenElements: z.array(z.string()).optional(),\n  /**\n   * Skipped scene: kept in the project and editable, but excluded from\n   * playback, export, duration fitting, and audio cues. Lets users park a\n   * scene they are unsure about instead of deleting it.\n   */\n  hidden: z.boolean().optional(),\n});",
                "HookSceneSchema": "// =============================================================================\n// SCENE SCHEMAS\n// =============================================================================\n\nconst HookSceneSchema = BaseScenePropsSchema.extend({\n  type: z.literal(\"hook\"),\n  content: HookContentSchema,\n});",
                "FeatureSceneSchema": "const FeatureSceneSchema = BaseScenePropsSchema.extend({\n  type: z.literal(\"feature\"),\n  content: FeatureContentSchema,\n});",
                "TextOnlySceneSchema": "const TextOnlySceneSchema = BaseScenePropsSchema.extend({\n  type: z.literal(\"text-only\"),\n  content: TextOnlyContentSchema,\n});",
                "CloseupSceneSchema": "const CloseupSceneSchema = BaseScenePropsSchema.extend({\n  type: z.literal(\"closeup\"),\n  content: CloseupContentSchema,\n});",
                "MultiPhoneSceneSchema": "const MultiPhoneSceneSchema = BaseScenePropsSchema.extend({\n  type: z.literal(\"multi-phone\"),\n  content: MultiPhoneContentSchema,\n});",
                "CTASceneSchema": "const CTASceneSchema = BaseScenePropsSchema.extend({\n  type: z.literal(\"cta\"),\n  content: CTAContentSchema,\n});",
                "SceneSchema": "// =============================================================================\n// COMBINED SCENE TYPE\n// =============================================================================\n\nconst SceneSchema = z.discriminatedUnion(\"type\", [\n  HookSceneSchema,\n  FeatureSceneSchema,\n  TextOnlySceneSchema,\n  CloseupSceneSchema,\n  MultiPhoneSceneSchema,\n  CTASceneSchema,\n]);",
                "Scene": "export type Scene = z.infer<typeof SceneSchema>;",
                "AudioTrackSchema": "// =============================================================================\n// AUDIO (single background music track)\n// =============================================================================\n\nconst AudioTrackSchema = z.object({\n  /** Project-relative storage path, e.g. \"promo-media/123-track.mp3\". Authoritative. */\n  storagePath: z.string(),\n  /** Ephemeral signed URL, re-signed on load/export. */\n  url: z.string().nullable().optional(),\n  /** Original filename, for display in the editor. */\n  fileName: z.string().nullable().optional(),\n  /** Duration of the uploaded source track, used to bound segment selection. */\n  sourceDurationSeconds: z.number().positive().nullable().optional(),\n  /** Offset into the source track where promo-video playback begins. */\n  startTimeSeconds: z.number().min(0).default(0),\n  /** Playback volume, 0–1. */\n  volume: z.number().min(0).max(1).default(0.8),\n  /** Fade-in length in seconds. */\n  fadeInSeconds: z.number().min(0).max(10).default(0),\n  /** Fade-out length in seconds. */\n  fadeOutSeconds: z.number().min(0).max(10).default(0),\n});",
                "AudioTrack": "export type AudioTrack = z.infer<typeof AudioTrackSchema>;",
                "AudioDesignSchema": "const AudioDesignSchema = z.object({\n  /** Automatically match one-shot effects to scene and transition motion. */\n  effectsEnabled: z.boolean().default(true),\n  /** Master gain applied to every derived motion effect. */\n  effectsVolume: z.number().min(0).max(1).default(0.4),\n  /** Bundled soundtrack chosen for generated videos. Uploaded music overrides it. */\n  musicId: z.enum(BACKGROUND_MUSIC_IDS).nullable().optional(),\n  /** Master gain for the bundled soundtrack. */\n  musicVolume: z.number().min(0).max(1).default(0.24),\n  /** Offset into the bundled soundtrack used at the start of the video. */\n  musicStartTimeSeconds: z.number().min(0).default(0),\n});",
                "AudioDesign": "export type AudioDesign = z.infer<typeof AudioDesignSchema>;",
                "AiGenerationSchema": "// =============================================================================\n// AI GENERATION IDENTITY\n// =============================================================================\n\n/** Which AI generation (and candidate) produced this video; analytics only. */\nconst AiGenerationSchema = z.object({\n  id: z.string().min(1).max(80),\n  candidateId: z.string().max(80).nullable().optional(),\n  source: z.enum(PROMO_GENERATION_SOURCES).nullable().optional(),\n  round: z.number().int().min(0).max(1000).nullable().optional(),\n});",
                "VideoConfigSchema": "// =============================================================================\n// VIDEO CONFIG\n// =============================================================================\n\nexport const VideoConfigSchema = z.object({\n  previewFormat: z\n    .enum(\n      PREVIEW_FORMAT_OPTIONS.map((option) => option.value) as [\n        PreviewFormat,\n        ...PreviewFormat[],\n      ],\n    )\n    .optional(),\n  version: z.union([z.literal(1), z.literal(2)]).nullable(),\n  /** Set once transition names mean content motion (see transitionNaming.ts). */\n  transitionNaming: z.literal(\"content-motion\").optional(),\n  theme: VideoThemeSchema,\n  scenes: z.array(SceneSchema).min(1),\n  duration: z.number().nullable(),\n  /** Optional single background music track spanning the whole video. */\n  audio: AudioTrackSchema.nullable().optional(),\n  /** Built-in motion effects for generated videos. */\n  audioDesign: AudioDesignSchema.nullable().optional(),\n  /** Phone model used for device frames (e.g. \"iphone17\", \"googlepixel\"). Defaults to iphone17. */\n  phoneId: z.string().nullable().optional(),\n  /** Canonical real 3D chassis used by feature scenes. */\n  deviceModelId: z.enum(DEVICE_MODEL_IDS).nullable().optional(),\n  /** Stable project-relative library paths indexed by every scene/overlay. */\n  screenshotPaths: z.array(z.string()).optional(),\n  /** Generation that produced this video; carried to edit/export analytics. */\n  aiGeneration: AiGenerationSchema.nullable().optional(),\n});",
                "VideoConfig": "export type VideoConfig = z.infer<typeof VideoConfigSchema>;",
                "isHookScene": "// =============================================================================\n// TYPE GUARDS\n// =============================================================================\n\nexport function isHookScene(\n  scene: Scene,\n): scene is z.infer<typeof HookSceneSchema> {\n  return scene.type === \"hook\";\n}",
                "isFeatureScene": "export function isFeatureScene(\n  scene: Scene,\n): scene is z.infer<typeof FeatureSceneSchema> {\n  return scene.type === \"feature\";\n}",
                "isTextOnlyScene": "export function isTextOnlyScene(\n  scene: Scene,\n): scene is z.infer<typeof TextOnlySceneSchema> {\n  return scene.type === \"text-only\";\n}",
                "isCloseupScene": "export function isCloseupScene(\n  scene: Scene,\n): scene is z.infer<typeof CloseupSceneSchema> {\n  return scene.type === \"closeup\";\n}",
                "isMultiPhoneScene": "export function isMultiPhoneScene(\n  scene: Scene,\n): scene is z.infer<typeof MultiPhoneSceneSchema> {\n  return scene.type === \"multi-phone\";\n}",
                "isPhoneScene": "/** Scenes that render a built-in multi-phone content block. */\nexport function isPhoneScene<T extends Scene>(\n  scene: T,\n): scene is T & { type: \"multi-phone\" } {\n  return scene.type === \"multi-phone\";\n}",
                "isCTAScene": "export function isCTAScene(\n  scene: Scene,\n): scene is z.infer<typeof CTASceneSchema> {\n  return scene.type === \"cta\";\n}",
                "NormalizedScene": "// =============================================================================\n// NORMALIZED TYPES (with required duration)\n// =============================================================================\n\nexport type NormalizedScene = Scene & { duration: number };",
                "NormalizedVideoConfig": "export type NormalizedVideoConfig = VideoConfig & {\n  theme: VideoTheme;\n  scenes: NormalizedScene[];\n  duration: number;\n};"
            }
        },
        {
            "group": "promoVideo",
            "path": "src/features/promovideo/lib/remotion/choreography/presets.ts",
            "sha256": "05cc2c4ec2eefd7dafa3fc52a965f99e0e3a8981e91ffad8665fd9ab43a84b93",
            "declarations": {
                "heroCenter": "/**\n * Choreography Presets Library\n * 15+ cinematic motion presets for App Store preview videos\n * All keyframes use percentage-based timing (0-100%)\n */\n\n// =============================================================================\n// ENTRANCE CHOREOGRAPHIES (Single Phone)\n// =============================================================================\n\nconst heroCenter: ChoreographyPreset = {\n  id: \"hero-center\",\n  name: \"Hero Center\",\n  description: \"Phone scales up dramatically from center with spring physics\",\n  category: \"entrance\",\n  tags: [\"dramatic\", \"entrance\", \"single\", \"premium\"],\n  tracks: [\n    {\n      elementId: \"phone-0\",\n      keyframes: [\n        { percent: 0, x: 50, y: 55, scale: 0.3, opacity: 0, easing: \"spring\" },\n        { percent: 40, x: 50, y: 50, scale: 1, opacity: 1, easing: \"spring\" },\n        { percent: 100, x: 50, y: 50, scale: 1, opacity: 1 },\n      ],\n    },\n  ],\n  minDuration: 3,\n  useAngledFrame: false,\n};",
                "createHorizontalSlidePreset": "function createHorizontalSlidePreset(options: {\n  id: \"slide-left\" | \"slide-right\";\n  name: string;\n  description: string;\n  startX: number;\n}): ChoreographyPreset {\n  return {\n    id: options.id,\n    name: options.name,\n    description: options.description,\n    category: \"entrance\",\n    tags: [\"smooth\", \"entrance\", \"single\", \"clean\"],\n    tracks: [\n      {\n        elementId: \"phone-0\",\n        keyframes: [\n          {\n            percent: 0,\n            x: options.startX,\n            y: 50,\n            scale: 0.9,\n            opacity: 0,\n            easing: \"ease-out\",\n          },\n          {\n            percent: 35,\n            x: 50,\n            y: 50,\n            scale: 1,\n            opacity: 1,\n            easing: \"ease-out\",\n          },\n          { percent: 100, x: 50, y: 50, scale: 1, opacity: 1 },\n        ],\n      },\n    ],\n    minDuration: 2.5,\n    useAngledFrame: true,\n  };\n}",
                "slideLeft": "const slideLeft = createHorizontalSlidePreset({\n  id: \"slide-left\",\n  name: \"Slide Left\",\n  description: \"Phone slides in smoothly from the right side\",\n  startX: 120,\n});",
                "slideRight": "const slideRight = createHorizontalSlidePreset({\n  id: \"slide-right\",\n  name: \"Slide Right\",\n  description: \"Phone slides in smoothly from the left side\",\n  startX: -20,\n});",
                "dropBounce": "const dropBounce: ChoreographyPreset = {\n  id: \"drop-bounce\",\n  name: \"Drop Bounce\",\n  description: \"Phone drops from top with playful bounce effect\",\n  category: \"entrance\",\n  tags: [\"playful\", \"entrance\", \"single\", \"dynamic\"],\n  tracks: [\n    {\n      elementId: \"phone-0\",\n      keyframes: [\n        {\n          percent: 0,\n          x: 50,\n          y: -30,\n          scale: 0.95,\n          opacity: 0,\n          easing: \"bounce\",\n        },\n        { percent: 50, x: 50, y: 50, scale: 1, opacity: 1, easing: \"bounce\" },\n        { percent: 100, x: 50, y: 50, scale: 1, opacity: 1 },\n      ],\n    },\n  ],\n  minDuration: 3,\n  useAngledFrame: false,\n};",
                "zoomThrough": "const zoomThrough: ChoreographyPreset = {\n  id: \"zoom-through\",\n  name: \"Zoom Through\",\n  description: \"Camera dramatically zooms through to reveal phone\",\n  category: \"entrance\",\n  tags: [\"dramatic\", \"entrance\", \"single\", \"cinematic\"],\n  tracks: [\n    {\n      elementId: \"phone-0\",\n      keyframes: [\n        { percent: 0, x: 50, y: 50, scale: 3, opacity: 0, easing: \"ease-out\" },\n        { percent: 50, x: 50, y: 50, scale: 1, opacity: 1, easing: \"ease-out\" },\n        { percent: 100, x: 50, y: 50, scale: 1, opacity: 1 },\n      ],\n    },\n  ],\n  minDuration: 3,\n  useAngledFrame: false,\n};",
                "rotateIn": "const rotateIn: ChoreographyPreset = {\n  id: \"rotate-in\",\n  name: \"Rotate In\",\n  description: \"Phone rotates while entering for dynamic reveal\",\n  category: \"entrance\",\n  tags: [\"dynamic\", \"entrance\", \"single\", \"premium\"],\n  tracks: [\n    {\n      elementId: \"phone-0\",\n      keyframes: [\n        {\n          percent: 0,\n          x: 50,\n          y: 60,\n          scale: 0.7,\n          rotation: -15,\n          opacity: 0,\n          easing: \"spring\",\n        },\n        {\n          percent: 45,\n          x: 50,\n          y: 50,\n          scale: 1,\n          rotation: 0,\n          opacity: 1,\n          easing: \"spring\",\n        },\n        { percent: 100, x: 50, y: 50, scale: 1, rotation: 0, opacity: 1 },\n      ],\n    },\n  ],\n  minDuration: 3,\n  useAngledFrame: true,\n};",
                "floatUp": "const floatUp: ChoreographyPreset = {\n  id: \"float-up\",\n  name: \"Float Up\",\n  description: \"Phone gently floats up from below with subtle motion\",\n  category: \"entrance\",\n  tags: [\"subtle\", \"entrance\", \"single\", \"smooth\"],\n  tracks: [\n    {\n      elementId: \"phone-0\",\n      keyframes: [\n        {\n          percent: 0,\n          x: 50,\n          y: 70,\n          scale: 0.95,\n          opacity: 0,\n          easing: \"ease-out\",\n        },\n        { percent: 40, x: 50, y: 50, scale: 1, opacity: 1, easing: \"ease-out\" },\n        { percent: 100, x: 50, y: 50, scale: 1, opacity: 1 },\n      ],\n    },\n  ],\n  minDuration: 2.5,\n  useAngledFrame: false,\n};",
                "cascadeLeft": "// =============================================================================\n// MULTI-PHONE CHOREOGRAPHIES\n// =============================================================================\n\nconst cascadeLeft: ChoreographyPreset = {\n  id: \"cascade-left\",\n  name: \"Cascade Left\",\n  description: \"Phones cascade in from right, creating a stacked visual\",\n  category: \"multi-phone\",\n  tags: [\"comparison\", \"multi\", \"smooth\", \"showcase\"],\n  multiPhoneLayout: {\n    type: \"cascade\",\n    positions: [\n      { x: 65, y: 50, scale: 0.75, rotation: 5, zIndex: 3 },\n      { x: 50, y: 52, scale: 0.75, rotation: 3, zIndex: 2 },\n      { x: 35, y: 54, scale: 0.75, rotation: 0, zIndex: 1 },\n    ],\n    staggerDelay: 6,\n  },\n  tracks: [\n    {\n      elementId: \"phone-0\",\n      keyframes: [\n        {\n          percent: 0,\n          x: 120,\n          y: 50,\n          scale: 0.75,\n          rotation: 5,\n          opacity: 0,\n          easing: \"spring\",\n        },\n        {\n          percent: 40,\n          x: 65,\n          y: 50,\n          scale: 0.75,\n          rotation: 5,\n          opacity: 1,\n          easing: \"spring\",\n        },\n        { percent: 100, x: 65, y: 50, scale: 0.75, rotation: 5, opacity: 1 },\n      ],\n    },\n    {\n      elementId: \"phone-1\",\n      keyframes: [\n        { percent: 0, x: 120, y: 52, scale: 0.75, rotation: 3, opacity: 0 },\n        {\n          percent: 12,\n          x: 120,\n          y: 52,\n          scale: 0.75,\n          rotation: 3,\n          opacity: 0,\n          easing: \"spring\",\n        },\n        {\n          percent: 52,\n          x: 50,\n          y: 52,\n          scale: 0.75,\n          rotation: 3,\n          opacity: 1,\n          easing: \"spring\",\n        },\n        { percent: 100, x: 50, y: 52, scale: 0.75, rotation: 3, opacity: 1 },\n      ],\n    },\n    {\n      elementId: \"phone-2\",\n      keyframes: [\n        { percent: 0, x: 120, y: 54, scale: 0.75, rotation: 0, opacity: 0 },\n        {\n          percent: 24,\n          x: 120,\n          y: 54,\n          scale: 0.75,\n          rotation: 0,\n          opacity: 0,\n          easing: \"spring\",\n        },\n        {\n          percent: 64,\n          x: 35,\n          y: 54,\n          scale: 0.75,\n          rotation: 0,\n          opacity: 1,\n          easing: \"spring\",\n        },\n        { percent: 100, x: 35, y: 54, scale: 0.75, rotation: 0, opacity: 1 },\n      ],\n    },\n  ],\n  minDuration: 4,\n  useAngledFrame: true,\n};",
                "cascadeRight": "const cascadeRight: ChoreographyPreset = {\n  id: \"cascade-right\",\n  name: \"Cascade Right\",\n  description: \"Phones cascade in from left, creating a stacked visual\",\n  category: \"multi-phone\",\n  tags: [\"comparison\", \"multi\", \"smooth\", \"showcase\"],\n  multiPhoneLayout: {\n    type: \"cascade\",\n    positions: [\n      { x: 35, y: 50, scale: 0.75, rotation: -5, zIndex: 3 },\n      { x: 50, y: 52, scale: 0.75, rotation: -3, zIndex: 2 },\n      { x: 65, y: 54, scale: 0.75, rotation: 0, zIndex: 1 },\n    ],\n    staggerDelay: 6,\n  },\n  tracks: [\n    {\n      elementId: \"phone-0\",\n      keyframes: [\n        {\n          percent: 0,\n          x: -20,\n          y: 50,\n          scale: 0.75,\n          rotation: -5,\n          opacity: 0,\n          easing: \"spring\",\n        },\n        {\n          percent: 40,\n          x: 35,\n          y: 50,\n          scale: 0.75,\n          rotation: -5,\n          opacity: 1,\n          easing: \"spring\",\n        },\n        { percent: 100, x: 35, y: 50, scale: 0.75, rotation: -5, opacity: 1 },\n      ],\n    },\n    {\n      elementId: \"phone-1\",\n      keyframes: [\n        { percent: 0, x: -20, y: 52, scale: 0.75, rotation: -3, opacity: 0 },\n        {\n          percent: 12,\n          x: -20,\n          y: 52,\n          scale: 0.75,\n          rotation: -3,\n          opacity: 0,\n          easing: \"spring\",\n        },\n        {\n          percent: 52,\n          x: 50,\n          y: 52,\n          scale: 0.75,\n          rotation: -3,\n          opacity: 1,\n          easing: \"spring\",\n        },\n        { percent: 100, x: 50, y: 52, scale: 0.75, rotation: -3, opacity: 1 },\n      ],\n    },\n    {\n      elementId: \"phone-2\",\n      keyframes: [\n        { percent: 0, x: -20, y: 54, scale: 0.75, rotation: 0, opacity: 0 },\n        {\n          percent: 24,\n          x: -20,\n          y: 54,\n          scale: 0.75,\n          rotation: 0,\n          opacity: 0,\n          easing: \"spring\",\n        },\n        {\n          percent: 64,\n          x: 65,\n          y: 54,\n          scale: 0.75,\n          rotation: 0,\n          opacity: 1,\n          easing: \"spring\",\n        },\n        { percent: 100, x: 65, y: 54, scale: 0.75, rotation: 0, opacity: 1 },\n      ],\n    },\n  ],\n  minDuration: 4,\n  useAngledFrame: true,\n};",
                "fanOut": "const fanOut: ChoreographyPreset = {\n  id: \"fan-out\",\n  name: \"Fan Out\",\n  description: \"Phones fan out dramatically from center\",\n  category: \"multi-phone\",\n  tags: [\"showcase\", \"multi\", \"dramatic\", \"premium\"],\n  multiPhoneLayout: {\n    type: \"fan\",\n    positions: [\n      { x: 50, y: 52, scale: 0.75, rotation: 0, zIndex: 3 },\n      { x: 28, y: 58, scale: 0.75, rotation: -10, zIndex: 2 },\n      { x: 72, y: 58, scale: 0.75, rotation: 10, zIndex: 2 },\n    ],\n    staggerDelay: 4,\n  },\n  tracks: [\n    {\n      elementId: \"phone-0\",\n      keyframes: [\n        {\n          percent: 0,\n          x: 50,\n          y: 50,\n          scale: 0.3,\n          rotation: 0,\n          opacity: 0,\n          easing: \"spring\",\n        },\n        {\n          percent: 40,\n          x: 50,\n          y: 52,\n          scale: 0.75,\n          rotation: 0,\n          opacity: 1,\n          easing: \"spring\",\n        },\n        { percent: 100, x: 50, y: 52, scale: 0.75, rotation: 0, opacity: 1 },\n      ],\n    },\n    {\n      elementId: \"phone-1\",\n      keyframes: [\n        { percent: 0, x: 50, y: 50, scale: 0.3, rotation: 0, opacity: 0 },\n        {\n          percent: 8,\n          x: 50,\n          y: 50,\n          scale: 0.3,\n          rotation: 0,\n          opacity: 0,\n          easing: \"spring\",\n        },\n        {\n          percent: 48,\n          x: 28,\n          y: 58,\n          scale: 0.75,\n          rotation: -10,\n          opacity: 1,\n          easing: \"spring\",\n        },\n        { percent: 100, x: 28, y: 58, scale: 0.75, rotation: -10, opacity: 1 },\n      ],\n    },\n    {\n      elementId: \"phone-2\",\n      keyframes: [\n        { percent: 0, x: 50, y: 50, scale: 0.3, rotation: 0, opacity: 0 },\n        {\n          percent: 16,\n          x: 50,\n          y: 50,\n          scale: 0.3,\n          rotation: 0,\n          opacity: 0,\n          easing: \"spring\",\n        },\n        {\n          percent: 56,\n          x: 72,\n          y: 58,\n          scale: 0.75,\n          rotation: 10,\n          opacity: 1,\n          easing: \"spring\",\n        },\n        { percent: 100, x: 72, y: 58, scale: 0.75, rotation: 10, opacity: 1 },\n      ],\n    },\n  ],\n  minDuration: 4,\n  useAngledFrame: false,\n};",
                "sideBySide": "const sideBySide: ChoreographyPreset = {\n  id: \"side-by-side\",\n  name: \"Side by Side\",\n  description: \"Two phones slide to side-by-side comparison layout\",\n  category: \"multi-phone\",\n  tags: [\"comparison\", \"multi\", \"clean\", \"smooth\"],\n  multiPhoneLayout: {\n    type: \"side-by-side\",\n    positions: [\n      { x: 30, y: 50, scale: 0.75, rotation: 0, zIndex: 1 },\n      { x: 70, y: 50, scale: 0.75, rotation: 0, zIndex: 1 },\n    ],\n    staggerDelay: 4,\n  },\n  tracks: [\n    {\n      elementId: \"phone-0\",\n      keyframes: [\n        {\n          percent: 0,\n          x: -20,\n          y: 50,\n          scale: 0.75,\n          opacity: 0,\n          easing: \"spring\",\n        },\n        {\n          percent: 40,\n          x: 30,\n          y: 50,\n          scale: 0.75,\n          opacity: 1,\n          easing: \"spring\",\n        },\n        { percent: 100, x: 30, y: 50, scale: 0.75, opacity: 1 },\n      ],\n    },\n    {\n      elementId: \"phone-1\",\n      keyframes: [\n        { percent: 0, x: 120, y: 50, scale: 0.75, opacity: 0 },\n        {\n          percent: 8,\n          x: 120,\n          y: 50,\n          scale: 0.75,\n          opacity: 0,\n          easing: \"spring\",\n        },\n        {\n          percent: 48,\n          x: 70,\n          y: 50,\n          scale: 0.75,\n          opacity: 1,\n          easing: \"spring\",\n        },\n        { percent: 100, x: 70, y: 50, scale: 0.75, opacity: 1 },\n      ],\n    },\n  ],\n  minDuration: 3.5,\n  useAngledFrame: false,\n};",
                "slowZoomIn": "// =============================================================================\n// KEN BURNS CHOREOGRAPHIES\n// =============================================================================\n\nconst slowZoomIn: ChoreographyPreset = {\n  id: \"slow-zoom-in\",\n  name: \"Slow Zoom In\",\n  description: \"Gradual zoom into phone for focused detail\",\n  category: \"ken-burns\",\n  tags: [\"focus\", \"cinematic\", \"subtle\", \"single\"],\n  kenBurns: {\n    enabled: true,\n    intensity: \"moderate\",\n    direction: \"zoom-in\",\n    scaleStart: 1,\n    scaleEnd: 1.15,\n  },\n  tracks: [\n    {\n      elementId: \"phone-0\",\n      keyframes: [\n        { percent: 0, x: 50, y: 50, scale: 1, opacity: 1 },\n        { percent: 100, x: 50, y: 50, scale: 1, opacity: 1 },\n      ],\n    },\n  ],\n  minDuration: 4,\n  useAngledFrame: false,\n};",
                "slowZoomOut": "const slowZoomOut: ChoreographyPreset = {\n  id: \"slow-zoom-out\",\n  name: \"Slow Zoom Out\",\n  description: \"Gradual zoom out revealing full context\",\n  category: \"ken-burns\",\n  tags: [\"reveal\", \"cinematic\", \"subtle\", \"single\"],\n  kenBurns: {\n    enabled: true,\n    intensity: \"moderate\",\n    direction: \"zoom-out\",\n    scaleStart: 1.2,\n    scaleEnd: 1,\n  },\n  tracks: [\n    {\n      elementId: \"phone-0\",\n      keyframes: [\n        { percent: 0, x: 50, y: 50, scale: 1.2, opacity: 1 },\n        { percent: 100, x: 50, y: 50, scale: 1, opacity: 1 },\n      ],\n    },\n  ],\n  minDuration: 4,\n  useAngledFrame: false,\n};",
                "panLeft": "const panLeft: ChoreographyPreset = {\n  id: \"pan-left\",\n  name: \"Pan Left\",\n  description: \"Slow pan across phone from right to left\",\n  category: \"ken-burns\",\n  tags: [\"exploration\", \"cinematic\", \"smooth\", \"single\"],\n  kenBurns: {\n    enabled: true,\n    intensity: \"subtle\",\n    direction: \"pan-left\",\n    panStartX: 55,\n    panEndX: 45,\n    panStartY: 50,\n    panEndY: 50,\n  },\n  tracks: [\n    {\n      elementId: \"phone-0\",\n      keyframes: [\n        { percent: 0, x: 55, y: 50, scale: 1.1, opacity: 1 },\n        { percent: 100, x: 45, y: 50, scale: 1.1, opacity: 1 },\n      ],\n    },\n  ],\n  minDuration: 4,\n  useAngledFrame: false,\n};",
                "panRight": "const panRight: ChoreographyPreset = {\n  id: \"pan-right\",\n  name: \"Pan Right\",\n  description: \"Slow pan across phone from left to right\",\n  category: \"ken-burns\",\n  tags: [\"exploration\", \"cinematic\", \"smooth\", \"single\"],\n  kenBurns: {\n    enabled: true,\n    intensity: \"subtle\",\n    direction: \"pan-right\",\n    panStartX: 45,\n    panEndX: 55,\n    panStartY: 50,\n    panEndY: 50,\n  },\n  tracks: [\n    {\n      elementId: \"phone-0\",\n      keyframes: [\n        { percent: 0, x: 45, y: 50, scale: 1.1, opacity: 1 },\n        { percent: 100, x: 55, y: 50, scale: 1.1, opacity: 1 },\n      ],\n    },\n  ],\n  minDuration: 4,\n  useAngledFrame: false,\n};",
                "morphScale": "// =============================================================================\n// TRANSITION CHOREOGRAPHIES\n// =============================================================================\n\nconst morphScale: ChoreographyPreset = {\n  id: \"morph-scale\",\n  name: \"Morph Scale\",\n  description: \"Current phone scales down as next scales up\",\n  category: \"transition\",\n  tags: [\"smooth\", \"minimal\"],\n  tracks: [\n    {\n      elementId: \"phone-exit\",\n      keyframes: [\n        { percent: 0, x: 50, y: 50, scale: 1, opacity: 1, easing: \"ease-in\" },\n        {\n          percent: 50,\n          x: 50,\n          y: 50,\n          scale: 0.8,\n          opacity: 0,\n          easing: \"ease-in\",\n        },\n      ],\n    },\n    {\n      elementId: \"phone-enter\",\n      keyframes: [\n        {\n          percent: 33,\n          x: 50,\n          y: 50,\n          scale: 0.8,\n          opacity: 0,\n          easing: \"ease-out\",\n        },\n        {\n          percent: 100,\n          x: 50,\n          y: 50,\n          scale: 1,\n          opacity: 1,\n          easing: \"ease-out\",\n        },\n      ],\n    },\n  ],\n  minDuration: 1,\n};",
                "crossFade": "const crossFade: ChoreographyPreset = {\n  id: \"cross-fade\",\n  name: \"Cross Fade\",\n  description: \"Simple opacity crossfade between scenes\",\n  category: \"transition\",\n  tags: [\"minimal\", \"smooth\", \"clean\"],\n  tracks: [\n    {\n      elementId: \"scene-exit\",\n      keyframes: [\n        { percent: 0, opacity: 1, easing: \"linear\" },\n        { percent: 75, opacity: 0, easing: \"linear\" },\n      ],\n    },\n    {\n      elementId: \"scene-enter\",\n      keyframes: [\n        { percent: 25, opacity: 0, easing: \"linear\" },\n        { percent: 100, opacity: 1, easing: \"linear\" },\n      ],\n    },\n  ],\n  minDuration: 0.7,\n};",
                "wipeLeft": "const wipeLeft: ChoreographyPreset = {\n  id: \"wipe-left\",\n  name: \"Wipe Left\",\n  description: \"New scene wipes from right to left\",\n  category: \"transition\",\n  tags: [\"dynamic\", \"energetic\"],\n  tracks: [\n    {\n      elementId: \"wipe-mask\",\n      keyframes: [\n        { percent: 0, x: 100, easing: \"ease-in-out\" },\n        { percent: 100, x: 0, easing: \"ease-in-out\" },\n      ],\n    },\n  ],\n  minDuration: 0.6,\n};",
                "pushUp": "const pushUp: ChoreographyPreset = {\n  id: \"push-up\",\n  name: \"Push Up\",\n  description: \"New scene pushes old scene upward\",\n  category: \"transition\",\n  tags: [\"energetic\", \"dynamic\"],\n  tracks: [\n    {\n      elementId: \"scene-exit\",\n      keyframes: [\n        { percent: 0, y: 0, easing: \"ease-in\" },\n        { percent: 100, y: -100, easing: \"ease-in\" },\n      ],\n    },\n    {\n      elementId: \"scene-enter\",\n      keyframes: [\n        { percent: 0, y: 100, easing: \"ease-out\" },\n        { percent: 100, y: 0, easing: \"ease-out\" },\n      ],\n    },\n  ],\n  minDuration: 0.6,\n};",
                "CHOREOGRAPHY_PRESETS": "// =============================================================================\n// EXPORT ALL PRESETS\n// =============================================================================\n\nconst CHOREOGRAPHY_PRESETS: Record<string, ChoreographyPreset> = {\n  // Entrance\n  \"hero-center\": heroCenter,\n  \"slide-left\": slideLeft,\n  \"slide-right\": slideRight,\n  \"drop-bounce\": dropBounce,\n  \"zoom-through\": zoomThrough,\n  \"rotate-in\": rotateIn,\n  \"float-up\": floatUp,\n  // Multi-phone\n  \"cascade-left\": cascadeLeft,\n  \"cascade-right\": cascadeRight,\n  \"fan-out\": fanOut,\n  \"side-by-side\": sideBySide,\n  // Ken Burns\n  \"slow-zoom-in\": slowZoomIn,\n  \"slow-zoom-out\": slowZoomOut,\n  \"pan-left\": panLeft,\n  \"pan-right\": panRight,\n  // Transitions\n  \"morph-scale\": morphScale,\n  \"cross-fade\": crossFade,\n  \"wipe-left\": wipeLeft,\n  \"push-up\": pushUp,\n};",
                "getChoreography": "/** Get choreography preset by ID */\nexport function getChoreography(id: string): ChoreographyPreset | undefined {\n  return CHOREOGRAPHY_PRESETS[id];\n}"
            }
        },
        {
            "group": "promoVideo",
            "path": "src/features/promovideo/lib/remotion/choreography/types.ts",
            "sha256": "43c30b9777c5c60176f2c2ff6873bc472786d6fb6db24f8d20f1477e5f3fa493",
            "declarations": {
                "ChoreographyCategory": "/**\n * Choreography System Types\n * Defines motion presets for cinematic video scenes\n */\n\n/** Choreography categories for organization */\nexport type ChoreographyCategory =\n  | \"entrance\"\n  | \"multi-phone\"\n  | \"ken-burns\"\n  | \"transition\";",
                "ChoreographyTag": "/** Tags for LLM-based choreography selection */\nexport type ChoreographyTag =\n  // Category tags (can be used for filtering)\n  | \"entrance\"\n  | \"multi-phone\"\n  | \"ken-burns\"\n  | \"transition\"\n  // Style/mood tags\n  | \"dramatic\"\n  | \"smooth\"\n  | \"playful\"\n  | \"comparison\"\n  | \"single\"\n  | \"multi\"\n  | \"focus\"\n  | \"cinematic\"\n  | \"subtle\"\n  | \"reveal\"\n  | \"exploration\"\n  | \"showcase\"\n  | \"depth\"\n  | \"premium\"\n  | \"clean\"\n  | \"minimal\"\n  | \"dynamic\"\n  | \"energetic\";",
                "EasingType": "/** Easing function types */\nexport type EasingType =\n  | \"linear\"\n  | \"ease-in\"\n  | \"ease-out\"\n  | \"ease-in-out\"\n  | \"spring\"\n  | \"bounce\";",
                "Keyframe": "/** Keyframe definition for animations */\nexport interface Keyframe {\n  /** Progress through scene as percentage (0-100) */\n  percent: number;\n  /** X position as percentage of viewport (0-100) */\n  x?: number;\n  /** Y position as percentage of viewport (0-100) */\n  y?: number;\n  /** Scale factor (1 = 100%) */\n  scale?: number;\n  /** Rotation in degrees */\n  rotation?: number;\n  /** Opacity (0-1) */\n  opacity?: number;\n  /** Easing to this keyframe */\n  easing?: EasingType;\n}",
                "AnimationValues": "/** Interpolated animation values at a specific frame */\nexport interface AnimationValues {\n  x: number;\n  y: number;\n  scale: number;\n  rotation: number;\n  opacity: number;\n}",
                "AnimationTrack": "/** Animation track for a single element */\nexport interface AnimationTrack {\n  /** Element identifier (e.g., \"phone-0\", \"phone-1\", \"text-title\") */\n  elementId: string;\n  /** Keyframes defining the animation */\n  keyframes: Keyframe[];\n}",
                "KenBurnsConfig": "/** Ken Burns configuration */\nexport interface KenBurnsConfig {\n  /** Whether Ken Burns is enabled */\n  enabled: boolean;\n  /** Movement intensity */\n  intensity?: \"subtle\" | \"moderate\" | \"dramatic\" | null;\n  /** Direction of movement */\n  direction?: \"zoom-in\" | \"zoom-out\" | \"pan-left\" | \"pan-right\" | null;\n  /** Preset identifier */\n  preset?:\n    | \"slow-zoom-in\"\n    | \"slow-zoom-out\"\n    | \"pan-left\"\n    | \"pan-right\"\n    | \"zoom-in-pan-right\"\n    | \"zoom-out-pan-left\"\n    | \"subtle-drift\"\n    | null;\n  /** Start and end scale values */\n  scaleStart?: number | null;\n  scaleEnd?: number | null;\n  /** Start and end position offsets (percentage) */\n  panStartX?: number | null;\n  panEndX?: number | null;\n  panStartY?: number | null;\n  panEndY?: number | null;\n}",
                "MultiPhoneLayout": "/** Multi-phone layout configuration */\nexport interface MultiPhoneLayout {\n  /** Layout type */\n  type: \"cascade\" | \"fan\" | \"side-by-side\";\n  /** Positions for each phone (percentage-based) */\n  positions: Array<{\n    x: number;\n    y: number;\n    scale: number;\n    rotation: number;\n    zIndex: number;\n    /** Device frame variant */\n    variant?: \"flat\" | \"left\" | \"right\";\n  }>;\n  /** Stagger delay between phones (frames) */\n  staggerDelay?: number;\n}",
                "ChoreographyPreset": "/** Full choreography preset definition */\nexport interface ChoreographyPreset {\n  /** Unique identifier */\n  id: string;\n  /** Human-readable name */\n  name: string;\n  /** Description for LLM context */\n  description: string;\n  /** Category for organization */\n  category: ChoreographyCategory;\n  /** Tags for LLM selection */\n  tags: ChoreographyTag[];\n  /** Animation tracks for elements */\n  tracks?: AnimationTrack[];\n  /** Ken Burns configuration (optional) */\n  kenBurns?: KenBurnsConfig;\n  /** Multi-phone layout (for multi-phone choreographies) */\n  multiPhoneLayout?: MultiPhoneLayout;\n  /** Minimum recommended scene duration (seconds) */\n  minDuration?: number;\n  /** Whether this choreography uses the angled frame variant */\n  useAngledFrame?: boolean;\n}"
            }
        },
        {
            "group": "promoVideo",
            "path": "src/features/promovideo/lib/configSchema.ts",
            "sha256": "091f46cc7de978de9394b1f815a22097e9deafe74f9137393ff87ceac5da6220",
            "declarations": {
                "text": "const text = z.string().max(20_000);",
                "optionalText": "const optionalText = text.nullish();",
                "index": "const index = z\n  .number()\n  .int()\n  .min(0)\n  .max(MAX_PROMO_SCREENSHOTS - 1);",
                "base": "const base = {\n  duration: z.number().finite().positive().max(3600).nullish(),\n  hidden: z.boolean().optional(),\n};",
                "promoSceneSchema": "// Keep editor presentation extensions while validating the content each renderer reads.\nconst promoSceneSchema = z.discriminatedUnion(\"type\", [\n  z\n    .object({\n      ...base,\n      type: z.literal(\"hook\"),\n      content: z\n        .object({ appName: optionalText, tagline: optionalText })\n        .passthrough(),\n    })\n    .passthrough(),\n  z\n    .object({\n      ...base,\n      type: z.literal(\"feature\"),\n      content: z\n        .object({ screenshotIndex: index, title: text, subtitle: optionalText })\n        .passthrough(),\n    })\n    .passthrough(),\n  z\n    .object({\n      ...base,\n      type: z.literal(\"text-only\"),\n      content: z\n        .object({ headline: text, subheadline: optionalText })\n        .passthrough(),\n    })\n    .passthrough(),\n  z\n    .object({\n      ...base,\n      type: z.literal(\"closeup\"),\n      content: z\n        .object({\n          screenshotIndex: index,\n          caption: optionalText,\n          focusRegion: z\n            .object({\n              x: z.number().finite(),\n              y: z.number().finite(),\n              width: z.number().finite().positive(),\n              height: z.number().finite().positive(),\n            })\n            .nullish(),\n        })\n        .passthrough(),\n    })\n    .passthrough(),\n  z\n    .object({\n      ...base,\n      type: z.literal(\"multi-phone\"),\n      content: z\n        .object({\n          screenshotIndexes: z.array(index).min(1).max(6),\n          title: optionalText,\n          layout: z.enum([\"cascade\", \"fan\", \"side-by-side\", \"custom\"]),\n        })\n        .passthrough(),\n    })\n    .passthrough(),\n  z\n    .object({\n      ...base,\n      type: z.literal(\"cta\"),\n      content: z\n        .object({\n          appName: optionalText,\n          downloadText: optionalText,\n          badge: z.enum([\"appStore\", \"googlePlay\", \"both\", \"none\"]).nullish(),\n        })\n        .passthrough(),\n    })\n    .passthrough(),\n]);",
                "promoConfigSchema": "export const promoConfigSchema = z\n  .object({\n    scenes: z.array(promoSceneSchema).max(100),\n    theme: z\n      .object({\n        colors: z.record(z.string(), z.string()).optional(),\n        typography: z\n          .object({\n            fontFamily: z.string(),\n            titleSize: z.number().finite().positive(),\n            subtitleSize: z.number().finite().positive(),\n          })\n          .partial()\n          .passthrough()\n          .optional(),\n      })\n      .passthrough()\n      .optional(),\n  })\n  .passthrough();",
                "promoExportConfigSchema": "export const promoExportConfigSchema = promoConfigSchema.extend({\n  scenes: z.array(promoSceneSchema).min(1).max(100),\n});"
            }
        },
        {
            "group": "promoVideo",
            "path": "src/features/promovideo/lib/remotion/utils/dimensions.ts",
            "sha256": "7b6bba81735baaa051e733b59c671ed262f7b7d0a46b7c8b5c26d2a0d2f37115",
            "declarations": {
                "APP_STORE_VIDEO_DIMENSIONS": "// Video dimension constants for App Store preview videos\n\n// App Store display sizes (portrait orientation)\nconst APP_STORE_VIDEO_DIMENSIONS = {\n  default: { width: 886, height: 1920, label: \"App Store Preview\" },\n  \"6.9\": {\n    width: 1320,\n    height: 2868,\n    label: \"iPhone 18 Pro Max, Air, 17 Pro Max\",\n  },\n  \"6.7\": { width: 1290, height: 2796, label: \"iPhone 15 Pro Max, 14 Pro Max\" },\n  \"6.5\": { width: 1284, height: 2778, label: \"iPhone 14 Plus, 13 Pro Max\" },\n  \"6.3\": { width: 1206, height: 2622, label: \"iPhone 18 Pro, 17 Pro, 16 Pro\" },\n} as const;",
                "IPAD_VIDEO_DIMENSIONS": "// iPad App Store preview sizes (portrait orientation)\nexport const IPAD_VIDEO_DIMENSIONS = {\n  \"12.9\": { width: 2048, height: 2732, label: 'iPad Pro 12.9\"' },\n  \"11\": { width: 1668, height: 2388, label: 'iPad 11\"' },\n} as const;",
                "SOCIAL_VIDEO_DIMENSIONS": "// Social media aspect ratios\nexport const SOCIAL_VIDEO_DIMENSIONS = {\n  \"9:16\": { width: 1080, height: 1920, label: \"TikTok, Reels, Stories\" },\n  \"1:1\": { width: 1080, height: 1080, label: \"Instagram Feed\" },\n  \"16:9\": { width: 1920, height: 1080, label: \"YouTube\" },\n} as const;",
                "APP_STORE_CREATIVE_VIDEO_DIMENSIONS": "// Apple's creative placements (App Store Connect asset library, Oct 2026):\n// product page header 21:9 and search results 3:2, 5–30 s at 30 or 60 fps.\n// Apple matches the file to a spec by its exact pixel size, so these always\n// render at full size (no 720p scale).\nexport const APP_STORE_CREATIVE_VIDEO_DIMENSIONS = {\n  header: { width: 3840, height: 1646, label: \"Product page header\" },\n  search: { width: 1920, height: 1280, label: \"Search results\" },\n} as const;",
                "AppStoreDisplaySize": "export type AppStoreDisplaySize = keyof typeof APP_STORE_VIDEO_DIMENSIONS;",
                "IpadDisplaySize": "export type IpadDisplaySize = keyof typeof IPAD_VIDEO_DIMENSIONS;",
                "SocialAspectRatio": "export type SocialAspectRatio = keyof typeof SOCIAL_VIDEO_DIMENSIONS;",
                "AppStoreCreativeSize": "export type AppStoreCreativeSize =\n  keyof typeof APP_STORE_CREATIVE_VIDEO_DIMENSIONS;",
                "ExportCategory": "export type ExportCategory = \"appStore\" | \"ipad\" | \"social\" | \"creative\";",
                "ExportSize": "export type ExportSize =\n  | AppStoreDisplaySize\n  | IpadDisplaySize\n  | SocialAspectRatio\n  | AppStoreCreativeSize;",
                "DEFAULT_EXPORT_SIZE": "// Default size for each export category\nexport const DEFAULT_EXPORT_SIZE: Record<ExportCategory, ExportSize> = {\n  appStore: \"default\",\n  ipad: \"12.9\",\n  social: \"9:16\",\n  creative: \"header\",\n};",
                "VIDEO_WIDTH": "// Default dimensions (App Store Preview)\nexport const VIDEO_WIDTH = 886;",
                "VIDEO_HEIGHT": "export const VIDEO_HEIGHT = 1920;",
                "FPS": "export const FPS = 30;",
                "getExportQualityScale": "/** Render scale for an export quality; sizes are authored at 1080p. */\nexport function getExportQualityScale(quality: \"720p\" | \"1080p\") {\n  return quality === \"720p\" ? 0.667 : 1;\n}",
                "scalesToEvenSide": "/** Rounding up (Chrome's capture size) and to nearest (Remotion) agree on an even size. */\nfunction scalesToEvenSide(side: number, scale: number): boolean {\n  const exact = side * scale;\n  const up = Math.ceil(exact - 1e-9);\n  return up % 2 === 0 && Math.round(exact) === up;\n}",
                "getEvenExportQualityScale": "/**\n * The 720p scale for an H.264/H.265 server render, nudged down (well under\n * 1%) until both scaled sides land on even pixel counts however they are\n * rounded. libx264 rejects odd sizes: the App Store preview (886×1920) at\n * 0.667 captures at 591×1281 and every Lambda chunk failed to encode.\n */\nexport function getEvenExportQualityScale(\n  quality: \"720p\" | \"1080p\",\n  width: number,\n  height: number,\n): number {\n  const base = getExportQualityScale(quality);\n  if (base === 1 && width % 2 === 0 && height % 2 === 0) return base;\n  for (let step = 0; step <= 500; step += 1) {\n    const scale = Math.round((base - step * 0.0001) * 10000) / 10000;\n    if (scalesToEvenSide(width, scale) && scalesToEvenSide(height, scale)) {\n      return scale;\n    }\n  }\n  return base;\n}",
                "getVideoDimensions": "// Helper to get dimensions based on export settings\nexport function getVideoDimensions(\n  category: ExportCategory,\n  size: ExportSize,\n): { width: number; height: number; label: string } {\n  if (category === \"appStore\") {\n    return APP_STORE_VIDEO_DIMENSIONS[size as AppStoreDisplaySize];\n  }\n  if (category === \"ipad\") {\n    return IPAD_VIDEO_DIMENSIONS[size as IpadDisplaySize];\n  }\n  if (category === \"creative\") {\n    return APP_STORE_CREATIVE_VIDEO_DIMENSIONS[size as AppStoreCreativeSize];\n  }\n  return SOCIAL_VIDEO_DIMENSIONS[size as SocialAspectRatio];\n}",
                "PreviewFormat": "// Preview format types for the editor dropdown\nexport type PreviewFormat =\n  | `appStore:${AppStoreDisplaySize}`\n  | `ipad:${IpadDisplaySize}`\n  | `social:${SocialAspectRatio}`\n  | `creative:${AppStoreCreativeSize}`;",
                "PreviewFormatOption": "export interface PreviewFormatOption {\n  value: PreviewFormat;\n  label: string;\n  width: number;\n  height: number;\n  category: \"App Store\" | \"iPad\" | \"Social Media\" | \"App Store Creative\";\n}",
                "PREVIEW_FORMAT_OPTIONS": "// Generate preview format options from the dimension constants (single source of truth)\nexport const PREVIEW_FORMAT_OPTIONS: PreviewFormatOption[] = [\n  // App Store formats\n  ...(\n    Object.entries(APP_STORE_VIDEO_DIMENSIONS) as [\n      AppStoreDisplaySize,\n      (typeof APP_STORE_VIDEO_DIMENSIONS)[AppStoreDisplaySize],\n    ][]\n  ).map(([size, dims]) => ({\n    value: `appStore:${size}` as PreviewFormat,\n    label: size === \"default\" ? \"App Store\" : `${size}\" iPhone`,\n    width: dims.width,\n    height: dims.height,\n    category: \"App Store\" as const,\n  })),\n  // iPad formats\n  ...(\n    Object.entries(IPAD_VIDEO_DIMENSIONS) as [\n      IpadDisplaySize,\n      (typeof IPAD_VIDEO_DIMENSIONS)[IpadDisplaySize],\n    ][]\n  ).map(([size, dims]) => ({\n    value: `ipad:${size}` as PreviewFormat,\n    label: dims.label,\n    width: dims.width,\n    height: dims.height,\n    category: \"iPad\" as const,\n  })),\n  // Social Media formats\n  ...(\n    Object.entries(SOCIAL_VIDEO_DIMENSIONS) as [\n      SocialAspectRatio,\n      (typeof SOCIAL_VIDEO_DIMENSIONS)[SocialAspectRatio],\n    ][]\n  ).map(([ratio, dims]) => ({\n    value: `social:${ratio}` as PreviewFormat,\n    label:\n      ratio === \"9:16\"\n        ? \"9:16 Portrait\"\n        : ratio === \"1:1\"\n          ? \"1:1 Square\"\n          : \"16:9 Landscape\",\n    width: dims.width,\n    height: dims.height,\n    category: \"Social Media\" as const,\n  })),\n  // App Store creative formats\n  ...(\n    Object.entries(APP_STORE_CREATIVE_VIDEO_DIMENSIONS) as [\n      AppStoreCreativeSize,\n      (typeof APP_STORE_CREATIVE_VIDEO_DIMENSIONS)[AppStoreCreativeSize],\n    ][]\n  ).map(([size, dims]) => ({\n    value: `creative:${size}` as PreviewFormat,\n    label: size === \"header\" ? \"Header 21:9\" : \"Search 3:2\",\n    width: dims.width,\n    height: dims.height,\n    category: \"App Store Creative\" as const,\n  })),\n];",
                "getPreviewDimensions": "export function getPreviewDimensions(format: PreviewFormat): {\n  width: number;\n  height: number;\n} {\n  const option = PREVIEW_FORMAT_OPTIONS.find((opt) => opt.value === format);\n  if (option) {\n    return { width: option.width, height: option.height };\n  }\n  // Default to App Store Preview\n  return { width: VIDEO_WIDTH, height: VIDEO_HEIGHT };\n}"
            }
        },
        {
            "group": "promoVideo",
            "path": "src/features/promovideo/lib/transitions.ts",
            "sha256": "efcd99ea4f51e1cc15ccab763ca6cb217cf6ecfe926e23e037ef53a80dd10953",
            "declarations": {
                "TRANSITION_OPTIONS": "/** Selectable scene-to-scene transition types, shared by the timeline popup and the Motion section. */\nexport const TRANSITION_OPTIONS = [\n  { value: \"none\", label: \"None\" },\n  { value: \"cross-fade\", label: \"Cross Fade\" },\n  { value: \"slide-left\", label: \"Slide Left\" },\n  { value: \"slide-right\", label: \"Slide Right\" },\n  { value: \"wipe-left\", label: \"Wipe Left\" },\n  { value: \"wipe-right\", label: \"Wipe Right\" },\n  { value: \"push-up\", label: \"Push Up\" },\n  { value: \"push-down\", label: \"Push Down\" },\n  { value: \"zoom\", label: \"Zoom\" },\n] as const;",
                "PROMO_TRANSITION_TYPES": "/**\n * Every value a scene's `transition.type` may hold. Names describe how the\n * content moves (see remotion/config/transitionNaming.ts): \"slide-left\"\n * pushes the next scene in from the right.\n */\nexport const PROMO_TRANSITION_TYPES = TRANSITION_OPTIONS.map(\n  (option) => option.value,\n);",
                "DEFAULT_TRANSITION_FRAMES": "/** Default transition length (frames) — half a second. */\nexport const DEFAULT_TRANSITION_FRAMES = Math.round(FPS * 0.5);",
                "MIN_CARRY_TRANSITION_SECONDS": "/** Leave enough overlap for a device turn in either editor control. */\nexport const MIN_CARRY_TRANSITION_SECONDS = 0.6;",
                "getTransitionLabel": "/** Short label for a transition type, used in compact UI. */\nexport function getTransitionLabel(type: string | undefined): string {\n  if (!type) return \"\";\n  switch (type) {\n    case \"cross-fade\":\n      return \"Fade\";\n    case \"slide-left\":\n      return \"Slide L\";\n    case \"slide-right\":\n      return \"Slide R\";\n    case \"wipe-left\":\n      return \"Wipe L\";\n    case \"wipe-right\":\n      return \"Wipe R\";\n    case \"push-up\":\n      return \"Push ↑\";\n    case \"push-down\":\n      return \"Push ↓\";\n    case \"zoom\":\n      return \"Zoom\";\n    default:\n      return type;\n  }\n}"
            }
        },
        {
            "group": "promoVideo",
            "path": "src/features/promovideo/lib/remotion/config/transitionNaming.ts",
            "sha256": "7636f4d3c9cf42d68bfab4869ccc2496e986f71891a8039b233f4ec4aa6587c0",
            "declarations": {
                "TRANSITION_NAMING": "/**\n * Transition names describe the way the content moves: \"slide-left\" pushes\n * the next scene in from the right, \"wipe-left\" sweeps its edge leftwards.\n * Videos saved before that change used Remotion's \"from\" directions, which\n * are the mirror image (\"slide-left\" came in from the left). Such configs\n * carry no `transitionNaming` marker; their horizontal slides and wipes are\n * mirrored once on load so they keep playing the way they were authored.\n */\nexport const TRANSITION_NAMING = \"content-motion\" as const;",
                "MIRRORED": "const MIRRORED: Record<string, string> = {\n  \"slide-left\": \"slide-right\",\n  \"slide-right\": \"slide-left\",\n  \"wipe-left\": \"wipe-right\",\n  \"wipe-right\": \"wipe-left\",\n};",
                "migrateTransitionNaming": "export function migrateTransitionNaming<T>(input: T): T {\n  if (!input || typeof input !== \"object\") return input;\n  const config = input as {\n    transitionNaming?: unknown;\n    scenes?: unknown;\n  };\n  if (config.transitionNaming === TRANSITION_NAMING) return input;\n  const scenes = Array.isArray(config.scenes)\n    ? config.scenes.map((scene) => {\n        const transition = (scene as { transition?: { type?: unknown } })\n          ?.transition;\n        const mirrored =\n          typeof transition?.type === \"string\"\n            ? MIRRORED[transition.type]\n            : undefined;\n        return mirrored\n          ? { ...scene, transition: { ...transition, type: mirrored } }\n          : scene;\n      })\n    : config.scenes;\n  return { ...config, scenes, transitionNaming: TRANSITION_NAMING } as T;\n}"
            }
        },
        {
            "group": "promoVideo",
            "path": "src/features/promovideo/lib/remotion/motion3d/promoMotionIds.ts",
            "sha256": "e3d260441694b534a3c1686f0f4f99b9c6aa9f55b18fb28e5df32ec7b481d18c",
            "declarations": {
                "PROMO_MOTION_IDS": "export const PROMO_MOTION_IDS = [\n  \"hero-launch\",\n  \"pivot-showcase\",\n  \"slow-rotate\",\n  \"drifting-tilt\",\n  \"float\",\n  \"glide-left\",\n  \"glide-right\",\n  \"tilt-in\",\n] as const;",
                "PromoMotionId": "export type PromoMotionId = (typeof PROMO_MOTION_IDS)[number];",
                "isPromoMotionId": "export function isPromoMotionId(value: unknown): value is PromoMotionId {\n  return (PROMO_MOTION_IDS as readonly unknown[]).includes(value);\n}",
                "promoMotionForChoreography": "/** Old 2D choreographies map to the 3D motion that moves the same way. */\nexport function promoMotionForChoreography(\n  choreography: string | null | undefined,\n): PromoMotionId {\n  switch (choreography) {\n    case \"slide-left\":\n      return \"glide-left\";\n    case \"slide-right\":\n      return \"glide-right\";\n    case \"rotate-in\":\n      return \"tilt-in\";\n    case \"zoom-through\":\n      return \"drifting-tilt\";\n    default:\n      return \"float\";\n  }\n}",
                "restYawForFrameVariant": "/** The 2D frame variants become a resting three-quarter angle. */\nexport function restYawForFrameVariant(variant: string | null | undefined) {\n  if (variant === \"right\") return 0.32;\n  if (variant === \"left\") return -0.32;\n  return 0;\n}"
            }
        },
        {
            "group": "promoVideo",
            "path": "src/features/promovideo/lib/fontFamilies.ts",
            "sha256": "4d04790a55c6cbcd5a97c52b4daa9bdce1c02fdb4f504e2725fc98a93b42723b",
            "declarations": {
                "PROMO_FONT_FAMILIES": "/**\n * Font families the promo video theme offers (`theme.typography.fontFamily`).\n * The editor's Theme panel lists exactly these; a project's uploaded custom\n * fonts (generation metadata `customFonts[].name`) are also accepted. Any\n * other name silently falls back to the default font when rendered.\n */\nexport const PROMO_FONT_FAMILIES = [\n  { value: \"Poppins\", label: \"Poppins\" },\n  { value: \"Inter\", label: \"Inter\" },\n  { value: \"Montserrat\", label: \"Montserrat\" },\n  { value: \"Open Sans\", label: \"Open Sans\" },\n  { value: \"Roboto\", label: \"Roboto\" },\n  { value: \"Lato\", label: \"Lato\" },\n  { value: \"Playfair Display\", label: \"Playfair\" },\n  { value: \"Georgia\", label: \"Georgia\" },\n  { value: \"Merriweather\", label: \"Merriweather\" },\n  { value: \"Lora\", label: \"Lora\" },\n  { value: \"Times New Roman\", label: \"Times\" },\n  { value: \"Bebas Neue\", label: \"Bebas Neue\" },\n  { value: \"Oswald\", label: \"Oswald\" },\n  { value: \"Space Grotesk\", label: \"Space Grotesk\" },\n  { value: \"Nunito\", label: \"Nunito\" },\n  { value: \"Baloo 2\", label: \"Baloo 2\" },\n  { value: \"SF Pro Display\", label: \"SF Pro Display\" },\n] as const;",
                "PromoFontFamily": "type PromoFontFamily = (typeof PROMO_FONT_FAMILIES)[number][\"value\"];",
                "PROMO_FONT_FAMILY_NAMES": "export const PROMO_FONT_FAMILY_NAMES: readonly PromoFontFamily[] =\n  PROMO_FONT_FAMILIES.map((font) => font.value);",
                "PROMO_SERIF_FONT_FAMILIES": "export const PROMO_SERIF_FONT_FAMILIES: ReadonlySet<string> = new Set([\n  \"Playfair Display\",\n  \"Georgia\",\n  \"Merriweather\",\n  \"Lora\",\n  \"Times New Roman\",\n]);",
                "PROMO_EXTRA_RENDERED_FONT_FAMILIES": "/**\n * Further names the renderer loads (remotion/utils/fonts.ts FONT_LOADERS and\n * SYSTEM_FONT_FALLBACKS) without listing them in the Theme panel: the\n * localized Noto families generated videos use for CJK/Arabic copy, and\n * system names mapped to Inter.\n */\nconst PROMO_EXTRA_RENDERED_FONT_FAMILIES = [\n  \"Manrope\",\n  \"OpenSans\",\n  \"SourceSansPro\",\n  \"Source Sans Pro\",\n  \"Raleway\",\n  \"Noto Sans JP\",\n  \"Noto Sans KR\",\n  \"Noto Sans SC\",\n  \"Noto Sans TC\",\n  \"Noto Sans Arabic\",\n  \"SF Pro Text\",\n  \"SF Pro\",\n  \"SF Mono\",\n  \"Helvetica Neue\",\n  \"Helvetica\",\n  \"Arial\",\n  \"system-ui\",\n  \"-apple-system\",\n  \"BlinkMacSystemFont\",\n  \"Segoe UI\",\n] as const;",
                "PROMO_RENDERED_FONT_FAMILIES": "/** Every `theme.typography.fontFamily` the renderer draws as named. */\nexport const PROMO_RENDERED_FONT_FAMILIES: readonly string[] = [\n  ...PROMO_FONT_FAMILY_NAMES,\n  ...PROMO_EXTRA_RENDERED_FONT_FAMILIES,\n];"
            }
        },
        {
            "group": "mockups",
            "path": "src/features/mockups/types.ts",
            "sha256": "ae8d02d325a52fa3604f03dc041500b2214c5e910726a94cff3059ca2c9561a6",
            "declarations": {
                "MotionPreset": "export type MotionPreset =\n  | \"hero-reveal\"\n  | \"feature-sweep\"\n  | \"showcase-orbit\"\n  | \"parallax-tilt\"\n  | \"custom\";",
                "Finish": "export type Finish =\n  | \"silver\"\n  | \"black\"\n  | \"glacier\"\n  | \"burgundy\"\n  | \"cosmic-orange\"\n  | \"deep-blue\"\n  | \"pixel-obsidian\"\n  | \"pixel-porcelain\"\n  | \"pixel-moonstone\"\n  | \"duo-night-sky\"\n  | \"duo-star-white\";",
                "BackgroundPreset": "export type BackgroundPreset = \"soft\" | \"paper\" | \"midnight\" | \"transparent\";",
                "OutputRatio": "export type OutputRatio = \"1:1\" | \"4:3\" | \"16:9\" | \"9:16\";",
                "KeyframeEasing": "export type KeyframeEasing = \"linear\" | \"ease-in\" | \"ease-out\" | \"ease-in-out\";",
                "MockupKeyframe": "export interface MockupKeyframe {\n  id: string;\n  time: number;\n  x: number;\n  y: number;\n  rotationX: number;\n  rotationY: number;\n  rotationZ: number;\n  scale: number;\n  /**\n   * Laptop lid openness at this keyframe: 0 closed, 1 fully open. Absent on\n   * phone/tablet keyframes and on projects saved before the lid was\n   * keyframable — both read as fully open.\n   */\n  lidOpen?: number;\n  easing: KeyframeEasing;\n}",
                "MockupBackgroundGradient": "export interface MockupBackgroundGradient {\n  from: string;\n  to: string;\n  angle: number;\n}",
                "MockupBackgroundImageAttribution": "interface MockupBackgroundImageAttribution {\n  source: \"pexels\" | \"unsplash\";\n  photographer: string;\n  photographerUrl: string;\n  pexelsUrl?: string;\n  unsplashUrl?: string;\n}",
                "MockupBackgroundImage": "export interface MockupBackgroundImage {\n  url: string;\n  storagePath?: string;\n  blur?: number;\n  verticalOffset?: number;\n  horizontalOffset?: number;\n  attribution?: MockupBackgroundImageAttribution;\n}",
                "BackgroundMode": "export type BackgroundMode = \"color\" | \"gradient\" | \"image\";",
                "OverlayShape": "export type OverlayShape = \"circle\" | \"rectangle\";",
                "MockupOverlay": "/**\n * A screen-space \"presenter cam\" picture-in-picture composited on top of the\n * mockup animation (Loom-style). The clip is a recorded or uploaded video\n * persisted to storage; geometry is normalized to the output frame so the live\n * preview (CSS) and the export (canvas) stay in sync.\n */\nexport interface MockupOverlay {\n  enabled: boolean;\n  /** Storage path of the recorded/uploaded clip (signed on load). */\n  mediaPath: string | null;\n  shape: OverlayShape;\n  /** Center position as a fraction of the frame, 0..1. */\n  x: number;\n  y: number;\n  /** PiP width as a fraction of the frame width, 0..1. */\n  size: number;\n  /** Mirror horizontally (natural selfie view). */\n  mirrored: boolean;\n  /** Volume of the clip's own audio in the export, 0..1 (0 = silent). */\n  audioVolume?: number;\n}",
                "MockupAudioTrack": "/** A custom background sound track uploaded in the Sound tab. */\nexport interface MockupAudioTrack {\n  /** Storage path of the uploaded audio (signed on load). */\n  storagePath?: string | null;\n  /** Bundled catalog track. Mutually exclusive with storagePath. */\n  bundledMusicId?: BackgroundMusicId | null;\n  url?: string | null;\n  fileName?: string | null;\n  /** Decoded source length, used to bound the selectable export segment. */\n  sourceDurationSeconds?: number | null;\n  /** Offset into the source used at the beginning of every animation loop. */\n  startTimeSeconds?: number;\n  /** 0..1 */\n  volume: number;\n}",
                "MockupProjectState": "export interface MockupProjectState {\n  /** Per-device composition. Absent on legacy single-device projects. */\n  devices?: MockupDevice[];\n  selectedMediaPath: string | null;\n  deviceModelId?: DeviceModelId;\n  motion: MotionPreset;\n  finish: Finish;\n  speed: number;\n  background: BackgroundPreset;\n  backgroundMode: BackgroundMode;\n  backgroundColor: string;\n  backgroundGradient: MockupBackgroundGradient | null;\n  backgroundImage: MockupBackgroundImage | null;\n  showDynamicIsland: boolean;\n  /**\n   * @deprecated Lid openness moved onto the keyframes so it can animate. Kept\n   * so projects saved before the move still load: their single value seeds\n   * every keyframe (see `migrateLidOpenToKeyframes`).\n   */\n  lidOpen?: number;\n  outputRatio: OutputRatio;\n  motionDuration: number;\n  deviceScale: number;\n  primaryKeyframes: MockupKeyframe[];\n  isPlaying: boolean;\n  overlay: MockupOverlay | null;\n  /** Custom background sound mixed into the export (Sound tab). */\n  audio: MockupAudioTrack | null;\n}",
                "MockupTransition": "export type MockupTransition = \"none\" | \"fade\" | \"slide\" | \"scale\";",
                "MockupDeviceTiming": "export interface MockupDeviceTiming {\n  start: number;\n  /** null keeps the device visible until the composition ends. */\n  end: number | null;\n  entrance: MockupTransition;\n  exit: MockupTransition;\n  transitionDuration: number;\n}",
                "MockupDevice": "export interface MockupDevice extends MockupDeviceTiming {\n  id: string;\n  name: string;\n  selectedMediaPath: string | null;\n  deviceModelId: DeviceModelId;\n  motion: MotionPreset;\n  finish: Finish;\n  deviceScale: number;\n  showDynamicIsland: boolean;\n  primaryKeyframes: MockupKeyframe[];\n}",
                "DEFAULT_MOCKUP_OVERLAY": "export const DEFAULT_MOCKUP_OVERLAY: MockupOverlay = {\n  enabled: true,\n  mediaPath: null,\n  shape: \"circle\",\n  x: 0.8,\n  y: 0.8,\n  size: 0.26,\n  mirrored: true,\n  audioVolume: 1,\n};",
                "migrateLidOpenToKeyframes": "/**\n * Projects saved before the lid became keyframable carry one project-wide\n * `lidOpen`. Push it onto every keyframe on load so the timeline is the single\n * source of truth from then on; already-migrated states pass through untouched.\n */\nexport function migrateLidOpenToKeyframes(\n  keyframes: MockupKeyframe[],\n  legacyLidOpen: number | undefined,\n): MockupKeyframe[] {\n  if (legacyLidOpen === undefined) return keyframes;\n  if (keyframes.some((frame) => frame.lidOpen !== undefined)) return keyframes;\n  return keyframes.map((frame) => ({ ...frame, lidOpen: legacyLidOpen }));\n}",
                "MockupProjectRecord": "export interface MockupProjectRecord {\n  id: string;\n  generation_id: string;\n  variant_id: string;\n  state: MockupProjectState;\n  created_at: string;\n  updated_at: string;\n}",
                "DEFAULT_MOCKUP_PROJECT_STATE": "export const DEFAULT_MOCKUP_PROJECT_STATE: MockupProjectState = {\n  selectedMediaPath: null,\n  deviceModelId: \"iphone-17-pro\",\n  motion: \"hero-reveal\",\n  finish: \"silver\",\n  speed: 1,\n  background: \"soft\",\n  backgroundMode: \"color\",\n  backgroundColor: \"#ffffff\",\n  backgroundGradient: null,\n  backgroundImage: null,\n  showDynamicIsland: false,\n  outputRatio: \"4:3\",\n  motionDuration: 6,\n  deviceScale: 1,\n  primaryKeyframes: [\n    {\n      id: \"start\",\n      time: 0,\n      x: -0.18,\n      y: -0.12,\n      rotationX: 0.18,\n      rotationY: -0.5,\n      rotationZ: -0.12,\n      scale: 0.92,\n      easing: \"ease-in-out\",\n    },\n    {\n      id: \"focus\",\n      time: 0.45,\n      x: 0,\n      y: 0.03,\n      rotationX: -0.08,\n      rotationY: -0.1,\n      rotationZ: -0.02,\n      scale: 1.08,\n      easing: \"ease-in-out\",\n    },\n    {\n      id: \"end\",\n      time: 1,\n      x: 0,\n      y: 0,\n      rotationX: -0.04,\n      rotationY: 0.08,\n      rotationZ: 0,\n      scale: 1,\n      easing: \"ease-in-out\",\n    },\n  ],\n  isPlaying: true,\n  overlay: null,\n  audio: null,\n};"
            }
        },
        {
            "group": "mockups",
            "path": "src/app/api/mockups/apiSchema.ts",
            "sha256": "7544f992677d67e7ed0b59aaa2260b199b0dfcc51959632d105914b6b9c1d81c",
            "declarations": {
                "keyframeSchema": "// Objects are loose so fields this schema does not know survive parsing (also\n// through the /api/v1 gateway); the save route strips and reports them\n// (stripUnknownFields) instead of dropping them silently.\nconst keyframeSchema = z.looseObject({\n  id: z.string().min(1),\n  /** Normalized 0..1 position inside the device's visibility window. */\n  time: z.number().min(0).max(1),\n  x: z.number().min(-3).max(3),\n  y: z.number().min(-3).max(3),\n  rotationX: z\n    .number()\n    .min(-2 * Math.PI)\n    .max(2 * Math.PI),\n  rotationY: z\n    .number()\n    .min(-2 * Math.PI)\n    .max(2 * Math.PI),\n  rotationZ: z\n    .number()\n    .min(-2 * Math.PI)\n    .max(2 * Math.PI),\n  scale: z.number().min(0.2).max(3),\n  lidOpen: z.number().min(0).max(1).optional(),\n  easing: z.enum([\"linear\", \"ease-in\", \"ease-out\", \"ease-in-out\"]),\n});",
                "keyframesSchema": "/**\n * The renderer interpolates keyframes in array order, so out-of-order times\n * would jump. Store them sorted by time (stable for equal times).\n */\nconst keyframesSchema = z\n  .array(keyframeSchema)\n  .min(2)\n  .max(8)\n  .transform((frames) =>\n    frames\n      .map((frame, index) => ({ frame, index }))\n      .sort((a, b) => a.frame.time - b.frame.time || a.index - b.index)\n      .map(({ frame }) => frame),\n  );",
                "finishIssue": "function finishIssue(deviceModelId: DeviceModelId, finish: Finish) {\n  const allowed = getAcceptedFinishesForDeviceModel(deviceModelId);\n  return allowed.includes(finish)\n    ? null\n    : `Finish \"${finish}\" is not available for ${DEVICE_MODEL_SPECS[deviceModelId].label} (${deviceModelId}). Use one of: ${allowed.join(\", \")}.`;\n}",
                "legacyStateSchema": "const legacyStateSchema = z.looseObject({\n  selectedMediaPath: projectMediaPathSchema.nullable(),\n  deviceModelId: z.enum(DEVICE_MODEL_IDS).default(\"iphone-17-pro\"),\n  motion: z.enum([\n    \"hero-reveal\",\n    \"feature-sweep\",\n    \"showcase-orbit\",\n    \"parallax-tilt\",\n    \"custom\",\n  ]),\n  finish: z.enum([\n    \"silver\",\n    \"black\",\n    \"glacier\",\n    \"burgundy\",\n    \"cosmic-orange\",\n    \"deep-blue\",\n    \"pixel-obsidian\",\n    \"pixel-porcelain\",\n    \"pixel-moonstone\",\n    \"duo-night-sky\",\n    \"duo-star-white\",\n  ]),\n  speed: z.number().min(0.7).max(1.4),\n  background: z.enum([\"soft\", \"paper\", \"midnight\", \"transparent\"]),\n  backgroundMode: z.enum([\"color\", \"gradient\", \"image\"]).default(\"color\"),\n  backgroundColor: z.string(),\n  backgroundGradient: z\n    .looseObject({\n      from: z.string(),\n      to: z.string(),\n      angle: z.number(),\n    })\n    .nullable()\n    .default(null),\n  backgroundImage: z\n    .looseObject({\n      url: z.string(),\n      storagePath: projectMediaPathSchema.optional(),\n      blur: z.number().min(0).max(20).optional(),\n      verticalOffset: z.number().min(0).max(100).optional(),\n      horizontalOffset: z.number().min(0).max(100).optional(),\n      attribution: z\n        .looseObject({\n          source: z.enum([\"pexels\", \"unsplash\"]),\n          photographer: z.string(),\n          photographerUrl: z.string(),\n          pexelsUrl: z.string().optional(),\n          unsplashUrl: z.string().optional(),\n        })\n        .optional(),\n    })\n    .nullable()\n    .default(null),\n  showDynamicIsland: z.boolean().default(false),\n  // Legacy: lid openness lives on the keyframes now. Older saved states still\n  // carry it and are migrated on load, so it stays accepted but optional.\n  lidOpen: z.number().min(0).max(1).optional(),\n  outputRatio: z.enum([\"1:1\", \"4:3\", \"16:9\", \"9:16\"]),\n  motionDuration: z.number().min(1).max(60),\n  deviceScale: z.number().min(0.7).max(1.3),\n  primaryKeyframes: keyframesSchema,\n  isPlaying: z.boolean(),\n  overlay: z\n    .looseObject({\n      enabled: z.boolean(),\n      mediaPath: projectMediaPathSchema.nullable(),\n      shape: z.enum([\"circle\", \"rectangle\"]),\n      x: z.number().min(0).max(1),\n      y: z.number().min(0).max(1),\n      size: z.number().min(0).max(1),\n      mirrored: z.boolean(),\n      audioVolume: z.number().min(0).max(1).optional(),\n    })\n    .nullable()\n    .default(null),\n  audio: MockupAudioTrackSchema.nullable().default(null),\n});",
                "mockupDeviceSchema": "const mockupDeviceSchema = legacyStateSchema\n  .pick({\n    selectedMediaPath: true,\n    deviceModelId: true,\n    motion: true,\n    finish: true,\n    deviceScale: true,\n    showDynamicIsland: true,\n    primaryKeyframes: true,\n  })\n  .extend({\n    id: z.string().min(1).max(100),\n    name: z.string().max(60),\n    start: z.number().min(0).max(59.9),\n    end: z.number().min(0.1).max(60).nullable(),\n    entrance: z.enum([\"none\", \"fade\", \"slide\", \"scale\"]),\n    exit: z.enum([\"none\", \"fade\", \"slide\", \"scale\"]),\n    transitionDuration: z.number().min(0.1).max(3),\n  });",
                "stateSchema": "export const stateSchema = legacyStateSchema\n  .extend({\n    devices: z\n      .array(mockupDeviceSchema)\n      .min(1)\n      .max(MAX_MOCKUP_DEVICES)\n      .optional(),\n  })\n  .superRefine((state, context) => {\n    const legacyFinish = finishIssue(\n      state.deviceModelId,\n      state.finish as Finish,\n    );\n    if (legacyFinish && !state.devices?.length)\n      context.addIssue({\n        code: \"custom\",\n        path: [\"finish\"],\n        message: legacyFinish,\n      });\n    const ids = new Set<string>();\n    state.devices?.forEach((device, index) => {\n      if (ids.has(device.id))\n        context.addIssue({\n          code: \"custom\",\n          path: [\"devices\", index, \"id\"],\n          message: \"Device IDs must be unique\",\n        });\n      ids.add(device.id);\n      const end = device.end ?? state.motionDuration;\n      if (end > state.motionDuration || end - device.start < 0.099999)\n        context.addIssue({\n          code: \"custom\",\n          path: [\"devices\", index, \"end\"],\n          message:\n            \"Device timing must fit within the composition with at least 0.1s duration\",\n        });\n      const deviceFinish = finishIssue(\n        device.deviceModelId,\n        device.finish as Finish,\n      );\n      if (deviceFinish)\n        context.addIssue({\n          code: \"custom\",\n          path: [\"devices\", index, \"finish\"],\n          message: deviceFinish,\n        });\n      if (!device.selectedMediaPath)\n        context.addIssue({\n          code: \"custom\",\n          path: [\"devices\", index, \"selectedMediaPath\"],\n          message: \"Choose a screenshot or video for each device\",\n        });\n      if (\n        new Set(device.primaryKeyframes.map((frame) => frame.id)).size !==\n        device.primaryKeyframes.length\n      )\n        context.addIssue({\n          code: \"custom\",\n          path: [\"devices\", index, \"primaryKeyframes\"],\n          message: \"Keyframe IDs must be unique within a device\",\n        });\n    });\n  });",
                "saveSchema": "export const saveSchema = z\n  .object({\n    generationId: z.string().uuid(),\n    /** Omitted: the project's active mockup variant (API/MCP callers). */\n    variantId: z.string().uuid().optional(),\n    state: stateSchema,\n    /**\n     * API callers: when a device's model changes kind (phone/duo/tablet/\n     * laptop) and its keyframes are still an untouched scene preset, re-fit\n     * them to the new model like the create flow does. The editor never sends\n     * this, so its saves keep exactly what was authored.\n     */\n    fitMotionToDevice: z.boolean().optional(),\n  })\n  .strict()\n  .superRefine((value, context) => {\n    const audioPath = value.state.audio?.storagePath;\n    if (audioPath && !projectMediaPathSchema.safeParse(audioPath).success) {\n      context.addIssue({\n        code: z.ZodIssueCode.custom,\n        path: [\"state\", \"audio\", \"storagePath\"],\n        message: \"Audio path must be project-relative\",\n      });\n    }\n  });"
            }
        },
        {
            "group": "mockups",
            "path": "src/features/mockups/lib/audioTrackSchema.ts",
            "sha256": "b53c5534e8afb3835f903ee62a0ea6943e21d86902a73ea9946dcf6bb3adc463",
            "declarations": {
                "MockupAudioTrackSchema": "// Loose: the mockup save route reports unknown fields instead of dropping them.\nexport const MockupAudioTrackSchema = z\n  .looseObject({\n    storagePath: z.string().min(1).nullable().optional(),\n    bundledMusicId: z.enum(BACKGROUND_MUSIC_IDS).nullable().optional(),\n    url: z.string().nullable().optional(),\n    fileName: z.string().nullable().optional(),\n    sourceDurationSeconds: z.number().positive().nullable().optional(),\n    startTimeSeconds: z.number().min(0).default(0),\n    volume: z.number().min(0).max(1),\n  })\n  .superRefine((audio, context) => {\n    const hasUpload = Boolean(audio.storagePath);\n    const hasBundledTrack = Boolean(audio.bundledMusicId);\n    if (hasUpload === hasBundledTrack) {\n      context.addIssue({\n        code: \"custom\",\n        message: \"Audio must reference exactly one uploaded or bundled track.\",\n      });\n    }\n  });"
            }
        },
        {
            "group": "shared",
            "path": "src/features/mockups/config.ts",
            "sha256": "a093ad998642cd3a047ae80f5b849506f662d6a56086ab2111c78f5bfdb80a16",
            "declarations": {
                "OUTPUT_RATIOS": "export const OUTPUT_RATIOS: Record<\n  OutputRatio,\n  { label: string; aspectRatio: string; detail: string }\n> = {\n  \"4:3\": { label: \"Classic\", aspectRatio: \"4 / 3\", detail: \"4:3\" },\n  \"1:1\": { label: \"Square\", aspectRatio: \"1 / 1\", detail: \"1:1\" },\n  \"16:9\": { label: \"Landscape\", aspectRatio: \"16 / 9\", detail: \"16:9\" },\n  \"9:16\": { label: \"Story\", aspectRatio: \"9 / 16\", detail: \"9:16\" },\n};",
                "EXPORT_DIMENSIONS": "export const EXPORT_DIMENSIONS: Record<\n  OutputRatio,\n  { width: number; height: number; label: string }\n> = {\n  \"4:3\": { width: 1440, height: 1080, label: \"1440 x 1080\" },\n  \"1:1\": { width: 1080, height: 1080, label: \"1080 x 1080\" },\n  \"16:9\": { width: 1920, height: 1080, label: \"1920 x 1080\" },\n  \"9:16\": { width: 1080, height: 1920, label: \"1080 x 1920\" },\n};",
                "kf": "function kf(\n  prefix: string,\n  index: number,\n  time: number,\n  pose: Partial<Omit<MockupKeyframe, \"id\" | \"time\" | \"easing\">>,\n  easing: MockupKeyframe[\"easing\"] = \"ease-in-out\",\n): MockupKeyframe {\n  return {\n    id: `${prefix}-${index}`,\n    time,\n    x: pose.x ?? 0,\n    y: pose.y ?? 0,\n    rotationX: pose.rotationX ?? 0,\n    rotationY: pose.rotationY ?? 0,\n    rotationZ: pose.rotationZ ?? 0,\n    scale: pose.scale ?? 1,\n    easing,\n  };\n}",
                "PI": "const PI = Math.PI;",
                "SCENE_PRESETS": "// Four demo-optimised presets. Each ships a complete keyframe sequence so the\n// timeline always shows exactly what the scene is playing — no parallel\n// \"preset\" motion path. Loops are seamless (first and last poses match).\nexport const SCENE_PRESETS = [\n  {\n    id: \"hero-launch\",\n    label: \"Cinematic swing\",\n    description:\n      \"Tilts in from the side, settles face-on with a soft zoom, then swings back out.\",\n    previewUrl: \"/demos/mockup-animations/cinematic-intro.mp4\",\n    state: {\n      motion: \"hero-reveal\" as const,\n      motionDuration: 6,\n      deviceScale: 1,\n      keyframes: [\n        kf(\"hero\", 0, 0, {\n          x: 0.18,\n          y: -0.12,\n          rotationX: 0.12,\n          rotationY: -0.45,\n          rotationZ: -0.08,\n          scale: 0.94,\n        }),\n        kf(\"hero\", 1, 0.22, { rotationX: -0.02 }),\n        kf(\"hero\", 2, 0.7339228592519685, {\n          y: 0.004,\n          rotationX: -0.02,\n          scale: 1.2289999999999959,\n        }),\n        kf(\"hero\", 3, 1, {\n          x: 0.18,\n          y: -0.12,\n          rotationX: 0.12,\n          rotationY: -0.45,\n          rotationZ: -0.08,\n          scale: 0.94,\n        }),\n      ],\n    },\n  },\n  {\n    id: \"pivot-showcase\",\n    label: \"Spin reveal\",\n    description:\n      \"Fast 360° spin to reveal the screen, then a slow breathing zoom.\",\n    previewUrl: \"/demos/mockup-animations/pivot-showcase.mp4\",\n    state: {\n      motion: \"feature-sweep\" as const,\n      motionDuration: 7,\n      deviceScale: 1,\n      keyframes: [\n        kf(\"pivot\", 0, 0, {\n          rotationX: 0.0083203125,\n          rotationY: -2 * PI,\n        }),\n        kf(\"pivot\", 1, 0.10385088582677166, {}),\n        kf(\"pivot\", 2, 0.8468165600393701, { scale: 1.2279999999999958 }),\n        kf(\"pivot\", 3, 1, {}),\n      ],\n    },\n  },\n  {\n    id: \"slow-rotate\",\n    label: \"Drop & zoom\",\n    description:\n      \"Drops in from above with a full rotation, then a dramatic zoom toward the screen.\",\n    previewUrl: \"/demos/mockup-animations/slow-rotate.mp4\",\n    state: {\n      motion: \"showcase-orbit\" as const,\n      motionDuration: 10,\n      deviceScale: 1,\n      keyframes: [\n        kf(\n          \"orbit\",\n          0,\n          0,\n          { x: -0.00287109375, y: 0.9, rotationY: -2 * PI },\n          \"linear\",\n        ),\n        kf(\"orbit\", 1, 0.11608329232283465, {}),\n        kf(\"orbit\", 2, 0.5327417568897638, { scale: 1.144999999999999 }),\n        kf(\"orbit\", 3, 0.7851716289370079, {\n          x: -0.056531250000000005,\n          y: -0.34374609375000004,\n          rotationX: -0.58421875,\n          rotationY: -0.433185307179586,\n          rotationZ: -0.133185307179586,\n          scale: 2.182999999999995,\n        }),\n        kf(\"orbit\", 4, 1, {\n          x: 0.015890625000000002,\n          y: -0.40109765625,\n          scale: 1.9079999999999984,\n        }),\n      ],\n    },\n  },\n  {\n    id: \"drifting-tilt\",\n    label: \"Punch zoom\",\n    description:\n      \"Scales up from a small punch-in to fill the frame. Great for hero reveals.\",\n    previewUrl: \"/demos/mockup-animations/drifting-tilt.mp4\",\n    state: {\n      motion: \"parallax-tilt\" as const,\n      motionDuration: 8,\n      deviceScale: 1,\n      keyframes: [\n        kf(\"drift\", 0, 0, { scale: 0.3 }),\n        kf(\"drift\", 1, 0.08378752460629921, {\n          scale: 1.1339999999999983,\n        }),\n        kf(\"drift\", 2, 0.5301488681102362, {\n          scale: 1.2249999999999974,\n        }),\n        kf(\"drift\", 3, 1, { scale: 1.1339999999999983 }),\n      ],\n    },\n  },\n] as const;",
                "ScenePresetId": "export type ScenePresetId = (typeof SCENE_PRESETS)[number][\"id\"];",
                "ScenePresetDeviceKind": "export type ScenePresetDeviceKind = \"phone\" | \"tablet\" | \"laptop\";",
                "ScenePresetDevice": "/**\n * What a preset is fitted to: a device kind, or the iPhone Duo, which moves\n * like a phone but is shown unfolded and turned portrait.\n */\nexport type ScenePresetDevice = ScenePresetDeviceKind | \"duo\";",
                "MOCKUP_DEVICE_CHOICES": "/**\n * The device choices offered when creating a mockup, in tab order. \"Phone\"\n * resolves to the iPhone or the Pixel from the project's platform — the two\n * phone models stay swappable in the studio's Device panel, they just don't\n * need to be separate choices this early.\n */\nexport const MOCKUP_DEVICE_CHOICES = [\n  { id: \"phone\" as const, label: \"Phone\" },\n  { id: \"duo\" as const, label: \"iPhone Duo\" },\n  { id: \"tablet\" as const, label: \"iPad\" },\n  { id: \"laptop\" as const, label: \"MacBook\" },\n] as const satisfies readonly {\n  id: ScenePresetDevice;\n  label: string;\n}[];",
                "MockupDeviceChoiceId": "export type MockupDeviceChoiceId = (typeof MOCKUP_DEVICE_CHOICES)[number][\"id\"];",
                "MOCKUP_DEVICE_CHOICE_IDS": "/** Choice ids in tab order, for zod enums and option lists. */\nexport const MOCKUP_DEVICE_CHOICE_IDS = MOCKUP_DEVICE_CHOICES.map(\n  (choice) => choice.id,\n) as [MockupDeviceChoiceId, ...MockupDeviceChoiceId[]];",
                "LAPTOP_LID_BY_KEYFRAME": "/**\n * Laptop lid choreography: one openness value per keyframe of the preset, in\n * order, so the hinge lands exactly on the beats the motion already has instead\n * of being resampled onto them. Presets whose first and last pose match\n * (hero-launch, pivot-showcase) close the lid again at the end so the loop\n * stays seamless; the two that end elsewhere leave it open.\n */\nconst LAPTOP_LID_BY_KEYFRAME: Record<ScenePresetId, readonly number[]> = {\n  // Swings in shut, opens as it settles face-on, closes as it swings back out.\n  \"hero-launch\": [0.05, 1, 1, 0.05],\n  // Spins shut, and is open by the time the screen faces the camera.\n  \"pivot-showcase\": [0.08, 1, 1, 0.08],\n  // Drops in closed like a laptop being set down, opens once it has landed.\n  \"slow-rotate\": [0, 0.15, 1, 1, 1],\n  // Starts ajar and opens through the punch-in.\n  \"drifting-tilt\": [0.4, 1, 1, 1],\n};",
                "getScenePresetForDevice": "/**\n * The authored presets use phone-sized movement. Keep their timing and\n * character, but reduce travel, rotation and zoom for wider devices so every\n * preset stays framed on tablet and desktop models. Laptops additionally get\n * the lid animated along the way.\n */\nexport function getScenePresetForDevice(\n  presetId: ScenePresetId,\n  device: ScenePresetDevice,\n) {\n  const preset = SCENE_PRESETS.find((item) => item.id === presetId);\n  if (!preset) return null;\n  const deviceKind: ScenePresetDeviceKind = device === \"duo\" ? \"phone\" : device;\n  // The Duo plays the phone choreography unfolded and a quarter turned\n  // (portrait, like App Store inner shots), where it stands 1.4x a phone's\n  // height at one scale; scaled back so it frames like one.\n  const isDuo = device === \"duo\";\n\n  const factors =\n    deviceKind === \"tablet\"\n      ? { position: 0.75, rotation: 0.65, scale: 0.72 }\n      : deviceKind === \"laptop\"\n        ? { position: 0.5, rotation: 0.24, scale: 0.38 }\n        : { position: 1, rotation: 1, scale: 1 };\n  const lidByKeyframe =\n    deviceKind === \"laptop\" ? LAPTOP_LID_BY_KEYFRAME[presetId] : null;\n\n  return {\n    ...preset,\n    state: {\n      ...preset.state,\n      deviceScale: preset.state.deviceScale * (isDuo ? 1 / 1.4 : 1),\n      keyframes: preset.state.keyframes.map((frame, index) => ({\n        ...frame,\n        x: frame.x * factors.position,\n        y: frame.y * factors.position,\n        rotationX: frame.rotationX * factors.rotation,\n        rotationY: frame.rotationY * factors.rotation,\n        rotationZ:\n          frame.rotationZ * factors.rotation - (isDuo ? Math.PI / 2 : 0),\n        scale: 1 + (frame.scale - 1) * factors.scale,\n        // A short list would silently leave later keyframes lidless, so fall\n        // back to the last authored value rather than to \"open\".\n        ...(lidByKeyframe\n          ? {\n              lidOpen:\n                lidByKeyframe[index] ?? lidByKeyframe[lidByKeyframe.length - 1],\n            }\n          : {}),\n      })),\n    },\n  };\n}",
                "FINISHES": "export const FINISHES: Record<\n  Finish,\n  {\n    label: string;\n    shell: string;\n    metal: string;\n    glassTint: string;\n    swatchImage: string;\n  }\n> = {\n  silver: {\n    label: \"Silver\",\n    shell: \"#f1f2ef\",\n    metal: \"#d4d6d3\",\n    glassTint: \"#e8e9e6\",\n    swatchImage: \"/components/finishes/iphone18-silver.webp\",\n  },\n  // iPhone 18 Pro finishes. Hues follow Apple's store swatches (Black #3C3C3C,\n  // Glacier #E8F2FE, Burgundy #4D1822). Glacier is tinted well past its swatch:\n  // studio lighting lifts the back glass, and at the swatch value it renders\n  // indistinguishable from Silver.\n  black: {\n    label: \"Black\",\n    shell: \"#3c3c3e\",\n    // Rail kept a step lighter than the glass so the edge holds on dark\n    // template backgrounds (Black is the house default).\n    metal: \"#48484b\",\n    glassTint: \"#38383a\",\n    swatchImage: \"/components/finishes/iphone18-black.webp\",\n  },\n  glacier: {\n    label: \"Glacier\",\n    shell: \"#d6e4f4\",\n    metal: \"#a9bfd8\",\n    glassTint: \"#b6cce6\",\n    swatchImage: \"/components/finishes/iphone18-glacier.webp\",\n  },\n  burgundy: {\n    label: \"Burgundy\",\n    shell: \"#5a1e2a\",\n    metal: \"#4d1822\",\n    glassTint: \"#521b26\",\n    swatchImage: \"/components/finishes/iphone18-burgundy.webp\",\n  },\n  \"cosmic-orange\": {\n    label: \"Cosmic Orange\",\n    shell: \"#ef964a\",\n    metal: \"#d87a34\",\n    glassTint: \"#e58a42\",\n    swatchImage: \"/components/finishes/iphone17-cosmic-orange.webp\",\n  },\n  \"deep-blue\": {\n    label: \"Deep Blue\",\n    shell: \"#46516f\",\n    metal: \"#303953\",\n    glassTint: \"#3d4764\",\n    swatchImage: \"/components/finishes/iphone17-deep-blue.webp\",\n  },\n  \"pixel-obsidian\": {\n    label: \"Obsidian\",\n    shell: \"#212121\",\n    metal: \"#212121\",\n    glassTint: \"#212121\",\n    swatchImage: \"/components/finishes/pixel10-pro-obsidian.png\",\n  },\n  \"pixel-porcelain\": {\n    label: \"Porcelain\",\n    shell: \"#F2EFE9\",\n    metal: \"#F2EFE9\",\n    glassTint: \"#F2EFE9\",\n    swatchImage: \"/components/finishes/pixel10-pro-porcelain.png\",\n  },\n  \"pixel-moonstone\": {\n    label: \"Moonstone\",\n    shell: \"#8FA3B0\",\n    metal: \"#8FA3B0\",\n    glassTint: \"#8FA3B0\",\n    swatchImage: \"/components/finishes/pixel10-pro-moonstone.png\",\n  },\n  // iPhone Duo finishes (Apple's names; no official hex is published). Night\n  // Sky is a deep blue, a shade darker than the 18 Pro's Black; Star White is\n  // a plain white back with a mirror-polished titanium edge.\n  \"duo-night-sky\": {\n    label: \"Night Sky\",\n    shell: \"#1a2131\",\n    metal: \"#262e40\",\n    glassTint: \"#151b29\",\n    swatchImage: \"/components/finishes/iphone-duo-night-sky.webp\",\n  },\n  \"duo-star-white\": {\n    label: \"Star White\",\n    shell: \"#f3f2ee\",\n    metal: \"#dcdcda\",\n    glassTint: \"#efeee9\",\n    swatchImage: \"/components/finishes/iphone-duo-star-white.webp\",\n  },\n};",
                "IPHONE_18_FINISH_KEYS": "/**\n * The iPhone model renders as the iPhone 18 Pro; its finishes lead. The iPhone\n * 17 Pro colours stay selectable as a secondary group, so saved projects that\n * use them keep rendering unchanged. Silver\n * is shared by both generations and listed once, first, so `[0]` fallbacks\n * still land on it.\n */\nexport const IPHONE_18_FINISH_KEYS = [\n  \"silver\",\n  \"black\",\n  \"glacier\",\n  \"burgundy\",\n] as const satisfies readonly Finish[];",
                "IPHONE_17_FINISH_KEYS": "export const IPHONE_17_FINISH_KEYS = [\n  \"cosmic-orange\",\n  \"deep-blue\",\n] as const satisfies readonly Finish[];",
                "IPHONE_FINISH_KEYS": "/** Every finish the iPhone model accepts, current generation first. */\nexport const IPHONE_FINISH_KEYS = [\n  ...IPHONE_18_FINISH_KEYS,\n  ...IPHONE_17_FINISH_KEYS,\n] as const satisfies readonly Finish[];",
                "PIXEL_FINISH_KEYS": "export const PIXEL_FINISH_KEYS = [\n  \"pixel-obsidian\",\n  \"pixel-porcelain\",\n  \"pixel-moonstone\",\n] as const satisfies readonly Finish[];",
                "IPHONE_DUO_FINISH_KEYS": "export const IPHONE_DUO_FINISH_KEYS = [\n  \"duo-night-sky\",\n  \"duo-star-white\",\n] as const satisfies readonly Finish[];"
            }
        },
        {
            "group": "mockups",
            "path": "src/features/mockups/lib/devices.ts",
            "sha256": "cc779461a7c90a4105a7109669fd2e7739bc9c02e61b1e72f5bb7d3e1029c8ae",
            "declarations": {
                "MAX_MOCKUP_DEVICES": "export const MAX_MOCKUP_DEVICES = 6;",
                "MIN_DEVICE_DURATION": "export const MIN_DEVICE_DURATION = 0.1;",
                "DEFAULT_DEVICE_TIMING": "const DEFAULT_DEVICE_TIMING: MockupDeviceTiming = {\n  start: 0,\n  end: null,\n  entrance: \"none\",\n  exit: \"none\",\n  transitionDuration: 0.4,\n};",
                "getMockupDevices": "/** Pure, lossless legacy migration; never rearranges devices. */\nexport function getMockupDevices(state: MockupProjectState): MockupDevice[] {\n  if (state.devices?.length) return state.devices;\n  return [\n    {\n      ...DEFAULT_DEVICE_TIMING,\n      id: \"primary\",\n      name: \"Device 1\",\n      selectedMediaPath: state.selectedMediaPath,\n      deviceModelId: state.deviceModelId ?? \"iphone-17-pro\",\n      motion: state.motion,\n      finish: state.finish,\n      deviceScale: state.deviceScale,\n      showDynamicIsland: state.showDynamicIsland,\n      primaryKeyframes: migrateLidOpenToKeyframes(\n        state.primaryKeyframes,\n        state.lidOpen,\n      ),\n    },\n  ];\n}",
                "withPrimaryMirror": "/**\n * Legacy single-device fields mirror `devices[0]` so readers that predate\n * `devices` (hub preview, variant preview, setup) keep working.\n */\nexport function withPrimaryMirror(devices: MockupDevice[]) {\n  const [primary] = devices;\n  return {\n    devices,\n    selectedMediaPath: primary.selectedMediaPath,\n    deviceModelId: primary.deviceModelId,\n    motion: primary.motion,\n    finish: primary.finish,\n    showDynamicIsland: primary.showDynamicIsland,\n    deviceScale: primary.deviceScale,\n    primaryKeyframes: primary.primaryKeyframes,\n  };\n}",
                "deviceWindow": "export function deviceWindow(timing: MockupDeviceTiming, duration: number) {\n  const start = Math.max(\n    0,\n    Math.min(duration - MIN_DEVICE_DURATION, timing.start),\n  );\n  const end = Math.max(\n    start + MIN_DEVICE_DURATION,\n    Math.min(duration, timing.end ?? duration),\n  );\n  return { start, end, duration: end - start };\n}",
                "DeviceWindow": "type DeviceWindow = Pick<ReturnType<typeof deviceWindow>, \"start\" | \"duration\">;",
                "toLocalProgress": "/** Composition progress (0..1) -> progress inside one device's window. */\nexport function toLocalProgress(\n  window: DeviceWindow,\n  globalProgress: number,\n  duration: number,\n) {\n  return Math.max(\n    0,\n    Math.min(1, (globalProgress * duration - window.start) / window.duration),\n  );\n}",
                "toGlobalProgress": "/** Progress inside one device's window -> composition progress (0..1). */\nexport function toGlobalProgress(\n  window: DeviceWindow,\n  localProgress: number,\n  duration: number,\n) {\n  return (window.start + localProgress * window.duration) / duration;\n}",
                "getPlaybackTime": "/** Composition time in seconds: the looping wall clock, or the scrubbed frame. */\nexport function getPlaybackTime({\n  isPlaying,\n  animationStartedAtMs,\n  speed,\n  previewProgress,\n  motionDuration,\n}: {\n  isPlaying: boolean;\n  animationStartedAtMs: number;\n  speed: number;\n  previewProgress: number;\n  motionDuration: number;\n}) {\n  return isPlaying\n    ? (((performance.now() - animationStartedAtMs) / 1000) * speed) %\n        motionDuration\n    : previewProgress * motionDuration;\n}",
                "deviceFrame": "/** Shared clock for preview, video textures, timeline and cloud export. */\nexport function deviceFrame(\n  timing: MockupDeviceTiming,\n  time: number,\n  duration: number,\n) {\n  const window = deviceWindow(timing, duration);\n  const progress = Math.max(\n    0,\n    Math.min(1, (time - window.start) / window.duration),\n  );\n  const visible =\n    time >= window.start &&\n    (time < window.end || (time === duration && window.end === duration));\n  const transition = Math.min(timing.transitionDuration, window.duration / 2);\n  // Progress through the entrance/exit; a zero-length transition is complete.\n  const phase = (elapsed: number) =>\n    transition > 0 ? Math.max(0, Math.min(1, elapsed / transition)) : 1;\n  const ease = (t: number) => t * t * (3 - 2 * t);\n  // Travel quickly into view, then settle without overshoot. Keep moving\n  // models solid: fading individual 3D meshes exposes their back faces.\n  const settle = (t: number) => 1 - Math.pow(1 - t, 3);\n  const entering = phase(time - window.start);\n  const exiting = phase(window.end - time);\n  const enter = ease(entering);\n  const exit = ease(exiting);\n  const enterMotion = settle(entering);\n  const exitMotion = settle(exiting);\n  return {\n    ...window,\n    progress,\n    visible,\n    opacity:\n      (timing.entrance === \"fade\" ? enter : 1) *\n      (timing.exit === \"fade\" ? exit : 1),\n    offsetY:\n      (timing.entrance === \"slide\" ? (1 - enterMotion) * -5 : 0) +\n      (timing.exit === \"slide\" ? (1 - exitMotion) * -5 : 0),\n    scale:\n      (timing.entrance === \"scale\" ? enterMotion : 1) *\n      (timing.exit === \"scale\" ? exitMotion : 1),\n  };\n}",
                "duplicateMockupDevice": "export function duplicateMockupDevice(\n  device: MockupDevice,\n  id: string,\n): MockupDevice {\n  return {\n    ...device,\n    id,\n    name: `${device.name.slice(0, 55)} copy`,\n    primaryKeyframes: device.primaryKeyframes.map((frame, i) => ({\n      ...frame,\n      id: `${id}-${i}`,\n    })),\n  };\n}"
            }
        },
        {
            "group": "mockups",
            "path": "src/app/api/mockups/export/requestSchema.ts",
            "sha256": "3911d0b84b286988e4e70a18710aad6ed0c403804686d34eb4d9e37c466d7a4a",
            "declarations": {
                "mockupExportRequestSchema": "export const mockupExportRequestSchema = z\n  .object({\n    projectId: z.string().uuid(),\n    // Omitted: the project's active mockup variant (API/MCP callers).\n    variantId: z.string().uuid().optional(),\n    options: z\n      .object({\n        format: z.enum([\"mp4\", \"webm\"]),\n        quality: z.enum([\"720p\", \"1080p\"]).default(\"1080p\"),\n        fps: z.union([z.literal(30), z.literal(60)]),\n        screenDurationSeconds: z.number().positive().optional(),\n        deviceDurationSeconds: z\n          .record(z.string().max(100), z.number().positive().max(86400))\n          .optional(),\n        overlayDurationSeconds: z.number().positive().optional(),\n      })\n      .strict(),\n  })\n  .strict();"
            }
        },
        {
            "group": "mockups",
            "path": "src/app/api/mockups/preview/apiSchema.ts",
            "sha256": "bf5d5d2724337f811d56f3e77bd73da9dc18acaa209331748a71f43fdd74e2fa",
            "declarations": {
                "MAX_MOCKUP_PREVIEW_FRAMES": "const MAX_MOCKUP_PREVIEW_FRAMES = 4;",
                "mockupPreviewSchema": "export const mockupPreviewSchema = z\n  .object({\n    generationId: z.string().uuid(),\n    /** Omitted: the project's active mockup variant. */\n    variantId: z.string().uuid().optional(),\n    /**\n     * Timeline seconds to render (0 ≤ t < motionDuration; the same clock as\n     * device start/end). Defaults to the middle of the animation.\n     */\n    times: z\n      .array(z.number().finite().min(0).max(60))\n      .min(1)\n      .max(MAX_MOCKUP_PREVIEW_FRAMES)\n      .optional(),\n    /** Output size relative to the export size (e.g. 0.5 of 1440x1080). */\n    scale: z.number().min(0.25).max(1).default(0.5),\n  })\n  .strict();"
            }
        },
        {
            "group": "mockups",
            "path": "src/features/mockups/lib/cloudRenderProfile.ts",
            "sha256": "9a1217e588a374c1187d9e7c2fe8e20d8ddf53875be33675e01bb1cc820aa94b",
            "declarations": {
                "CLOUD_RENDER_FPS": "/**\n * The screen-recording profile the mockup cloud renderer is built for. The\n * editor re-encodes every recording to it before upload\n * (normalizeVideoForCloudRender); files that arrive another way (API/MCP\n * uploads) are checked against the same numbers server-side.\n */\nexport const CLOUD_RENDER_FPS = 30;",
                "CLOUD_RENDER_KEYFRAME_INTERVAL_SECONDS": "export const CLOUD_RENDER_KEYFRAME_INTERVAL_SECONDS = 2;",
                "CLOUD_RENDER_MAX_SHORT_SIDE": "export const CLOUD_RENDER_MAX_SHORT_SIDE = 1080;",
                "CLOUD_RENDER_MAX_VIDEO_BYTES": "export const CLOUD_RENDER_MAX_VIDEO_BYTES = 30 * 1024 * 1024;",
                "CLOUD_RENDER_CODECS": "/** Codecs Chromium decodes in the Lambda renderer (no HEVC/ProRes on Linux). */\nconst CLOUD_RENDER_CODECS = new Set([\"avc\", \"vp8\", \"vp9\", \"av1\"]);",
                "KEYFRAME_INTERVAL_TOLERANCE_SECONDS": "// Encoders place the last keyframe wherever the stream ends and some round\n// the interval up by a frame; only clearly longer GOPs stall parallel seeks.\nconst KEYFRAME_INTERVAL_TOLERANCE_SECONDS = 0.5;",
                "FPS_TOLERANCE": "const FPS_TOLERANCE = 0.5;",
                "VideoProbe": "export interface VideoProbe {\n  durationSeconds: number;\n  width: number;\n  height: number;\n  /** mediabunny codec id: avc, hevc, vp8, vp9, av1, … (null when unknown). */\n  codec: string | null;\n  /** Average packet rate; equals the frame rate for constant-rate video. */\n  fps: number | null;\n  /** Longest distance between two keyframes (or the stream end), in seconds. */\n  maxKeyframeIntervalSeconds: number | null;\n  sizeBytes: number | null;\n}",
                "CloudRenderAssessment": "export interface CloudRenderAssessment {\n  exportSafe: boolean;\n  issues: string[];\n}",
                "assessCloudRenderVideo": "export function assessCloudRenderVideo(\n  probe: VideoProbe,\n): CloudRenderAssessment {\n  const issues: string[] = [];\n  if (!probe.codec || !CLOUD_RENDER_CODECS.has(probe.codec)) {\n    issues.push(\n      `Codec ${probe.codec ?? \"unknown\"} cannot be decoded by the cloud renderer; use H.264 (avc).`,\n    );\n  }\n  if (Math.min(probe.width, probe.height) > CLOUD_RENDER_MAX_SHORT_SIDE) {\n    issues.push(\n      `Resolution ${probe.width}x${probe.height} exceeds ${CLOUD_RENDER_MAX_SHORT_SIDE}px on the short side.`,\n    );\n  }\n  if (probe.fps !== null && probe.fps > 60 + FPS_TOLERANCE) {\n    issues.push(\n      `Frame rate ${probe.fps.toFixed(1)} fps is above 60 fps (${CLOUD_RENDER_FPS} fps recommended).`,\n    );\n  }\n  if (\n    probe.maxKeyframeIntervalSeconds !== null &&\n    probe.maxKeyframeIntervalSeconds >\n      CLOUD_RENDER_KEYFRAME_INTERVAL_SECONDS +\n        KEYFRAME_INTERVAL_TOLERANCE_SECONDS\n  ) {\n    issues.push(\n      `Keyframes are ${probe.maxKeyframeIntervalSeconds.toFixed(1)} s apart; the cloud renderer needs one at least every ${CLOUD_RENDER_KEYFRAME_INTERVAL_SECONDS} s.`,\n    );\n  }\n  if (\n    probe.sizeBytes !== null &&\n    probe.sizeBytes > CLOUD_RENDER_MAX_VIDEO_BYTES\n  ) {\n    issues.push(\n      `File is ${(probe.sizeBytes / (1024 * 1024)).toFixed(1)} MB; screen recordings must be 30 MB or smaller.`,\n    );\n  }\n  return { exportSafe: issues.length === 0, issues };\n}",
                "CLOUD_RENDER_FFMPEG_COMMAND": "/**\n * An ffmpeg command that produces the same profile the editor encodes in the\n * browser: H.264, 30 fps CFR, a keyframe every 2 s, ≤1080 px short side, no\n * audio (the device screen is muted; soundtrack audio is a separate track).\n */\nexport const CLOUD_RENDER_FFMPEG_COMMAND =\n  \"ffmpeg -i input.mov -an -c:v libx264 -pix_fmt yuv420p -r 30 -g 60 -keyint_min 60 -sc_threshold 0 \" +\n  \"-vf \\\"scale='if(lte(iw,ih),min(iw,1080),-2)':'if(lte(iw,ih),-2,min(ih,1080))'\\\" \" +\n  \"-b:v 3500k -maxrate 3500k -bufsize 7000k -movflags +faststart output.mp4\";"
            }
        },
        {
            "group": "mockups",
            "path": "src/features/mockups/lib/finishes.ts",
            "sha256": "9afec3aaed8f83543b22905723c5ee7bdde6d59d6bcff9904f69a5977786550b",
            "declarations": {
                "getAcceptedFinishesForDeviceModel": "/**\n * Finishes a saved device may carry: the model's selectable finishes plus its\n * default. The two can differ (iPad/MacBook offer only \"silver\" but default\n * to the house \"black\", which they render the same), and create flows store\n * the default, so validation must accept it.\n */\nexport function getAcceptedFinishesForDeviceModel(\n  deviceModelId: DeviceModelId,\n): readonly Finish[] {\n  const keys = getFinishKeysForDeviceModel(deviceModelId);\n  const fallback = getDefaultFinishForDeviceModel(deviceModelId);\n  return keys.includes(fallback) ? keys : [...keys, fallback];\n}"
            }
        },
        {
            "group": "shared",
            "path": "src/shared/config/backgroundMusic.ts",
            "sha256": "cea32820e13553ba53c203850ed8b1893f125b96313455e72cd18c649f0ab90f",
            "declarations": {
                "BACKGROUND_MUSIC_IDS": "export const BACKGROUND_MUSIC_IDS = [\n  \"clean-tech-pulse\",\n  \"optimistic-product-pop\",\n  \"cinematic-innovation\",\n  \"warm-human-motion\",\n  \"luxury-ambient\",\n  \"playful-kinetic\",\n  \"high-energy-social\",\n  \"calm-utility\",\n  \"night-drive\",\n  \"lofi-daydream\",\n  \"sunrise-organic\",\n  \"bold-launch\",\n] as const;",
                "BackgroundMusicId": "export type BackgroundMusicId = (typeof BACKGROUND_MUSIC_IDS)[number];",
                "BACKGROUND_MUSIC_DURATION_SECONDS": "/** Bundled tracks are generated as 30-second source files. */\nexport const BACKGROUND_MUSIC_DURATION_SECONDS = 30;",
                "BackgroundMusicBeatGrid": "/**\n * Constant-tempo beat grid of a bundled track, for cutting scenes on the beat.\n *\n * Beat n (0-based) is at `beatOffsetSeconds + n * 60 / bpm`; every\n * `beatsPerBar`-th beat from the offset is a downbeat.\n *\n * - `authored`: the track was composed on this grid by\n *   `scripts/promo-music/compose.py`, so it is exact. Their BPMs make one beat a\n *   whole number of frames at 30 fps (90/100/120 BPM = 20/18/15 frames).\n * - `measured`: detected by `scripts/promo-music/beatgrid.py` and cross-checked\n *   against section changes. Beat times are accurate to about 10 ms (validated\n *   on the authored tracks); the downbeat can be half a bar out, which still\n *   lands on a beat.\n */\nexport interface BackgroundMusicBeatGrid {\n  bpm: number;\n  beatsPerBar: number;\n  /** Time of the first downbeat (beat 1 of a bar). */\n  beatOffsetSeconds: number;\n  source: \"authored\" | \"measured\";\n  /** Downbeat where the arrangement opens up: a good place for a hero reveal. */\n  liftSeconds?: number;\n  /** Downbeat of the closing hit; everything after it is the ring-out. */\n  finalHitSeconds?: number;\n}",
                "BackgroundMusicTrack": "interface BackgroundMusicTrack {\n  title: string;\n  mood: string;\n  path: string;\n  /** Absent when no steady grid could be measured. */\n  beatGrid?: BackgroundMusicBeatGrid;\n}",
                "BACKGROUND_MUSIC_TRACKS": "export const BACKGROUND_MUSIC_TRACKS: Record<\n  BackgroundMusicId,\n  BackgroundMusicTrack\n> = {\n  \"clean-tech-pulse\": {\n    title: \"Clean Tech Pulse\",\n    mood: \"Focused, modern, precise\",\n    path: \"promovideo/audio/music/clean-tech-pulse.m4a\",\n    beatGrid: {\n      bpm: 96,\n      beatsPerBar: 4,\n      beatOffsetSeconds: 0.022,\n      source: \"measured\",\n    },\n  },\n  \"optimistic-product-pop\": {\n    title: \"Optimistic Product Pop\",\n    mood: \"Bright, friendly, uplifting\",\n    path: \"promovideo/audio/music/optimistic-product-pop.m4a\",\n    beatGrid: {\n      bpm: 124,\n      beatsPerBar: 4,\n      beatOffsetSeconds: 0.017,\n      source: \"measured\",\n    },\n  },\n  \"cinematic-innovation\": {\n    title: \"Cinematic Innovation\",\n    mood: \"Bold, premium, expansive\",\n    path: \"promovideo/audio/music/cinematic-innovation.m4a\",\n    beatGrid: {\n      bpm: 124,\n      beatsPerBar: 4,\n      beatOffsetSeconds: 0.017,\n      source: \"measured\",\n    },\n  },\n  \"warm-human-motion\": {\n    title: \"Warm Human Motion\",\n    mood: \"Warm, personal, reassuring\",\n    path: \"promovideo/audio/music/warm-human-motion.m4a\",\n    beatGrid: {\n      bpm: 92,\n      beatsPerBar: 4,\n      beatOffsetSeconds: 0.76,\n      source: \"measured\",\n    },\n  },\n  \"luxury-ambient\": {\n    title: \"Luxury Ambient\",\n    mood: \"Elegant, spacious, refined\",\n    path: \"promovideo/audio/music/luxury-ambient.m4a\",\n    beatGrid: {\n      bpm: 108,\n      beatsPerBar: 4,\n      beatOffsetSeconds: 0.017,\n      source: \"measured\",\n    },\n  },\n  \"playful-kinetic\": {\n    title: \"Playful Kinetic\",\n    mood: \"Playful, light, energetic\",\n    path: \"promovideo/audio/music/playful-kinetic.m4a\",\n  },\n  \"high-energy-social\": {\n    title: \"High Energy Social\",\n    mood: \"Fast, punchy, attention-grabbing\",\n    path: \"promovideo/audio/music/high-energy-social.m4a\",\n    beatGrid: {\n      bpm: 118,\n      beatsPerBar: 4,\n      beatOffsetSeconds: 0.018,\n      source: \"measured\",\n    },\n  },\n  \"calm-utility\": {\n    title: \"Calm Utility\",\n    mood: \"Calm, clear, dependable\",\n    path: \"promovideo/audio/music/calm-utility.m4a\",\n    beatGrid: {\n      bpm: 112,\n      beatsPerBar: 4,\n      beatOffsetSeconds: 0.011,\n      source: \"measured\",\n    },\n  },\n  \"night-drive\": {\n    title: \"Night Drive\",\n    mood: \"Sleek, nocturnal, driving\",\n    path: \"promovideo/audio/music/night-drive.m4a\",\n    beatGrid: {\n      bpm: 120,\n      beatsPerBar: 4,\n      beatOffsetSeconds: 0,\n      source: \"authored\",\n      liftSeconds: 8,\n      finalHitSeconds: 26,\n    },\n  },\n  \"lofi-daydream\": {\n    title: \"Lo-fi Daydream\",\n    mood: \"Relaxed, cozy, mellow\",\n    path: \"promovideo/audio/music/lofi-daydream.m4a\",\n    beatGrid: {\n      bpm: 90,\n      beatsPerBar: 4,\n      beatOffsetSeconds: 0,\n      source: \"authored\",\n      liftSeconds: 16 / 3,\n      finalHitSeconds: 80 / 3,\n    },\n  },\n  \"sunrise-organic\": {\n    title: \"Sunrise Organic\",\n    mood: \"Bright, organic, hopeful\",\n    path: \"promovideo/audio/music/sunrise-organic.m4a\",\n    beatGrid: {\n      bpm: 100,\n      beatsPerBar: 4,\n      beatOffsetSeconds: 0,\n      source: \"authored\",\n      liftSeconds: 9.6,\n      finalHitSeconds: 26.4,\n    },\n  },\n  \"bold-launch\": {\n    title: \"Bold Launch\",\n    mood: \"Epic, percussive, triumphant\",\n    path: \"promovideo/audio/music/bold-launch.m4a\",\n    beatGrid: {\n      bpm: 100,\n      beatsPerBar: 4,\n      beatOffsetSeconds: 0,\n      source: \"authored\",\n      liftSeconds: 7.2,\n      finalHitSeconds: 26.4,\n    },\n  },\n};",
                "getBackgroundMusicUrl": "export function getBackgroundMusicUrl(id: BackgroundMusicId): string {\n  return `/${BACKGROUND_MUSIC_TRACKS[id].path}`;\n}"
            }
        },
        {
            "group": "shared",
            "path": "packages/mcp-contracts/src/index.ts",
            "sha256": "23f5e24d09a35167cbfd37ba73b530e20a9ac98808a04611d38fe5365007816d",
            "declarations": {
                "SOCIAL_FORMATS": "export const SOCIAL_FORMATS = [\n  \"og\",\n  \"x_post\",\n  \"instagram_story\",\n  \"instagram_post\",\n  \"x_header\",\n  \"linkedin_banner\",\n  \"play_store_feature\",\n  \"app_store_event_card\",\n  \"app_store_event_details\",\n  \"app_store_header\",\n  \"app_store_search\",\n  \"ad_banner\",\n  \"ad_mobile_banner\",\n  \"ad_tablet_banner_720\",\n  \"ad_tablet_banner_728\",\n  \"ad_mrec\",\n  \"ad_phone_portrait\",\n  \"ad_phone_landscape\",\n  \"ad_fullscreen_landscape\",\n  \"ad_tablet_portrait\",\n  \"ad_tablet_landscape\",\n] as const;",
                "SocialFormat": "export type SocialFormat = (typeof SOCIAL_FORMATS)[number];",
                "JsonObject": "export type JsonObject = Record<string, unknown>;",
                "displayUrl": "// Presentation-only data travels in tool _meta, never in the model summary.\nconst displayUrl = z\n  .string()\n  .url()\n  .refine((value) => {\n    const url = new URL(value);\n    return (\n      [\"https:\", \"http:\"].includes(url.protocol) &&\n      !url.username &&\n      !url.password\n    );\n  });",
                "projectListItemSchema": "export const projectListItemSchema = z.object({\n  id: z.string().uuid(),\n  name: z.string(),\n  platform: z.string().optional(),\n  category: z.string().optional(),\n  updatedAt: z.string().optional(),\n  iconUrl: displayUrl.optional(),\n  projectUrl: displayUrl,\n});",
                "projectListSchema": "export const projectListSchema = z.object({\n  projects: z.array(projectListItemSchema),\n});",
                "ProjectListItem": "export type ProjectListItem = z.infer<typeof projectListItemSchema>;",
                "assetKindSchema": "export const assetKindSchema = z.enum([\n  \"screenshots\",\n  \"appIcon\",\n  \"recordings\",\n  \"illustrations\",\n  \"backgrounds\",\n  \"panoramas\",\n  \"fonts\",\n  \"promoMedia\",\n]);",
                "assetListItemSchema": "export const assetListItemSchema = z.object({\n  path: z\n    .string()\n    .min(1)\n    .refine(\n      (path) =>\n        !/[\\\\\\\\%?#:]/.test(path) &&\n        path.split(\"/\").every((part) => part && part !== \".\" && part !== \"..\"),\n    ),\n  name: z.string(),\n  kind: assetKindSchema,\n  mediaType: z.enum([\"image\", \"video\", \"audio\", \"font\"]),\n  deviceType: z.enum([\"mobile\", \"tablet\", \"desktop\", \"watch\"]).optional(),\n  platform: z.enum([\"ios\", \"android\"]).optional(),\n  previewUrl: displayUrl.optional(),\n});",
                "assetListSchema": "export const assetListSchema = z.object({\n  projectId: z.string().uuid(),\n  assets: z.array(assetListItemSchema),\n  truncated: z.boolean(),\n});",
                "AssetListItem": "export type AssetListItem = z.infer<typeof assetListItemSchema>;",
                "AssetList": "export type AssetList = z.infer<typeof assetListSchema>;",
                "AssetKind": "export type AssetKind = z.infer<typeof assetKindSchema>;",
                "projectIdField": "export const projectIdField = z\n  .string()\n  .uuid()\n  .describe(\"AppLaunchFlow project UUID.\");",
                "operationIdField": "export const operationIdField = z\n  .string()\n  .uuid()\n  .describe(\n    \"Use the operationId returned by the picker. Retry the exact same ID and selection after an uncertain response; only an independent new user action gets a new ID.\",\n  );",
                "editTargetFields": "export const editTargetFields = {\n  variantId: z\n    .string()\n    .uuid()\n    .describe(\n      \"Concrete variant returned by the preceding read, never an active/default alias.\",\n    ),\n  expectedRevision: z\n    .string()\n    .uuid()\n    .describe(\n      \"Revision of this exact target from the preceding read. Stale/replayed writes fail with EDIT_CONFLICT; re-read instead of retrying.\",\n    ),\n};",
                "editLanguageField": "export const editLanguageField = z\n  .string()\n  .trim()\n  .min(1)\n  .max(32)\n  .describe(\"Exact language returned by the preceding read.\");",
                "selectionFields": "export const selectionFields = {\n  operationId: operationIdField,\n  catalogKey: z.string().min(1).max(128),\n  templateId: z.string().min(1),\n  paletteMode: z.enum([\"v1\", \"v2\"]).default(\"v1\"),\n};",
                "screenshotSelectionApiSchema": "export const screenshotSelectionApiSchema = z\n  .object({\n    ...selectionFields,\n    generationId: projectIdField,\n    replaceVariantId: z.string().uuid().optional(),\n  })\n  .strict();",
                "socialSelectionApiSchema": "export const socialSelectionApiSchema = screenshotSelectionApiSchema.extend({\n  primaryFormat: z.enum(SOCIAL_FORMATS),\n});",
                "screenshotSelectionSchema": "// Tool-facing names are consistent; existing dashboard routes keep their wire\n// names behind the client adapter, without alias ambiguity in model schemas.\nexport const screenshotSelectionSchema = screenshotSelectionApiSchema\n  .omit({ generationId: true, replaceVariantId: true })\n  .extend({ projectId: projectIdField });",
                "socialSelectionSchema": "export const socialSelectionSchema = socialSelectionApiSchema\n  .omit({ generationId: true, replaceVariantId: true })\n  .extend({\n    projectId: projectIdField,\n    primaryFormat: z.enum(SOCIAL_FORMATS).default(\"og\"),\n  });",
                "promoCandidateSelectionSchema": "export const promoCandidateSelectionSchema = z\n  .object({\n    projectId: projectIdField,\n    operationId: operationIdField,\n    candidateKey: z.string().regex(/^[a-f0-9]{64}$/i),\n    candidateId: z.string().min(1).max(120),\n    replaceVariantId: z.string().uuid().optional(),\n  })\n  .strict();",
                "promoSelectionApiSchema": "export const promoSelectionApiSchema = promoCandidateSelectionSchema\n  .extend({\n    candidateKey: promoCandidateSelectionSchema.shape.candidateKey.optional(),\n    candidateId: promoCandidateSelectionSchema.shape.candidateId.optional(),\n    config: z.record(z.string(), z.unknown()).optional(),\n    label: z.string().trim().min(1).max(120).optional(),\n  })\n  .refine(\n    (input) =>\n      input.config\n        ? !input.candidateKey && !input.candidateId\n        : !!input.candidateKey && !!input.candidateId,\n    \"Provide either a stored candidate key/id or a config, not both\",\n  );",
                "ScreenshotSelectionRequest": "export type ScreenshotSelectionRequest = z.input<\n  typeof screenshotSelectionApiSchema\n>;",
                "SocialSelectionRequest": "export type SocialSelectionRequest = z.input<typeof socialSelectionApiSchema>;",
                "ScreenshotToolSelectionRequest": "export type ScreenshotToolSelectionRequest = z.input<\n  typeof screenshotSelectionSchema\n>;",
                "SocialToolSelectionRequest": "export type SocialToolSelectionRequest = z.input<typeof socialSelectionSchema>;",
                "PromoCandidateSelectionRequest": "export type PromoCandidateSelectionRequest = z.input<\n  typeof promoCandidateSelectionSchema\n>;",
                "SelectionResponse": "export interface SelectionResponse {\n  variantId: string;\n}",
                "ScreenshotSelectionResponse": "export interface ScreenshotSelectionResponse extends SelectionResponse {\n  detectedLanguage: string;\n  mobileLayout: unknown;\n  tabletLayout: unknown;\n  desktopLayout: unknown;\n}",
                "SocialSelectionResponse": "export interface SocialSelectionResponse extends SelectionResponse {\n  sourceLanguage: string;\n  language: string;\n  availableLanguages: string[];\n  socialTemplateId: string;\n  socialPrimaryFormat: SocialFormat;\n  graphics: Array<{ format: SocialFormat; layout: unknown }>;\n}",
                "PreparedCatalog": "export interface PreparedCatalog {\n  catalogKey: string;\n  cacheHit?: boolean;\n  templatePayloads: Record<string, unknown>;\n}",
                "PreparedPromoCandidates": "export interface PreparedPromoCandidates {\n  candidateKey: string;\n  candidates: Array<{\n    id: string;\n    title: string;\n    explanation: string;\n    durationInFrames: number;\n    config: JsonObject;\n  }>;\n}",
                "TemplateSummary": "export interface TemplateSummary {\n  id: string;\n  name: string;\n  description?: string;\n  categories?: string[];\n  screenCount?: number;\n}",
                "MAX_ASSET_UPLOAD_BYTES": "export const MAX_ASSET_UPLOAD_BYTES = 25 * 1024 * 1024;",
                "imageTypes": "const imageTypes = {\n  png: \"image/png\",\n  jpg: \"image/jpeg\",\n  jpeg: \"image/jpeg\",\n  webp: \"image/webp\",\n  gif: \"image/gif\",\n  svg: \"image/svg+xml\",\n  avif: \"image/avif\",\n  heic: \"image/heic\",\n  heif: \"image/heif\",\n};",
                "videoTypes": "const videoTypes = {\n  mp4: \"video/mp4\",\n  mov: \"video/quicktime\",\n  webm: \"video/webm\",\n  m4v: \"video/x-m4v\",\n};",
                "audioTypes": "const audioTypes = {\n  mp3: \"audio/mpeg\",\n  wav: \"audio/wav\",\n  m4a: \"audio/mp4\",\n  aac: \"audio/aac\",\n  ogg: \"audio/ogg\",\n  flac: \"audio/flac\",\n};",
                "fontTypes": "const fontTypes = {\n  ttf: \"font/ttf\",\n  otf: \"font/otf\",\n  woff: \"font/woff\",\n  woff2: \"font/woff2\",\n};",
                "assetUploadTypes": "export function assetUploadTypes(kind: AssetKind): Record<string, string> {\n  if (kind === \"fonts\") return fontTypes;\n  if (kind === \"recordings\") return videoTypes;\n  if (kind === \"promoMedia\")\n    return { ...imageTypes, ...videoTypes, ...audioTypes };\n  return imageTypes;\n}",
                "assetUploadAccept": "export function assetUploadAccept(kind: AssetKind): string {\n  return Object.keys(assetUploadTypes(kind))\n    .map((ext) => `.${ext}`)\n    .join(\",\");\n}",
                "assetUploadRequestSchema": "export const assetUploadRequestSchema = z.object({\n  projectId: z.string().uuid(),\n  kind: assetKindSchema,\n  filename: z\n    .string()\n    .min(1)\n    .max(180)\n    .refine(\n      (name) =>\n        !/[\\\\/\\u0000-\\u001f%?#:]/.test(name) && name !== \".\" && name !== \"..\",\n    ),\n  sizeBytes: z.number().int().positive().max(MAX_ASSET_UPLOAD_BYTES),\n  contentType: z.string().min(1),\n  deviceType: z.enum([\"mobile\", \"tablet\", \"desktop\", \"watch\"]).optional(),\n  platform: z.enum([\"ios\", \"android\"]).optional(),\n});",
                "AssetUploadRequest": "export type AssetUploadRequest = z.infer<typeof assetUploadRequestSchema>;",
                "portableParseOptions": "// Zod 4's no-eval parsing option; Zod 3 accepts the empty path and ignores jitless.\nconst portableParseOptions = { jitless: true, path: [] };",
                "AssetUploadMetadata": "type AssetUploadMetadata = {\n  projectId: string;\n  filename: string;\n  contentType: string;\n  deviceType?: AssetUploadRequest[\"deviceType\"];\n  platform?: AssetUploadRequest[\"platform\"];\n  fileType?:\n    | \"logo\"\n    | \"mockup-media\"\n    | \"illustration\"\n    | \"background\"\n    | \"panorama\"\n    | \"font\"\n    | \"promo-media\";\n};",
                "assetUploadMetadata": "/** The same category/extension validation runs before the chooser upload and on the MCP server. */\nexport function assetUploadMetadata(\n  value: AssetUploadRequest,\n): AssetUploadMetadata {\n  const input = assetUploadRequestSchema.parse(value, portableParseOptions);\n  const ext = input.filename.split(\".\").pop()?.toLowerCase() || \"\";\n  const mime = assetUploadTypes(input.kind)[ext];\n  if (!mime || input.contentType !== mime)\n    throw new Error(\"Unsupported file type for this asset category\");\n  const common = {\n    projectId: input.projectId,\n    filename: input.filename,\n    contentType: mime,\n  };\n  if (input.kind === \"screenshots\") {\n    if (!input.deviceType || !input.platform)\n      throw new Error(\"Choose a screenshot device and platform\");\n    return {\n      ...common,\n      deviceType: input.deviceType,\n      platform: input.platform,\n    };\n  }\n  const fileType = {\n    appIcon: \"logo\",\n    recordings: \"mockup-media\",\n    illustrations: \"illustration\",\n    backgrounds: \"background\",\n    panoramas: \"panorama\",\n    fonts: \"font\",\n    promoMedia: \"promo-media\",\n  } as const;\n  return { ...common, fileType: fileType[input.kind] };\n}",
                "assetUploadGrantSchema": "export const assetUploadGrantSchema = z.object({\n  projectId: z.string().uuid(),\n  path: assetListItemSchema.shape.path,\n  uploadUrl: displayUrl,\n});",
                "AssetUploadGrant": "export type AssetUploadGrant = z.infer<typeof assetUploadGrantSchema>;",
                "validateAssetUploadGrant": "/** No arbitrary destinations, credentials or another project's storage objects. */\nexport function validateAssetUploadGrant(\n  value: unknown,\n  request: AssetUploadRequest,\n  localOrigin?: string,\n): AssetUploadGrant {\n  const grant = assetUploadGrantSchema.parse(value, portableParseOptions);\n  const metadata = assetUploadMetadata(request);\n  const folders = {\n    logo: \"logo\",\n    \"mockup-media\": \"mockups\",\n    illustration: \"illustrations\",\n    background: \"backgrounds\",\n    panorama: \"panorama\",\n    font: \"fonts\",\n    \"promo-media\": \"promo-media\",\n  };\n  const folder = metadata.fileType\n    ? folders[metadata.fileType]\n    : `${metadata.deviceType}/${metadata.platform}`;\n  const url = new URL(grant.uploadUrl);\n  const local = localOrigin ? new URL(localOrigin) : undefined;\n  const allowedLocal =\n    local &&\n    [\"127.0.0.1\", \"localhost\"].includes(local.hostname) &&\n    (url.origin === local.origin ||\n      (url.protocol === \"http:\" &&\n        [\"127.0.0.1\", \"localhost\"].includes(url.hostname) &&\n        url.port === \"54321\"));\n  if (\n    grant.projectId !== request.projectId ||\n    !grant.path.startsWith(`${folder}/`) ||\n    grant.path.slice(folder.length + 1).includes(\"/\") ||\n    (!allowedLocal &&\n      url.origin !== \"https://ubvbpgodmmitzutgshzu.supabase.co\") ||\n    url.hash ||\n    url.username ||\n    url.password ||\n    decodeURIComponent(url.pathname) !==\n      `/storage/v1/object/upload/sign/screenshots/${request.projectId}/${grant.path}` ||\n    !url.searchParams.get(\"token\")\n  )\n    throw new Error(\"Invalid upload destination\");\n  return grant;\n}"
            }
        },
        {
            "group": "shared",
            "path": "src/shared/validation/schemas.ts",
            "sha256": "ce45a90c2b15b721e014a17218a880b8c7204853a340eb9d5df5bb2d1551e254",
            "declarations": {
                "appPlatformSchema": "export const appPlatformSchema = z.enum([\"ios\", \"android\", \"both\"]);",
                "languageCodeSchema": "// Language code schema (ISO 639-1)\n// Must stay in sync with SUPPORTED_LANGUAGES in\n// src/shared/config/languages.ts\nexport const languageCodeSchema = z.enum([\n  \"en\",\n  \"en-GB\",\n  \"en-AU\",\n  \"en-CA\",\n  \"es\",\n  \"es-MX\",\n  \"fr\",\n  \"fr-CA\",\n  \"de\",\n  \"it\",\n  \"pt\",\n  \"pt-BR\",\n  \"ja\",\n  \"ko\",\n  \"zh-CN\",\n  \"zh-TW\",\n  \"nl\",\n  \"ru\",\n  \"ar\",\n  \"tr\",\n  \"pl\",\n  \"sv\",\n  \"no\",\n  \"da\",\n  \"fi\",\n  \"cs\",\n  \"hi\",\n  \"hu\",\n  \"ro\",\n  \"uk\",\n  \"el\",\n  \"he\",\n  \"id\",\n  \"ms\",\n  \"th\",\n  \"vi\",\n  \"fil\",\n  \"hr\",\n  \"sk\",\n  \"ca\",\n  \"bn\",\n  \"gu\",\n  \"kn\",\n  \"ml\",\n  \"mr\",\n  \"or\",\n  \"pa\",\n  \"sl\",\n  \"ta\",\n  \"te\",\n  \"ur\",\n]);"
            }
        },
        {
            "group": "shared",
            "path": "src/features/templates-core/utils/frame3DGeometry.ts",
            "sha256": "79d2ebbc276aa5fa22ceddd3abced687181fb24156176672b025ffc8be832cf6",
            "declarations": {
                "ScreenCorners3D": "export interface ScreenCorners3D {\n  topLeft: { x: number; y: number };\n  topRight: { x: number; y: number };\n  bottomLeft: { x: number; y: number };\n  bottomRight: { x: number; y: number };\n}",
                "resolveFrameScreenCorners": "export function resolveFrameScreenCorners(\n  corners: ScreenCorners3D | undefined,\n  bezelSize: number,\n  frameWidth: number,\n  frameHeight: number,\n): ScreenCorners3D {\n  if (corners) {\n    return {\n      topLeft: { ...corners.topLeft },\n      topRight: { ...corners.topRight },\n      bottomLeft: { ...corners.bottomLeft },\n      bottomRight: { ...corners.bottomRight },\n    };\n  }\n\n  return {\n    topLeft: { x: bezelSize, y: bezelSize },\n    topRight: { x: frameWidth - bezelSize, y: bezelSize },\n    bottomLeft: { x: bezelSize, y: frameHeight - bezelSize },\n    bottomRight: {\n      x: frameWidth - bezelSize,\n      y: frameHeight - bezelSize,\n    },\n  };\n}",
                "createFrameMeshGeometry": "export function createFrameMeshGeometry(\n  { topLeft, topRight, bottomLeft, bottomRight }: ScreenCorners3D,\n  isFlat: boolean,\n): PIXI.MeshGeometry {\n  if (isFlat) {\n    return new PIXI.MeshGeometry({\n      positions: new Float32Array([\n        topLeft.x,\n        topLeft.y,\n        topRight.x,\n        topRight.y,\n        bottomLeft.x,\n        bottomLeft.y,\n        bottomRight.x,\n        bottomRight.y,\n      ]),\n      uvs: new Float32Array([0, 0, 1, 0, 0, 1, 1, 1]),\n      indices: new Uint32Array([0, 1, 2, 1, 3, 2]),\n    });\n  }\n\n  const gridColumns = 9;\n  const gridRows = 9;\n  const positions: number[] = [];\n  const uvs: number[] = [];\n  const indices: number[] = [];\n\n  for (let row = 0; row < gridRows; row++) {\n    for (let column = 0; column < gridColumns; column++) {\n      const u = column / (gridColumns - 1);\n      const v = row / (gridRows - 1);\n      const topX = topLeft.x + (topRight.x - topLeft.x) * u;\n      const topY = topLeft.y + (topRight.y - topLeft.y) * u;\n      const bottomX = bottomLeft.x + (bottomRight.x - bottomLeft.x) * u;\n      const bottomY = bottomLeft.y + (bottomRight.y - bottomLeft.y) * u;\n\n      positions.push(topX + (bottomX - topX) * v, topY + (bottomY - topY) * v);\n      uvs.push(u, v);\n    }\n  }\n\n  for (let row = 0; row < gridRows - 1; row++) {\n    for (let column = 0; column < gridColumns - 1; column++) {\n      const topLeftIndex = row * gridColumns + column;\n      const topRightIndex = topLeftIndex + 1;\n      const bottomLeftIndex = (row + 1) * gridColumns + column;\n      const bottomRightIndex = bottomLeftIndex + 1;\n      indices.push(\n        topLeftIndex,\n        topRightIndex,\n        bottomLeftIndex,\n        topRightIndex,\n        bottomRightIndex,\n        bottomLeftIndex,\n      );\n    }\n  }\n\n  return new PIXI.MeshGeometry({\n    positions: new Float32Array(positions),\n    uvs: new Float32Array(uvs),\n    indices: new Uint32Array(indices),\n  });\n}",
                "getFrameScreenSize": "export function getFrameScreenSize({\n  topLeft,\n  topRight,\n  bottomLeft,\n  bottomRight,\n}: ScreenCorners3D): { width: number; height: number } {\n  return {\n    width: Math.max(\n      Math.abs(topRight.x - topLeft.x),\n      Math.abs(bottomRight.x - bottomLeft.x),\n    ),\n    height: Math.max(\n      Math.abs(bottomLeft.y - topLeft.y),\n      Math.abs(bottomRight.y - topRight.y),\n    ),\n  };\n}",
                "getFrameScreenBounds": "export function getFrameScreenBounds({\n  topLeft,\n  topRight,\n  bottomLeft,\n  bottomRight,\n}: ScreenCorners3D) {\n  const xValues = [topLeft.x, topRight.x, bottomLeft.x, bottomRight.x];\n  const yValues = [topLeft.y, topRight.y, bottomLeft.y, bottomRight.y];\n  const x = Math.min(...xValues);\n  const y = Math.min(...yValues);\n  return {\n    x,\n    y,\n    width: Math.max(...xValues) - x,\n    height: Math.max(...yValues) - y,\n  };\n}",
                "CreateFrameScreenMeshOptions": "type CreateFrameScreenMeshOptions = {\n  screenshot: PIXI.Texture;\n  geometry: PIXI.MeshGeometry;\n  cornerRadius: number;\n  screenWidth: number;\n  screenHeight: number;\n  fitMode: ScreenshotFitMode;\n  textureOwner?: TextureOwner;\n  contentRotation: number;\n  contentTransform?: ScreenshotContentTransform;\n  cleanStatusBar?: boolean;\n  fillPadding?: boolean;\n};",
                "ScreenshotContentMesh": "/** A frame screen mesh that can live-update its content pan/zoom without a full re-render. */\nexport type ScreenshotContentMesh = PIXI.Mesh & {\n  __updateContentTransform?: (\n    transform: ScreenshotContentTransform | undefined,\n  ) => void;\n};",
                "createFrameScreenMesh": "export function createFrameScreenMesh({\n  screenshot,\n  geometry,\n  cornerRadius,\n  screenWidth,\n  screenHeight,\n  fitMode,\n  textureOwner,\n  contentRotation,\n  contentTransform,\n  cleanStatusBar = false,\n  fillPadding = true,\n}: CreateFrameScreenMeshOptions): PIXI.Mesh {\n  // The screen is rasterized onto a canvas before it is mapped into the frame.\n  // At the frame image's own screen size that canvas is ~1050px wide for the\n  // 1124px flat iPad frame, so an iPad export (device drawn ~1450-1750px wide)\n  // squeezed its 2048px screenshot to 1050px and stretched it back up. Raster\n  // at the screenshot's own resolution instead, up to 4x the frame's. Editor\n  // previews load downscaled screenshots, so they rarely pay for this.\n  const rasterScale = Math.min(\n    4,\n    Math.max(\n      1,\n      screenshot.width / Math.max(1, screenWidth),\n      screenshot.height / Math.max(1, screenHeight),\n    ),\n  );\n  const rasterWidth = screenWidth * rasterScale;\n  const rasterHeight = screenHeight * rasterScale;\n  const rasterCornerRadius = cornerRadius * rasterScale;\n  const texture = applyRoundedCornersToTexture(\n    screenshot,\n    rasterCornerRadius,\n    rasterWidth,\n    rasterHeight,\n    fitMode,\n    textureOwner,\n    contentRotation,\n    contentTransform,\n    cleanStatusBar,\n    fillPadding,\n  );\n  const mesh: ScreenshotContentMesh = new PIXI.Mesh({ geometry, texture });\n\n  // Live preview hook for the on-canvas reposition mode. One canvas and one\n  // GPU texture are allocated lazily on the first draft and then redrawn +\n  // re-uploaded in place on every subsequent tick, so a drag never allocates\n  // per-frame. The texture is registered with the render pass's textureOwner\n  // and released with the pass like any other dynamic texture.\n  let liveCanvas: HTMLCanvasElement | null = null;\n  let liveTexture: PIXI.Texture | null = null;\n  mesh.__updateContentTransform = (transform) => {\n    if (\n      mesh.destroyed ||\n      screenshot.destroyed ||\n      typeof document === \"undefined\"\n    ) {\n      return;\n    }\n    if (!liveCanvas) {\n      liveCanvas = document.createElement(\"canvas\");\n      liveCanvas.width = Math.round(rasterWidth);\n      liveCanvas.height = Math.round(rasterHeight);\n    }\n    if (\n      !drawRoundedScreenshotContent(\n        liveCanvas,\n        screenshot,\n        rasterCornerRadius,\n        fitMode,\n        contentRotation,\n        transform,\n        cleanStatusBar,\n        fillPadding,\n      )\n    ) {\n      return;\n    }\n    if (!liveTexture || liveTexture.destroyed) {\n      liveTexture = createDynamicTextureFromSource(liveCanvas, textureOwner, {\n        mipmaps: false,\n      });\n    } else {\n      liveTexture.source.update();\n    }\n    if (mesh.texture !== liveTexture) mesh.texture = liveTexture;\n  };\n  return mesh;\n}",
                "addFrameSprite": "export function addFrameSprite(\n  container: PIXI.Container,\n  frameTexture: PIXI.Texture,\n  frameWidth: number,\n  frameHeight: number,\n  isNoFrame: boolean,\n): PIXI.Sprite | null {\n  if (isNoFrame) return null;\n\n  const frameSprite = new PIXI.Sprite(frameTexture);\n  frameSprite.width = frameWidth;\n  frameSprite.height = frameHeight;\n  container.addChild(frameSprite);\n  return frameSprite;\n}",
                "applyFrameTransform": "export function applyFrameTransform(\n  container: PIXI.Container,\n  frameWidth: number,\n  frameHeight: number,\n  tiltAngle: number,\n): void {\n  container.pivot.set(frameWidth / 2, frameHeight / 2);\n  if (tiltAngle !== 0) {\n    container.rotation = (tiltAngle * Math.PI) / 180;\n  }\n}"
            }
        },
        {
            "group": "shared",
            "path": "src/features/device-3d/config.ts",
            "sha256": "baa450b47f79b5963c62815b8315cab85110b946213fe29bf7167f5862a3a09c",
            "declarations": {
                "DeviceModelKind": "export type DeviceModelKind = \"phone\" | \"tablet\" | \"laptop\";",
                "DeviceModelHinge": "/** A rigid part (the laptop lid) rotating about a hinge axis. */\ninterface DeviceModelHinge {\n  kind: \"hinge\";\n  nodeName: string;\n  pivot: [number, number, number];\n  closedRotation: [number, number, number];\n}",
                "DeviceModelFold": "/**\n * A book-style foldable driven through its skeleton. The GLB is authored flat\n * open; one half (`movingBone`) swings about the hinge axis onto the other,\n * which stays put, so the closed device shows the cover display on the moving\n * half's back the right way round. The spine follows at half the angle so it\n * covers the fold edge throughout. All coordinates are in the rig's space,\n * which is the skinned meshes' bind space.\n */\nexport interface DeviceModelFold {\n  kind: \"fold\";\n  /** Authored root node, re-oriented to face the camera (Euler XYZ). */\n  rootNodeName: string;\n  rootRotation: [number, number, number];\n  movingBone: string;\n  spineBone: string;\n  /** Rig-space hinge axis point; the axis itself is rig +Z. */\n  pivot: [number, number, number];\n  /** Rotation of the moving half at lidOpen 0, about rig +Z. */\n  closedAngle: number;\n  /**\n   * How far the spine slides toward the hinge axis by the time the device is\n   * shut. Flat, it sits in a channel under the halves; shut, it has to close\n   * up against their edges or the folded display shows through the gap.\n   */\n  spineLift: number;\n  /** Rig-space X extent of one half, for keeping the device centred. */\n  halfWidth: number;\n  /** Width of the spine when closed (its outermost rig-space |X|). */\n  spineDepth: number;\n}",
                "DeviceModelArticulation": "type DeviceModelArticulation = DeviceModelHinge | DeviceModelFold;",
                "DeviceSecondaryScreen": "/**\n * A second display on the same model (the iPhone Duo's cover screen). It shows\n * the same media as the main display, fitted to its own shape.\n */\ninterface DeviceSecondaryScreen {\n  materialNames: readonly string[];\n  aspect: number;\n}",
                "DeviceStillFraming": "/**\n * Offscreen still framing for editor composites, per model.\n *\n * `widthFactor` is the still canvas width as a fraction of the layout canvas\n * width; `aspect` is height/width — above 1 for the portrait handhelds, below 1\n * for the landscape laptop. Transparent margin is trimmed after rendering\n * (`trimTransparentCanvasWithMeta`), so an over-generous box only costs\n * effective resolution, while an under-sized one clips the device.\n */\ninterface DeviceStillFraming {\n  widthFactor: number;\n  aspect: number;\n}",
                "DeviceScreenInsets": "/**\n * The display area inside the trimmed still, as fractions of the sprite. Drives\n * the drag/hit bounds and the hover overlay, so it wants to track the glass, not\n * the chassis — a laptop's screen sits in the upper half above its base.\n */\ninterface DeviceScreenInsets {\n  x: number;\n  y: number;\n  width: number;\n  height: number;\n  radius: number;\n}",
                "DeviceModelSpec": "interface DeviceModelSpec {\n  path: string;\n  baseScale: number;\n  modelOffset: [number, number, number];\n  label: string;\n  kind: DeviceModelKind;\n  screenMaterialNames: readonly string[];\n  /**\n   * Display aspect (width/height) of the model's screen surface, used to shape\n   * the composited screen texture for non-stretch fit modes. The screen mesh's\n   * UVs span 0..1 across the whole display, so a texture of any other shape is\n   * distorted onto it - which is exactly what `stretch` is, and what `cover` /\n   * `contain` have to counteract.\n   *\n   * The iPhone and Pixel derive theirs from the geometry constants above, so\n   * they cannot drift from the quads actually drawn. The other two are measured\n   * from their GLB's screen-mesh POSITION accessor bounds - the meshes come\n   * from the asset, so there is nothing in the repo to derive them from:\n   *   iPad Pro       0.5522  / 0.73035 (\"Screen\")\n   *   MacBook Pro 14 30.07597 / hypot(6.69185, 18.37419) - the lid is authored\n   *                  at 69.99 degrees, so the panel's height is the tilted\n   *                  diagonal, not the raw Z extent (1.538 = the real 3024x1964\n   *                  panel, which confirms the correction).\n   */\n  screenAspect: number;\n  /**\n   * True when the GLB's screen UVs run right-to-left, which renders the\n   * screenshot mirrored. Decoded from each model's TEXCOORD_0: U correlates\n   * with the mesh's local x at +1.000 on the Pixel and the MacBook, but at\n   * -1.000 on the iPad. (The iPhone's screen is our own quad, whose -x UVs are\n   * cancelled by the mesh's 180-degree Z rotation.)\n   */\n  screenMirrorX?: boolean;\n  /**\n   * Keep the media upright in view when the device is rolled a quarter turn\n   * or more (the nearest quarter of its pose's Z rotation), the way a phone's\n   * display follows how it is held. Only the Duo needs it: unfolded, its\n   * inner display is landscape, and App Store inner shots pose it portrait.\n   */\n  uprightScreenContent?: boolean;\n  secondaryScreen?: DeviceSecondaryScreen;\n  supportsCameraHole: boolean;\n  still: DeviceStillFraming;\n  screenInsets: DeviceScreenInsets;\n  hiddenNodeNamePatterns?: readonly RegExp[];\n  articulation?: DeviceModelArticulation;\n}",
                "PHONE_STILL": "// Shared by both phones: same chassis proportions, same bezel fractions.\nconst PHONE_STILL: DeviceStillFraming = { widthFactor: 0.9, aspect: 1.45 };",
                "PHONE_SCREEN_INSETS": "const PHONE_SCREEN_INSETS: DeviceScreenInsets = {\n  x: 0.08,\n  y: 0.055,\n  width: 0.84,\n  height: 0.89,\n  radius: 0.11,\n};",
                "IPHONE_SCREEN_QUAD": "/**\n * The iPhone's screen is our own quad rather than a mesh from the GLB, and the\n * Pixel's camera-hole cover spans its display's local extents. Both live here\n * so `screenAspect` is derived from the same numbers the geometry is built\n * from — stated separately, retuning either silently mis-fit `cover`/`contain`\n * with nothing to catch it.\n */\nexport const IPHONE_SCREEN_QUAD = {\n  width: 0.06754,\n  height: 0.14566,\n  radius: 0.011,\n} as const;",
                "PIXEL_SCREEN_BOUNDS": "export const PIXEL_SCREEN_BOUNDS = {\n  minX: -3.3585,\n  maxX: 3.2945,\n  minY: 0.2762,\n  maxY: 15.0042,\n  radius: 1.5,\n} as const;",
                "resolveDeviceModelPath": "export function resolveDeviceModelPath(\n  path: string,\n  baseUrl = getMcpAssetOrigin()\n    ? `${getMcpAssetOrigin()}/`\n    : typeof document === \"undefined\"\n      ? undefined\n      : document.baseURI,\n): string {\n  if (!baseUrl) return path;\n  try {\n    return new URL(path, baseUrl).href;\n  } catch {\n    return path;\n  }\n}",
                "DEVICE_MODEL_PATH": "const DEVICE_MODEL_PATH = resolveDeviceModelPath(\"/models/iphone-17-pro.glb\");",
                "DEVICE_MODEL_SPECS": "export const DEVICE_MODEL_SPECS: Record<DeviceModelId, DeviceModelSpec> = {\n  \"iphone-17-pro\": {\n    path: DEVICE_MODEL_PATH,\n    baseScale: 7.2,\n    modelOffset: [0, 0, 0],\n    // The id and GLB keep their iPhone 17 Pro names: both are persisted in\n    // saved layouts and the public API. The 18 Pro shares the chassis.\n    label: \"iPhone 18 Pro\",\n    screenAspect: IPHONE_SCREEN_QUAD.width / IPHONE_SCREEN_QUAD.height,\n    kind: \"phone\",\n    screenMaterialNames: [],\n    supportsCameraHole: true,\n    still: PHONE_STILL,\n    screenInsets: PHONE_SCREEN_INSETS,\n  },\n  \"google-pixel-10-pro\": {\n    path: resolveDeviceModelPath(\n      \"/models/google-pixel-10-pro-jade.glb?v=single-real-back\",\n    ),\n    baseScale: 0.069,\n    modelOffset: [0, -7.64, 0],\n    label: \"Pixel 10 Pro\",\n    screenAspect:\n      (PIXEL_SCREEN_BOUNDS.maxX - PIXEL_SCREEN_BOUNDS.minX) /\n      (PIXEL_SCREEN_BOUNDS.maxY - PIXEL_SCREEN_BOUNDS.minY),\n    kind: \"phone\",\n    screenMaterialNames: [\"GP10p_display\"],\n    supportsCameraHole: true,\n    still: PHONE_STILL,\n    screenInsets: PHONE_SCREEN_INSETS,\n  },\n  \"ipad-pro\": {\n    // The repaired asset maps the complete screenshot onto the display. Keep\n    // old HTTP/Three loader entries from retaining the inset, cropping UVs.\n    path: resolveDeviceModelPath(\"/models/ipad-pro.glb?v=screen-uv-2\"),\n    baseScale: 0.014,\n    modelOffset: [-0.127, -38.769, 1.06],\n    label: \"iPad Pro\",\n    screenAspect: 0.75608,\n    screenMirrorX: true,\n    kind: \"tablet\",\n    screenMaterialNames: [\"Screen\"],\n    supportsCameraHole: false,\n    // Squarer than a phone, and uniform thin bezels all round.\n    still: { widthFactor: 0.9, aspect: 1.3 },\n    screenInsets: {\n      x: 0.05,\n      y: 0.045,\n      width: 0.9,\n      height: 0.91,\n      radius: 0.03,\n    },\n    // The current asset is already device-only. These guards also keep future\n    // replacements from silently bringing bundled accessories into mockups.\n    hiddenNodeNamePatterns: [\n      /apple[\\s._-]*pencil/i,\n      /^pencil(?:[\\s._-]|$)/i,\n      /magic[\\s._-]*keyboard/i,\n      /smart[\\s._-]*(?:keyboard|folio)/i,\n      /^folio(?:[\\s._-]|$)/i,\n    ],\n  },\n  \"macbook-pro-14\": {\n    path: resolveDeviceModelPath(\"/models/macbook-pro-14.glb\"),\n    baseScale: 3.5,\n    modelOffset: [0, -0.096, 0.04],\n    label: 'MacBook Pro 14\"',\n    screenAspect: 1.53809,\n    kind: \"laptop\",\n    // The only landscape model: wider than tall, and with the lid open the\n    // glass occupies the upper ~two-thirds while the base flares wider below.\n    still: { widthFactor: 1, aspect: 0.78 },\n    screenInsets: {\n      x: 0.09,\n      y: 0.04,\n      width: 0.82,\n      height: 0.66,\n      radius: 0.01,\n    },\n    // The asset's material named \"Screen\" is on the laptop base. The\n    // emissive panel below is the actual display surface.\n    screenMaterialNames: [\"HlQwFCAPWzetDQy\"],\n    supportsCameraHole: false,\n    articulation: {\n      kind: \"hinge\",\n      nodeName: \"RcexTyyhpuJYATQ\",\n      // Real hinge axis, solved from the mesh rather than eyeballed: it is the\n      // fixed point of the transform that takes the authored open lid to a\n      // properly closed one — bezel resting on the base's top rim, silhouettes\n      // aligned. Rotating about it therefore both closes flush and makes every\n      // intermediate value trace the arc a physical hinge would.\n      // Node-local units (the parent is scaled 0.01 and rotated +90° about X),\n      // so this is world (y, z) = (-0.00092, -0.10764): inside the chassis at\n      // the rear, level with the hinge barrel — not on the deck surface.\n      pivot: [0, -10.7643, 0.09202],\n      // The display plane is authored at 69.9884° to the keyboard (plane fit\n      // over the screen mesh, flat to within 0.001 mm). Rotating by the\n      // supplementary angle leaves both shells exactly parallel when closed.\n      closedRotation: [1.920064, 0, 0],\n    },\n    hiddenNodeNamePatterns: [\n      /charger/i,\n      /power[\\s._-]*adapter/i,\n      /charging[\\s._-]*cable/i,\n      /^stand(?:[\\s._-]|$)/i,\n    ],\n  },\n  \"iphone-duo\": {\n    path: resolveDeviceModelPath(\"/models/iphone-duo.glb\"),\n    // The rig is in GLB units of ~1 cm (117.8 mm tall = 11.72). Scaled to the\n    // iPhone's 1.077 world units tall, so at the same layout scale a closed Duo\n    // stands as tall as the iPhone it replaces (and an open one turned\n    // portrait 1.4x taller).\n    baseScale: 0.0919,\n    modelOffset: [0, 0, 0],\n    label: \"iPhone Duo\",\n    kind: \"phone\",\n    // Inner display, measured from the \"4-scrn 2\" POSITION bounds\n    // (15.7845 x 11.0359). It is landscape when open; App Store inner shots\n    // are portrait (2007x2853), shown on the device posed a quarter turn.\n    screenAspect: 15.784506 / 11.035881,\n    // \"scrn 2.2\" is the under-display camera disc. It shares the panel's\n    // planar UVs, so texturing it too makes the camera disappear the way it\n    // does in use, instead of exposing the hole cut for it.\n    screenMaterialNames: [\"scrn 2\", \"scrn 2.2\"],\n    // Cover display on the back of the moving half (\"8\": 7.7324 x 11.1828).\n    secondaryScreen: {\n      materialNames: [\"scrn 1\"],\n      aspect: 7.732397 / 11.182772,\n    },\n    uprightScreenContent: true,\n    // The cover display's punch hole is part of the chassis and always drawn;\n    // there is no camera toggle to offer.\n    supportsCameraHole: false,\n    // Square: the open device is landscape, the closed or quarter-turned one\n    // portrait, and the frame has to hold any of them (the open diagonal is\n    // 81% of it). 1.3 wide gives the phones' pixels per world unit\n    // (0.9 x 1.45 tall), so a Duo and an iPhone at one scale match in height.\n    still: { widthFactor: 1.3, aspect: 1 },\n    screenInsets: {\n      x: 0.025,\n      y: 0.03,\n      width: 0.95,\n      height: 0.94,\n      radius: 0.03,\n    },\n    articulation: {\n      kind: \"fold\",\n      // three sanitises the authored \"Iphone Duo\" node name.\n      rootNodeName: \"Iphone_Duo\",\n      // The export lies on its side (screen normal along -X). +90 degrees\n      // about X puts the inner display facing the camera, hinge vertical.\n      rootRotation: [Math.PI / 2, 0, 0],\n      movingBone: \"L\",\n      spineBone: \"cc\",\n      // The L/R bones' shared origin: the hinge line, just above the display.\n      pivot: [0, 0.27545, 0],\n      closedAngle: -Math.PI,\n      halfWidth: 8.238,\n      // Spine bottom 0.39 below the axis, less the 0.1 lift.\n      spineLift: 0.1,\n      spineDepth: 0.29,\n    },\n  },\n};",
                "DEVICE_MODEL_IDS": "/**\n * Every model id, derived from the spec record so it cannot drift. `DeviceModelId`\n * keys the record, so adding a model to the union forces a spec entry and this\n * list follows — use it for zod enums, preloads and option lists rather than\n * restating the ids.\n */\nexport const DEVICE_MODEL_IDS = Object.keys(DEVICE_MODEL_SPECS) as [\n  DeviceModelId,\n  ...DeviceModelId[],\n];",
                "DEVICE_SAMPLE_SCREENSHOT": "export const DEVICE_SAMPLE_SCREENSHOT = \"/sample-ss/iphone17.jpg\";",
                "DEVICE_STILL_CAMERA": "export const DEVICE_STILL_CAMERA = {\n  position: [0, 0.08, 3.35] as [number, number, number],\n  fov: 24,\n};",
                "DEVICE_STILL_SCALE": "// Fixed group scale for the offscreen still render, tuned to leave margin for\n// the phone to orbit without clipping. (Still canvas dimensions/aspect live in\n// ./still/deviceStillRenderer so the PIXI renderer can import them three-free.)\nexport const DEVICE_STILL_SCALE = 0.62;",
                "DEVICE_DEFAULT_LAYOUT_SCALE": "// <Scene deviceScale>\nexport const DEVICE_DEFAULT_LAYOUT_SCALE = 1.6;",
                "getDefaultLayoutScaleForDeviceModel": "/**\n * Layout scale applied when a device is first added as a 3D model.\n *\n * The still is trimmed to the device's silhouette before it becomes a sprite,\n * so how much canvas a given scale buys depends on how much of its still frame\n * the device fills. A phone is narrow inside a portrait frame and needs scaling\n * up; the laptop nearly fills a landscape frame and needs scaling down.\n */\nexport function getDefaultLayoutScaleForDeviceModel(\n  deviceModelId: DeviceModelId,\n): number {\n  const kind = DEVICE_MODEL_SPECS[deviceModelId].kind;\n  // Added portrait-open (a quarter turned), where it is 1.4x the iPhone's\n  // height at one scale.\n  if (deviceModelId === \"iphone-duo\") return 1.15;\n  if (kind === \"laptop\") return 0.8;\n  if (kind === \"tablet\") return 1.2;\n  return DEVICE_DEFAULT_LAYOUT_SCALE;\n}",
                "DEVICE_DEFAULT_POSE": "/**\n * Default pose applied when a 3D-model device is added or a node is switched\n * to 3D: square to the canvas, matching the \"Front\" preset.\n *\n * A device that arrives pre-turned reads as a choice the user did not make,\n * and the pose grid then highlights nothing because a three-quarter default\n * matches no preset exactly. Starting at Front makes the selected preset and\n * the canvas agree, and every other pose one click away.\n */\nexport const DEVICE_DEFAULT_POSE = {\n  rotationX: 0,\n  rotationY: 0,\n  rotationZ: 0,\n};",
                "getDefaultPoseForDeviceModel": "/**\n * Default editor pose for a specific chassis.\n *\n * Every kind starts square to the canvas. Laptops especially: even a small\n * inherited roll makes the keyboard and display look accidentally crooked.\n */\nexport function getDefaultPoseForDeviceModel(deviceModelId?: DeviceModelId) {\n  // The iPhone Duo starts unfolded and turned portrait, the way App Store\n  // inner-display shots present it; every other chassis starts at Front.\n  if (deviceModelId === \"iphone-duo\") {\n    return { ...DEVICE_DEFAULT_POSE, rotationZ: -Math.PI / 2 };\n  }\n  return { ...DEVICE_DEFAULT_POSE };\n}",
                "Model3DPosePreset": "export interface Model3DPosePreset {\n  id: string;\n  label: string;\n  rotationX: number;\n  rotationY: number;\n  rotationZ: number;\n  /**\n   * Device kinds this pose suits. Omitted means every kind. A pose that flatters\n   * a handheld can read badly on another chassis — the tilted-back hero poses\n   * make an open laptop look like it is tipping over.\n   */\n  kinds?: readonly DeviceModelKind[];\n}",
                "MODEL_3D_POSE_PRESETS": "// Quick-pick poses for the 3D device. Deliberately independent from the 2D\n// frame variants: thumbnails render live from the actual model (finish +\n// screenshot) via the still renderer, so poses can use any rotation —\n// including roll — without a pre-rendered asset.\nexport const MODEL_3D_POSE_PRESETS: Model3DPosePreset[] = [\n  {\n    id: \"front\",\n    label: \"Front\",\n    rotationX: 0,\n    rotationY: 0,\n    rotationZ: 0,\n  },\n  {\n    id: \"turn-left\",\n    label: \"Turn Left\",\n    rotationX: -0.08,\n    rotationY: -0.55,\n    rotationZ: 0,\n  },\n  {\n    id: \"turn-right\",\n    label: \"Turn Right\",\n    rotationX: -0.08,\n    rotationY: 0.55,\n    rotationZ: 0,\n  },\n  // Hero poses: tilted back with a slight turn and roll (≈ X -24°, Y ±15°,\n  // Z ±14°), tuned by hand in the pose editor. Handhelds only — the same tilt\n  // on an open laptop reads as falling backwards rather than presented.\n  {\n    id: \"hero-left\",\n    label: \"Hero Left\",\n    rotationX: -0.42,\n    rotationY: -0.26,\n    rotationZ: -0.24,\n    kinds: [\"phone\", \"tablet\"],\n  },\n  {\n    id: \"hero-right\",\n    label: \"Hero Right\",\n    rotationX: -0.42,\n    rotationY: 0.26,\n    rotationZ: 0.24,\n    kinds: [\"phone\", \"tablet\"],\n  },\n];",
                "getPosePresetsForDeviceModel": "/** The pose presets worth offering for a given model. */\nexport function getPosePresetsForDeviceModel(\n  deviceModelId: DeviceModelId,\n): Model3DPosePreset[] {\n  const kind = DEVICE_MODEL_SPECS[deviceModelId].kind;\n  return MODEL_3D_POSE_PRESETS.filter(\n    (preset) => !preset.kinds || preset.kinds.includes(kind),\n  );\n}",
                "matchesPosePreset": "export function matchesPosePreset(\n  preset: Model3DPosePreset,\n  pose: { rotationX: number; rotationY: number; rotationZ: number },\n): boolean {\n  const eps = 0.01;\n  return (\n    Math.abs(preset.rotationX - pose.rotationX) < eps &&\n    Math.abs(preset.rotationY - pose.rotationY) < eps &&\n    Math.abs(preset.rotationZ - pose.rotationZ) < eps\n  );\n}",
                "getDeviceModelIdForPlatform": "export function getDeviceModelIdForPlatform(\n  platform: \"ios\" | \"android\",\n): DeviceModelId {\n  return platform === \"android\" ? \"google-pixel-10-pro\" : \"iphone-17-pro\";\n}",
                "getDeviceModelIdForKind": "/** Resolves a create-flow device choice to the model the studio will render. */\nexport function getDeviceModelIdForKind(\n  kind: DeviceModelKind | \"duo\",\n  platform: \"ios\" | \"android\" = \"ios\",\n): DeviceModelId {\n  if (kind === \"duo\") return \"iphone-duo\";\n  if (kind === \"tablet\") return \"ipad-pro\";\n  if (kind === \"laptop\") return \"macbook-pro-14\";\n  return getDeviceModelIdForPlatform(platform);\n}",
                "getScenePresetDeviceForModel": "/** What a motion preset is fitted to for a model (see getScenePresetForDevice). */\nexport function getScenePresetDeviceForModel(\n  deviceModelId: DeviceModelId,\n): DeviceModelKind | \"duo\" {\n  return deviceModelId === \"iphone-duo\"\n    ? \"duo\"\n    : DEVICE_MODEL_SPECS[deviceModelId].kind;\n}",
                "getDeviceModelIdForPhoneId": "export function getDeviceModelIdForPhoneId(phoneId: string): DeviceModelId {\n  return phoneId === PhoneId.GooglePixel\n    ? \"google-pixel-10-pro\"\n    : \"iphone-17-pro\";\n}",
                "getDeviceModelIdForScreenshotDevice": "/**\n * The 3D model that stands in for a screenshot node's 2D frame.\n *\n * A node carries exactly one of phoneId/tabletId/desktopId/watchId. Watches have\n * no 3D model, so they resolve to null and callers keep the 2D frame. Which\n * phone it is depends on the layout's platform; tablet and desktop each have a\n * single model, so their ids don't participate.\n */\nexport function getDeviceModelIdForScreenshotDevice(\n  device: {\n    phoneId?: string;\n    tabletId?: string;\n    desktopId?: string;\n    watchId?: string;\n  },\n  platform: \"ios\" | \"android\" = \"ios\",\n): DeviceModelId | null {\n  if (device.watchId) return null;\n  if (device.desktopId) return \"macbook-pro-14\";\n  if (device.tabletId) return \"ipad-pro\";\n  if (device.phoneId) return getDeviceModelIdForPhoneId(device.phoneId);\n  return getDeviceModelIdForPlatform(platform);\n}",
                "getDeviceModelIdForScreenshotCategory": "/**\n * Same mapping for the editor's device categories, which say \"desktop\" where\n * the 3D models say \"laptop\". Watches have no model.\n */\nexport function getDeviceModelIdForScreenshotCategory(\n  category: \"phone\" | \"tablet\" | \"desktop\" | \"watch\",\n  platform: \"ios\" | \"android\" = \"ios\",\n): DeviceModelId | null {\n  if (category === \"watch\") return null;\n  return getDeviceModelIdForKind(\n    category === \"desktop\" ? \"laptop\" : category,\n    platform,\n  );\n}",
                "supportsModel3D": "/** Whether a screenshot node can be rendered as a 3D device at all. */\nexport function supportsModel3D(device: {\n  phoneId?: string;\n  tabletId?: string;\n  desktopId?: string;\n  watchId?: string;\n}): boolean {\n  return !device.watchId;\n}",
                "getDefaultPhoneIdForPlatform": "export function getDefaultPhoneIdForPlatform(\n  platform: \"ios\" | \"android\",\n): PhoneId {\n  return platform === \"android\" ? PhoneId.GooglePixel : PhoneId.iPhone17ProMax;\n}",
                "getDefaultFinishForDeviceModel": "export function getDefaultFinishForDeviceModel(\n  deviceModelId: DeviceModelId,\n): Finish {\n  // iPhone 18 Pro Black is the house iPhone finish (Deep Blue until the 18\n  // Pro launch; saved layouts keep whatever finish they stored).\n  if (deviceModelId === \"google-pixel-10-pro\") return \"pixel-obsidian\";\n  if (deviceModelId === \"iphone-duo\") return \"duo-night-sky\";\n  return \"black\";\n}",
                "getFinishKeysForDeviceModel": "export function getFinishKeysForDeviceModel(\n  deviceModelId: DeviceModelId,\n): readonly Finish[] {\n  if (deviceModelId === \"google-pixel-10-pro\") return PIXEL_FINISH_KEYS;\n  if (deviceModelId === \"iphone-17-pro\") return IPHONE_FINISH_KEYS;\n  if (deviceModelId === \"iphone-duo\") return IPHONE_DUO_FINISH_KEYS;\n  return [\"silver\"];\n}",
                "FinishGroups": "export interface FinishGroups {\n  /** Current-generation finishes, always shown. */\n  primary: readonly Finish[];\n  /** Older finishes kept selectable behind a disclosure, e.g. iPhone 17 Pro. */\n  secondary: { label: string; keys: readonly Finish[] } | null;\n}",
                "getFinishGroupsForDeviceModel": "/**\n * Finish pickers show `primary` up front and `secondary` collapsed.\n * `getFinishKeysForDeviceModel` stays the full allow-list for validation.\n */\nexport function getFinishGroupsForDeviceModel(\n  deviceModelId: DeviceModelId,\n): FinishGroups {\n  if (deviceModelId === \"iphone-17-pro\") {\n    return {\n      primary: IPHONE_18_FINISH_KEYS,\n      secondary: { label: \"iPhone 17 Pro\", keys: IPHONE_17_FINISH_KEYS },\n    };\n  }\n  return {\n    primary: getFinishKeysForDeviceModel(deviceModelId),\n    secondary: null,\n  };\n}",
                "getDeviceSampleScreenshot": "export function getDeviceSampleScreenshot(\n  deviceModelId: DeviceModelId,\n): string {\n  const kind = DEVICE_MODEL_SPECS[deviceModelId].kind;\n  if (kind === \"tablet\") return \"/sample-ss/ipad.jpg\";\n  if (kind === \"laptop\") return \"/sample-ss/desktop.jpg\";\n  return DEVICE_SAMPLE_SCREENSHOT;\n}",
                "CONTENT_PROBE_POINTS": "// Normalized (x, y) points sampled to detect whether opaque device content has\n// rendered yet, so captures don't snapshot a still-loading frame.\nexport const CONTENT_PROBE_POINTS: ReadonlyArray<readonly [number, number]> = [\n  [0.5, 0.5],\n  [0.5, 0.35],\n  [0.5, 0.65],\n  [0.45, 0.5],\n  [0.55, 0.5],\n];"
            }
        },
        {
            "group": "shared",
            "path": "src/features/templates-core/utils/phoneRegistry.ts",
            "sha256": "2e38150a8fc655f5b1c978c34df572f297e931dd102853c82cc44c6a5cce7337",
            "declarations": {
                "PhoneId": "/**\n * Supported phone device IDs\n */\nexport enum PhoneId {\n  iPhone17ProMax = \"iphone17\",\n  GooglePixel = \"googlepixel\",\n}",
                "PhoneVariant3D": "/**\n * Phone frame variant (orientation/perspective)\n */\nexport type PhoneVariant3D =\n  | \"left\"\n  | \"left-2\"\n  | \"right\"\n  | \"right-2\"\n  | \"handheld\"\n  | \"handheld2\"\n  | \"handheld3\"\n  | \"flat\"\n  | \"none\";",
                "PhoneSpec": "/**\n * Phone device specifications\n */\nexport interface PhoneSpec {\n  id: string;\n  aspectRatio: number; // width / height\n  cornerRadius: number; // Corner radius in pixels (at reference size) - used for rounded corners on screenshots (default/fallback)\n  bezelSize: number; // Border/bezel size in pixels (at reference size) - used for calculating screen bounds\n  screenCorners3D?: ScreenCorners3D; // 3D frame corner coordinates (for mesh pinning) - default corners\n  variant3D?: PhoneVariant3D; // Default 3D frame variant (left/right/handheld)\n  variants3D?: Partial<\n    Record<\n      PhoneVariant3D,\n      {\n        screenCorners3D?: ScreenCorners3D; // Variant-specific corner coordinates for 3D rendering\n        cornerRadius?: number; // Variant-specific corner radius (overrides default if provided)\n      }\n    >\n  >; // Variant-specific settings for 3D rendering\n}",
                "SHARED_PHONE_VARIANTS": "const SHARED_PHONE_VARIANTS = {\n  right: {\n    screenCorners3D: {\n      topLeft: { x: 80, y: -7 },\n      topRight: { x: 1567, y: 235 },\n      bottomLeft: { x: 775, y: 3732 },\n      bottomRight: { x: 2451, y: 3512 },\n    },\n    cornerRadius: 220,\n  },\n  left: {\n    screenCorners3D: {\n      topLeft: { x: 862, y: 235 },\n      topRight: { x: 2338, y: -7 },\n      bottomLeft: { x: -34, y: 3514 },\n      bottomRight: { x: 1646, y: 3725 },\n    },\n    cornerRadius: 220,\n  },\n  \"left-2\": {\n    screenCorners3D: {\n      topLeft: { x: 10, y: 265 },\n      topRight: { x: 1168, y: -7 },\n      bottomLeft: { x: 11, y: 3523 },\n      bottomRight: { x: 1165, y: 3788 },\n    },\n    cornerRadius: 230,\n  },\n  \"right-2\": {\n    screenCorners3D: {\n      topLeft: { x: 166, y: -9 },\n      topRight: { x: 1324, y: 266 },\n      bottomLeft: { x: 164, y: 3796 },\n      bottomRight: { x: 1321, y: 3523 },\n    },\n    cornerRadius: 230,\n  },\n  handheld: {\n    screenCorners3D: {\n      topLeft: { x: 21, y: 43 },\n      topRight: { x: 692, y: 8 },\n      bottomLeft: { x: 382, y: 1601 },\n      bottomRight: { x: 1058, y: 1444 },\n    },\n    cornerRadius: 100,\n  },\n} satisfies NonNullable<PhoneSpec[\"variants3D\"]>;",
                "PHONE_REGISTRY": "/**\n * Registry of supported phone devices\n */\nconst PHONE_REGISTRY: Record<PhoneId, PhoneSpec> = {\n  [PhoneId.iPhone17ProMax]: {\n    id: PhoneId.iPhone17ProMax,\n    aspectRatio: 1320 / 2868, // Same aspect ratio as iPhone 17\n    cornerRadius: 100, // Default corner radius\n    bezelSize: 20,\n    variant3D: \"right\", // Default variant\n    variants3D: {\n      ...SHARED_PHONE_VARIANTS,\n      handheld2: {\n        screenCorners3D: {\n          topLeft: { x: 388, y: 7 },\n          topRight: { x: 995, y: 33 },\n          bottomLeft: { x: 53, y: 1373 },\n          bottomRight: { x: 667, y: 1518 },\n        },\n        cornerRadius: 100,\n      },\n      handheld3: {\n        screenCorners3D: {\n          topLeft: { x: 119, y: 11 },\n          topRight: { x: 736, y: 9 },\n          bottomLeft: { x: 121, y: 1307 },\n          bottomRight: { x: 735, y: 1310 },\n        },\n        cornerRadius: 100,\n      },\n      flat: {\n        screenCorners3D: {\n          topLeft: { x: 46, y: 40 },\n          topRight: { x: 1260, y: 40 },\n          bottomLeft: { x: 46, y: 2667 },\n          bottomRight: { x: 1260, y: 2667 },\n        },\n        cornerRadius: 115, // Optional: override default if needed\n      },\n    },\n  },\n  [PhoneId.GooglePixel]: {\n    id: PhoneId.GooglePixel,\n    aspectRatio: 1080 / 2400, // Typical Pixel aspect ratio\n    cornerRadius: 80, // Default corner radius\n    bezelSize: 15,\n    variant3D: \"right\", // Default variant\n    variants3D: {\n      ...SHARED_PHONE_VARIANTS,\n      handheld2: {\n        screenCorners3D: {\n          topLeft: { x: 390, y: 19 },\n          topRight: { x: 1006, y: 6 },\n          bottomLeft: { x: 44, y: 1372 },\n          bottomRight: { x: 693, y: 1481 },\n        },\n        cornerRadius: 100,\n      },\n      handheld3: {\n        screenCorners3D: {\n          topLeft: { x: 119, y: 11 },\n          topRight: { x: 736, y: 9 },\n          bottomLeft: { x: 121, y: 1307 },\n          bottomRight: { x: 735, y: 1310 },\n        },\n        cornerRadius: 90,\n      },\n      flat: {\n        screenCorners3D: {\n          topLeft: { x: 38, y: 35 },\n          topRight: { x: 1129, y: 31 },\n          bottomLeft: { x: 34, y: 2449 },\n          bottomRight: { x: 1130, y: 2451 },\n        },\n        cornerRadius: 80,\n      },\n    },\n  },\n};",
                "PHONE_FRAME_DIMENSIONS": "/**\n * Frame PNG dimensions per phone × variant (width, height in pixels).\n * Used to compute proportional scale adjustments when switching variants.\n */\nconst PHONE_FRAME_DIMENSIONS: Record<\n  string,\n  Partial<Record<PhoneVariant3D, { width: number; height: number }>>\n> = {\n  [PhoneId.iPhone17ProMax]: {\n    flat: { width: 1311, height: 2708 },\n    left: { width: 2419, height: 3811 },\n    \"left-2\": { width: 1331, height: 3787 },\n    right: { width: 2420, height: 3813 },\n    \"right-2\": { width: 1334, height: 3788 },\n    handheld: { width: 1526, height: 2120 },\n    handheld2: { width: 1383, height: 2016 },\n    handheld3: { width: 1449, height: 1786 },\n  },\n  [PhoneId.GooglePixel]: {\n    flat: { width: 1180, height: 2500 },\n    left: { width: 2419, height: 3811 },\n    \"left-2\": { width: 1331, height: 3787 },\n    right: { width: 2420, height: 3813 },\n    \"right-2\": { width: 1334, height: 3788 },\n    handheld: { width: 1526, height: 2120 },\n    handheld2: { width: 1443, height: 2042 },\n    handheld3: { width: 1449, height: 1786 },\n  },\n};",
                "getPhoneFrameDimensions": "/**\n * Get frame PNG dimensions for a phone variant.\n */\nexport function getPhoneFrameDimensions(\n  phoneId: string | undefined,\n  variant: PhoneVariant3D,\n): { width: number; height: number } | undefined {\n  const id = phoneId || PhoneId.iPhone17ProMax;\n  return PHONE_FRAME_DIMENSIONS[id]?.[variant];\n}",
                "getPhoneSpec": "/**\n * Get phone spec by ID\n * @param phoneId - Phone identifier\n * @returns PhoneSpec or undefined if not found\n */\nexport function getPhoneSpec(phoneId: PhoneId | string): PhoneSpec | undefined {\n  return PHONE_REGISTRY[phoneId as PhoneId];\n}",
                "getDefaultPhoneSpec": "/**\n * Get default phone spec (iPhone 17 Pro Max)\n */\nexport function getDefaultPhoneSpec(): PhoneSpec {\n  return PHONE_REGISTRY[PhoneId.iPhone17ProMax];\n}"
            }
        },
        {
            "group": "shared",
            "path": "src/features/templates-core/utils/tabletRegistry.ts",
            "sha256": "b8dda367361f54fb3b82cc39df8ff89aae04e822990f2a0b0c0376db45a6774d",
            "declarations": {
                "TabletId": "/**\n * Supported tablet device IDs\n */\nexport enum TabletId {\n  iPadPro129 = \"ipad\",\n}",
                "TabletVariant3D": "/**\n * Tablet frame variant (orientation/perspective)\n */\nexport type TabletVariant3D =\n  | \"left\"\n  | \"left-2\"\n  | \"right\"\n  | \"right-2\"\n  | \"handheld\"\n  | \"handheld2\"\n  | \"handheld3\"\n  | \"flat\"\n  | \"none\";",
                "TabletSpec": "/**\n * Tablet device specifications\n */\nexport interface TabletSpec {\n  id: string;\n  aspectRatio: number; // width / height\n  cornerRadius: number; // Corner radius in pixels (at reference size) - used for rounded corners on screenshots\n  bezelSize: number; // Border/bezel size in pixels (at reference size) - used for calculating screen bounds\n  screenCorners3D?: ScreenCorners3D; // 3D frame corner coordinates (for mesh pinning) - default corners\n  variant3D?: TabletVariant3D; // Default 3D frame variant (left/right/handheld)\n  variants3D?: Partial<\n    Record<TabletVariant3D, { screenCorners3D?: ScreenCorners3D }>\n  >; // Variant-specific corner coordinates for 3D rendering\n}",
                "TABLET_REGISTRY": "/**\n * Registry of supported tablet devices\n */\nconst TABLET_REGISTRY: Record<TabletId, TabletSpec> = {\n  [TabletId.iPadPro129]: {\n    id: TabletId.iPadPro129,\n    aspectRatio: 2048 / 2732, // iPad Pro 12.9\" portrait\n    cornerRadius: 20,\n    bezelSize: 50,\n    variant3D: \"right\", // Default variant\n    variants3D: {\n      right: {\n        screenCorners3D: {\n          topLeft: { x: 131, y: 94 },\n          topRight: { x: 2077, y: 370 },\n          bottomLeft: { x: 831, y: 3426 },\n          bottomRight: { x: 2962, y: 3145 },\n        },\n      },\n      left: {\n        screenCorners3D: {\n          topLeft: { x: 871, y: 372 },\n          topRight: { x: 2927, y: 91 },\n          bottomLeft: { x: 81, y: 3145 },\n          bottomRight: { x: 2371, y: 3419 },\n        },\n      },\n      \"left-2\": {\n        screenCorners3D: {\n          topLeft: { x: 60, y: 452 },\n          topRight: { x: 1662, y: 118 },\n          bottomLeft: { x: 60, y: 3240 },\n          bottomRight: { x: 1666, y: 3585 },\n        },\n      },\n      \"right-2\": {\n        screenCorners3D: {\n          topLeft: { x: 150, y: 120 },\n          topRight: { x: 1847, y: 451 },\n          bottomLeft: { x: 152, y: 3587 },\n          bottomRight: { x: 1851, y: 3234 },\n        },\n      },\n      handheld: {\n        screenCorners3D: {\n          topLeft: { x: 141, y: 100 },\n          topRight: { x: 2070, y: 376 },\n          bottomLeft: { x: 835, y: 3418 },\n          bottomRight: { x: 2952, y: 3139 },\n        },\n      },\n      flat: {\n        screenCorners3D: {\n          topLeft: { x: 46, y: 42 },\n          topRight: { x: 1080, y: 42 },\n          bottomLeft: { x: 46, y: 1422 },\n          bottomRight: { x: 1080, y: 1422 },\n        },\n      },\n    },\n  },\n};",
                "TABLET_FRAME_DIMENSIONS": "/**\n * Frame PNG dimensions per tablet × variant (width, height in pixels).\n * Used to compute proportional scale adjustments when switching variants.\n */\nconst TABLET_FRAME_DIMENSIONS: Record<\n  string,\n  Partial<Record<TabletVariant3D, { width: number; height: number }>>\n> = {\n  [TabletId.iPadPro129]: {\n    flat: { width: 1124, height: 1467 },\n    left: { width: 3059, height: 3578 },\n    \"left-2\": { width: 1810, height: 3699 },\n    right: { width: 3042, height: 3587 },\n    \"right-2\": { width: 1916, height: 3704 },\n    handheld: { width: 3042, height: 3587 },\n  },\n};",
                "getTabletFrameDimensions": "/**\n * Get frame PNG dimensions for a tablet variant.\n */\nexport function getTabletFrameDimensions(\n  tabletId: string | undefined,\n  variant: TabletVariant3D,\n): { width: number; height: number } | undefined {\n  const id = tabletId || TabletId.iPadPro129;\n  return TABLET_FRAME_DIMENSIONS[id]?.[variant];\n}",
                "getTabletSpec": "/**\n * Get tablet spec by ID\n * @param tabletId - Tablet identifier\n * @returns TabletSpec or undefined if not found\n */\nexport function getTabletSpec(\n  tabletId: TabletId | string,\n): TabletSpec | undefined {\n  return TABLET_REGISTRY[tabletId as TabletId];\n}",
                "getDefaultTabletSpec": "/**\n * Get default tablet spec (iPad Pro 12.9\")\n */\nexport function getDefaultTabletSpec(): TabletSpec {\n  return TABLET_REGISTRY[TabletId.iPadPro129];\n}"
            }
        },
        {
            "group": "shared",
            "path": "src/features/templates-core/utils/desktopRegistry.ts",
            "sha256": "e092ea8166b0b52d4401a27794a83cf4e64c8781b12992581af37a774326c36f",
            "declarations": {
                "DesktopId": "/**\n * Supported desktop device IDs.\n */\nexport enum DesktopId {\n  MacBookPro16 = \"macbook-pro-16\",\n}",
                "ScreenCorners3D": "export interface ScreenCorners3D {\n  topLeft: { x: number; y: number };\n  topRight: { x: number; y: number };\n  bottomLeft: { x: number; y: number };\n  bottomRight: { x: number; y: number };\n}",
                "DesktopSpec": "/**\n * Desktop device specification.\n */\nexport interface DesktopSpec {\n  id: string;\n  aspectRatio: number; // width / height\n  cornerRadius: number; // Rounded display corners at reference frame size\n  frameAsset: string; // Path under /public\n  frameDimensions: { width: number; height: number };\n  screenCorners3D: ScreenCorners3D; // Flat placement corners inside frame\n}",
                "DESKTOP_REGISTRY": "/**\n * Registry of supported desktop devices.\n */\nconst DESKTOP_REGISTRY: Record<DesktopId, DesktopSpec> = {\n  [DesktopId.MacBookPro16]: {\n    id: DesktopId.MacBookPro16,\n    aspectRatio: 2880 / 1800, // 16:10\n    cornerRadius: 26,\n    frameAsset: \"/components/macbook-pro-16.webp\",\n    frameDimensions: { width: 3983, height: 2431 },\n    screenCorners3D: {\n      topLeft: { x: 351, y: 43 },\n      topRight: { x: 3632, y: 53 },\n      bottomLeft: { x: 351, y: 2173 },\n      bottomRight: { x: 3632, y: 2173 },\n    },\n  },\n};",
                "getDesktopFrameDimensions": "/** Natural artwork size, matching the desktop renderer's frame metrics. */\nexport function getDesktopFrameDimensions(\n  desktopId: string,\n  variant: \"flat\" | \"none\" = \"flat\",\n): { width: number; height: number } | undefined {\n  const spec = getDesktopSpec(desktopId);\n  if (!spec) return undefined;\n  if (variant !== \"none\") {\n    return spec.frameDimensions;\n  }\n  const { width, height } = cornersToBounds(spec.screenCorners3D);\n  return { width, height };\n}",
                "getDesktopSpec": "export function getDesktopSpec(\n  desktopId: DesktopId | string,\n): DesktopSpec | undefined {\n  return DESKTOP_REGISTRY[desktopId as DesktopId];\n}",
                "getDefaultDesktopSpec": "export function getDefaultDesktopSpec(): DesktopSpec {\n  return DESKTOP_REGISTRY[DesktopId.MacBookPro16];\n}"
            }
        },
        {
            "group": "shared",
            "path": "src/features/templates-core/utils/watchRegistry.ts",
            "sha256": "f94f921e1b8236a0e8be37549b1a3043b8fc3ccf13a8f26003a03357b8280385",
            "declarations": {
                "WatchId": "/**\n * Supported watch device IDs\n */\nexport enum WatchId {\n  AppleWatch41 = \"apple-watch-41\",\n  AppleWatch42 = \"apple-watch-42\",\n  AppleWatch46 = \"apple-watch-46\",\n}",
                "ScreenCorners3D": "/**\n * 3D screen corner coordinates for mesh pinning\n * Coordinates are relative to the watch frame image dimensions\n */\nexport interface ScreenCorners3D {\n  topLeft: { x: number; y: number };\n  topRight: { x: number; y: number };\n  bottomLeft: { x: number; y: number };\n  bottomRight: { x: number; y: number };\n}",
                "WatchVariant3D": "/**\n * Watch frame variant\n */\nexport type WatchVariant3D = \"flat\" | \"none\";",
                "WatchSpec": "/**\n * Watch device specifications\n */\nexport interface WatchSpec {\n  id: string;\n  aspectRatio: number; // width / height (screen aspect ratio)\n  cornerRadius: number; // Corner radius in pixels at reference size\n  bezelSize: number; // Bezel size in pixels at reference size\n  screenCorners3D?: ScreenCorners3D; // Default screen corners\n  variant3D?: WatchVariant3D; // Default variant\n  variants3D?: Partial<\n    Record<WatchVariant3D, { screenCorners3D?: ScreenCorners3D }>\n  >;\n}",
                "WATCH_REGISTRY": "/**\n * Registry of supported watch devices\n */\nconst WATCH_REGISTRY: Record<WatchId, WatchSpec> = {\n  [WatchId.AppleWatch41]: {\n    id: WatchId.AppleWatch41,\n    aspectRatio: 352 / 430,\n    cornerRadius: 60,\n    bezelSize: 10,\n    variant3D: \"flat\",\n    variants3D: {\n      flat: {\n        screenCorners3D: {\n          topLeft: { x: 30, y: 155 },\n          topRight: { x: 391, y: 154 },\n          bottomLeft: { x: 30, y: 594 },\n          bottomRight: { x: 393, y: 592 },\n        },\n      },\n    },\n  },\n  [WatchId.AppleWatch42]: {\n    id: WatchId.AppleWatch42,\n    aspectRatio: 374 / 446,\n    cornerRadius: 65,\n    bezelSize: 10,\n    variant3D: \"flat\",\n    variants3D: {\n      flat: {\n        screenCorners3D: {\n          topLeft: { x: 56, y: 159 },\n          topRight: { x: 442, y: 161 },\n          bottomLeft: { x: 57, y: 618 },\n          bottomRight: { x: 444, y: 618 },\n        },\n      },\n    },\n  },\n  [WatchId.AppleWatch46]: {\n    id: WatchId.AppleWatch46,\n    aspectRatio: 416 / 496,\n    cornerRadius: 70,\n    bezelSize: 10,\n    variant3D: \"flat\",\n    variants3D: {\n      flat: {\n        screenCorners3D: {\n          topLeft: { x: 66, y: 187 },\n          topRight: { x: 492, y: 185 },\n          bottomLeft: { x: 69, y: 692 },\n          bottomRight: { x: 496, y: 692 },\n        },\n      },\n    },\n  },\n};",
                "WATCH_FRAME_DIMENSIONS": "/**\n * Frame dimensions per watch model × variant (width, height in pixels).\n */\nconst WATCH_FRAME_DIMENSIONS: Record<\n  string,\n  Partial<Record<WatchVariant3D, { width: number; height: number }>>\n> = {\n  [WatchId.AppleWatch41]: {\n    flat: { width: 440, height: 746 },\n  },\n  [WatchId.AppleWatch42]: {\n    flat: { width: 500, height: 780 },\n  },\n  [WatchId.AppleWatch46]: {\n    flat: { width: 560, height: 880 },\n  },\n};",
                "getWatchFrameDimensions": "/**\n * Get frame dimensions for a watch variant.\n */\nexport function getWatchFrameDimensions(\n  watchId: string | undefined,\n  variant: WatchVariant3D,\n): { width: number; height: number } | undefined {\n  const id = watchId || WatchId.AppleWatch41;\n  return WATCH_FRAME_DIMENSIONS[id]?.[variant];\n}",
                "getWatchSpec": "/**\n * Get watch spec by ID\n */\nexport function getWatchSpec(watchId: WatchId | string): WatchSpec | undefined {\n  return WATCH_REGISTRY[watchId as WatchId];\n}",
                "getDefaultWatchSpec": "/**\n * Get default watch spec (Apple Watch 41mm)\n */\nexport function getDefaultWatchSpec(): WatchSpec {\n  return WATCH_REGISTRY[WatchId.AppleWatch41];\n}"
            }
        }
    ]
}
