diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..b9ea3b9 --- /dev/null +++ b/.env.example @@ -0,0 +1,23 @@ +EXPO_PUBLIC_FIREBASE_API_KEY= +EXPO_PUBLIC_FIREBASE_AUTH_DOMAIN= +EXPO_PUBLIC_FIREBASE_PROJECT_ID= +EXPO_PUBLIC_FIREBASE_STORAGE_BUCKET= +EXPO_PUBLIC_FIREBASE_MESSAGING_SENDER_ID= +EXPO_PUBLIC_FIREBASE_APP_ID= +EXPO_PUBLIC_GOOGLE_WEB_CLIENT_ID=YOUR_GOOGLE_WEB_CLIENT_ID +EXPO_PUBLIC_GOOGLE_IOS_CLIENT_ID=YOUR_GOOGLE_IOS_CLIENT_ID +OPENAI_API_KEY= +EXPO_PUBLIC_AURA_VOICE_ENABLED=1 +EXPO_PUBLIC_LIVE_PRODUCT_SEARCH_ENABLED=0 +EXPO_PUBLIC_AFFILIATE_SHOPPING_ENABLED=0 +FIREBASE_CLIENT_ID=YOUR_FIREBASE_CLIENT_ID +FIREBASE_CLIENT_SECRET=YOUR_FIREBASE_CLIENT_SECRET +SERPAPI_API_KEY= +SKIMLINKS_ID= +PRODUCT_SEARCH_PROVIDER=serpapi +PRODUCT_SEARCH_ENABLED=false +PRODUCT_SEARCH_CACHE_TTL_HOURS=24 +PRODUCT_SEARCH_DAILY_LIMIT=20 +PRODUCT_SEARCH_COUNTRY=us +PRODUCT_SEARCH_LANGUAGE=en +MAX_LIVE_SEARCH_RESULTS=3 diff --git a/.gitignore b/.gitignore index 8d2729b..49ee316 100644 --- a/.gitignore +++ b/.gitignore @@ -16,6 +16,9 @@ yarn-error.log* .env .env.* .env.local +!.env.example +google-services.json +GoogleService-Info.plist # OS .DS_Store @@ -26,7 +29,20 @@ yarn-error.log* expo-env.d.ts # @end expo-cli -ios/ -android/ +/ios/ +/android/ ios.build/ android.build/ +.claude/ +.idea/ + +# Allow Firebase native config files required for EAS/TestFlight builds +!GoogleService-Info.plist +!ios/ +!ios/AURA/ +!ios/AURA/GoogleService-Info.plist +# Apple signing credentials - never commit +credentials.json +ios/certs/ +*.p12 +*.mobileprovision diff --git a/APP_STORE_PRIVACY_ANSWERS_DRAFT.md b/APP_STORE_PRIVACY_ANSWERS_DRAFT.md new file mode 100644 index 0000000..69dfa88 --- /dev/null +++ b/APP_STORE_PRIVACY_ANSWERS_DRAFT.md @@ -0,0 +1,45 @@ +# AURA App Store Privacy Answers Draft + +Draft based only on repo evidence. Do not submit without owner/legal review. + +## Data Used To Track You + +- NEEDS OWNER CONFIRMATION: No tracking SDK or ad SDK was found in package.json. Affiliate links are present, but code evidence does not prove cross-app tracking. Confirm Skimlinks/affiliate behavior before answering "No." + +## Data Linked To The User + +| App Store category | Draft answer | Status | Repo basis | +|---|---|---|---| +| Contact Info - Email Address | Collected, linked to user, used for app functionality/account management | CONFIRMED FROM CODE | Firebase email/password auth | +| Contact Info - Name | Collected if user enters it, linked to user, used for personalization | CONFIRMED FROM CODE | Register/profile stores name/displayName | +| User Content - Photos or Videos | Clothing photos, profile photo, AURA image attachments are collected and linked to user | CONFIRMED FROM CODE | Firebase Storage `users/{uid}/...` | +| User Content - Other User Content | Closet metadata, outfit plans, AURA chats, product links, saved looks | CONFIRMED FROM CODE | Firestore `users/{uid}` subcollections | +| Identifiers - User ID | Firebase UID is used and linked to user | CONFIRMED FROM CODE | Auth UID scopes Firestore/Storage | +| Location - Precise Location | Likely collected/processed when weather is enabled because lat/lon is sent to weather API | LIKELY | `expo-location`, Open-Meteo fetch with lat/lon | +| Contacts | Not found | CONFIRMED FROM CODE | No contacts permission/API found | +| Calendars | Calendar event titles/times are read when permission is granted | LIKELY | `expo-calendar` reads events; confirm App Store category wording | +| Audio Data | Voice dictation is present; backend transcription callable exists but no current call site found | NEEDS OWNER CONFIRMATION | `@react-native-voice/voice`, `transcribeAuraAudio` | +| Search History | Product searches/import links may count depending interpretation | NEEDS OWNER CONFIRMATION | SerpApi and product URL import flows | +| Purchases | No in-app purchase code found | CONFIRMED FROM CODE | No StoreKit/IAP dependency found | +| Financial Info | Item price fields can be stored manually or from product links; no payment info found | LIKELY | Clothing item price fields | +| Health and Fitness | Not found | CONFIRMED FROM CODE | No HealthKit/fitness APIs found | +| Sensitive Info | Not intentionally collected in code | NEEDS OWNER CONFIRMATION | User-generated chat/photos can contain sensitive content | +| Diagnostics | No Crashlytics/Sentry SDK found | NEEDS OWNER CONFIRMATION | App Store/TestFlight or Firebase console settings may collect outside repo | +| Product Interaction | No analytics SDK found, but Firestore commerce events are written for product suggestions | LIKELY | `commerceEvents` and suggestion analytics | + +## Data Not Linked To The User + +- NEEDS OWNER CONFIRMATION: Weather cache and device-level SDK telemetry may be unlinked, but repo alone is insufficient. + +## Privacy Manifest Risk + +`ios/AURA/PrivacyInfo.xcprivacy` currently has an empty `NSPrivacyCollectedDataTypes` array. That may be acceptable only if App Store Connect privacy answers carry the disclosure and SDK manifests cover required APIs, but owner/legal should review before external TestFlight/App Review. + +## Recommended Owner Answers To Confirm + +- Tracking: probably "No" only if affiliate/Skimlinks setup does not track users across apps/sites. +- Data linked to user: Email, name, user ID, photos, other user content, product interaction, location if weather enabled, calendar data if enabled. +- Purposes: App functionality, personalization, account management, product personalization/recommendations. +- Third-party advertising: not confirmed. +- Developer advertising or marketing: not confirmed. +- Analytics: not confirmed from code. diff --git a/AURA_TESTFLIGHT_QA_FIX_REPORT.md b/AURA_TESTFLIGHT_QA_FIX_REPORT.md new file mode 100644 index 0000000..52cb5d9 --- /dev/null +++ b/AURA_TESTFLIGHT_QA_FIX_REPORT.md @@ -0,0 +1,80 @@ +# AURA TestFlight QA Fix Report + +Date: 2026-05-21 +Build target: TestFlight Build 4 + +## Root Causes Found + +- Product-link wardrobe saves could inherit a prior AURA chat image because the candidate local-photo resolver fell back to the only recent chat attachment when there was one candidate and one image. Link candidates now never use chat attachment local photos. +- Product preview fallback was too permissive. A 404/error page or weak client preview could still become an "I found this item" card. Product-link previews now require a real product title and selected product image. +- Retailer titles were not fully HTML-decoded, so values such as `Men's` surfaced in cards. Numeric HTML entities are now decoded in client and Functions extraction paths. +- Footwear detection ran too late for retailer titles that contain generic men's/women's text. Air Jordan, New Balance, Foot Locker, JD Sports, shoes, sneakers, trainers, and men's/women's shoes now map to footwear before top inference. +- "Better" follow-ups were routed to existing-outfit advice instead of generating a concrete replacement. Refinement prompts now force a new outfit card with diversity constraints. +- Streaming assistant messages were local-only until final completion. The client now persists the pending assistant placeholder, overwrites it with the final message, and recovers stale pending streams on rehydrate. +- The AURA chat bottom inset did not leave enough room for the composer plus floating dock, so outfit cards and action buttons could sit behind controls. +- Outfit-photo answers used the generic detected-outfit intro even when the user asked "What am I wearing?" Direct outfit descriptions now appear before the review card. + +## Bugs Fixed + +- H&M/product-link stale image regression: product-link items use only product preview image candidates. +- Aritzia/error-page preview: semantic 404/error/bot pages fail cleanly instead of creating fake item cards. +- Amazon/Foot Locker/JD Sports/Macy's/Aritzia retailer quality: added safer title cleanup, brand fallback labels, HTML entity decoding, and stricter card creation. +- Footwear categorization: shoe retailer and sneaker model cues now map to footwear/shoes. +- AURA bottom overlap: chat padding now accounts for composer height, floating dock height, safe area bottom, and extra scroll room. +- Message controls: long-press user messages for Copy/Edit and assistant/system messages for Copy/Regenerate. +- Streaming persistence: user messages and pending assistant placeholders persist before stream completion; stale pending responses show Retry. +- Better/different outfit requests: now generate concrete alternate looks and avoid recent repeated combinations. +- Outfit photo direct answer: "What am I wearing?" responds naturally first, dedupes color words, and improves displayed roles. +- Shopping recommendations: existing AURA wardrobe suggestion cards and missing-piece actions continue to expose the live shopping/search sheet without pretending exact product availability. + +## Files Changed In This Pass + +- `app/(tabs)/ai.tsx` +- `app/(tabs)/closet.tsx` +- `functions/src/previewProductLink.ts` +- `functions/src/shared/auraCandidatePreview.ts` +- `functions/src/shared/auraOutfitPhotoAnalysis.ts` +- `functions/src/shared/auraUrlCandidatePreview.ts` +- `functions/src/shared/productLinkExtractor.ts` +- `functions/src/shared/productUrlMetadata.ts` +- `src/components/ai/AuraReplyCard.tsx` +- `src/components/ai/ChatList.tsx` +- `src/components/ai/ChatMessage.tsx` +- `src/hooks/aura/useAuraChatHydration.ts` +- `src/lib/aiChats.ts` +- `src/lib/aura.ts` +- `src/lib/auraAttachments.ts` +- `src/lib/auraChatHelpers.ts` +- `src/lib/auraOutfitAnalysisDisplay.ts` +- `src/lib/productLinkClosetImport.ts` +- `src/types/aura.ts` + +## Known Remaining Issues + +- `npx expo-doctor` still warns that native folders are present, so native config in `app.json` will not auto-sync through CNG. This is not from the QA fixes, but Build 4 should be made from the current native project or the native config should be synced intentionally. +- No Sentry was added in this pass per instruction. +- Retailer scraping can still be blocked by some stores; blocked/unsupported states now fail cleanly and ask the tester to try another link, screenshot, or manual add. +- I did not redesign AURA UI; message controls use the platform alert menu to keep the chat uncluttered. + +## Manual QA Checklist + +- Aritzia 404: paste a known bad Aritzia product URL. Expected: no product card, friendly failure message. +- Amazon product link: paste a real Amazon product URL. Expected: decoded title, real image if readable, Add enabled only with title plus image. +- Foot Locker shoe: paste a New Balance/Air Jordan shoe URL. Expected: title apostrophes decoded and category shoes/footwear. +- JD Sports shoe: paste an Air Jordan/JD Sports URL. Expected: title apostrophes decoded and category shoes/footwear. +- Macy's product: paste a readable Macy's product URL. Expected: real title/image preview or clean unsupported failure. +- H&M stale image regression: send an outfit/photo in AURA chat, then import an H&M product link. Expected: saved closet item image matches H&M product preview, not the prior chat photo. +- Better refinement: ask for a look, then send "Better" or "make it better." Expected: concrete updated outfit card, at least one meaningful changed piece, short explanation. +- Different outfit request: ask "another" or "different." Expected: no exact repeat of the recent top/bottom/shoes combination within recent suggestions. +- What am I wearing: upload an outfit photo and ask "What am I wearing?" Expected: natural text answer first, then detected pieces card; tie/sunglasses as accessories, shoes as footwear. +- Bottom composer overlap: with no keyboard, keyboard open, long assistant response, and outfit card at the bottom, Save Look/Wear Today/Plan Today remain scrollable above the composer/dock on small and large iPhones. +- App background mid-stream: send a prompt, background/close mid-response, reopen. Expected: chat rehydrates from Firestore; completed final appears if written, otherwise pending response shows Retry. + +## Checks Run + +- `npx tsc --noEmit` - passed +- `npm run lint` - passed +- `npx expo-doctor` - ran, 17/18 passed; failed only the native-folder/app-config CNG warning +- `cd functions && npm run lint` - passed +- `cd functions && npm run build` - passed +- `git diff --check` - passed diff --git a/COST_AND_ABUSE_READINESS.md b/COST_AND_ABUSE_READINESS.md new file mode 100644 index 0000000..faf7022 --- /dev/null +++ b/COST_AND_ABUSE_READINESS.md @@ -0,0 +1,74 @@ +# AURA Cost And Abuse Readiness + +Audit date: 2026-05-11 + +## Current Controls + +| Area | Current control | Status | +|---|---|---| +| AURA chat | 10/min and 80/day per user | Good for small TestFlight, still costly if 50 active users all hit cap | +| Streaming AURA chat | Same `auraChat` limit | Good, but HTTP endpoint lacks App Check | +| Product link import/preview | 5/min and 30/day per user | Good for TestFlight | +| Image ingestion | 3/min and 25/day per user | Good for TestFlight | +| Outfit generation | 10/min and 100/day per user | Too high for external launch; okay for tiny internal group | +| Voice transcription | 6/min and 60/day per user | Good if feature is used | +| Live product search | Env daily limit default 20/day per user; disabled unless env enables provider | Good if disabled by default | +| Image size | Storage rules cap images at 10 MB; client compresses item/chat images | Good | +| Audio size | Storage rules and function cap audio at 10 MB | Good | +| Product URL fetch | Safe fetch timeout, content-type checks, 3 MB HTML cap in safeFetch | Good | +| Product search cache | Top-level `productSearchCache` with TTL setting | Good, but monitor cache growth | +| Account deletion | 3/day per user | Good | + +## Expensive Endpoints + +1. `askAuraStream` and `askAura`: OpenAI GPT-5.4, wardrobe context, possible vision inputs. +2. `ingestItemFromPhotos`: OpenAI vision, Sharp/background removal, Storage writes. +3. `previewProductLink`/`importProductLink`: external fetch, OpenAI image ranking/extraction, Storage/Firestore writes. +4. `generateAuraSwipeBatch`/`generateOutfitsV1`: OpenAI plus wardrobe reads. +5. `transcribeAuraAudio`: OpenAI transcription if wired. +6. `searchLiveProducts`: SerpApi paid search if enabled. + +## Risks + +- App Check missing means authenticated abuse can come from modified clients. +- `askAuraStream` is HTTP, so callable App Check enforcement will not protect it automatically. +- `outfitGeneration` daily cap of 100/user is high if a tester loops swipes. +- AURA chat cap of 80/day/user can become expensive with image-rich prompts. +- Stored chat history and wardrobe context can grow; backend slices history but Firestore storage still grows. +- Storage growth risk exists for repeated AURA attachments because attachment cleanup is account-level, not per-chat. +- Product link extraction logs and cached metadata should be watched for growth and privacy. +- Firebase reads from Calendar/Home may become hot if closet size grows, but acceptable for small TestFlight. + +## Recommended Small TestFlight Caps + +- User cap: 10 to 25 internal testers until Firebase config, App Check, and deletion QA are confirmed. +- AURA message cap: 30/day/user for internal TestFlight; raise only after monitoring. +- Product import cap: 10/day/user. +- Image upload/ingestion cap: 10/day/user. +- Outfit generation/swipe cap: 30/day/user. +- Voice transcription cap: 20/day/user if voice is enabled. +- Live product search: keep disabled unless explicitly testing shopping; if enabled, cap at 5/day/user. + +## Monitoring Metrics + +Watch daily during TestFlight: + +- Function invocations by endpoint. +- Function errors by endpoint and error code. +- OpenAI spend by model and feature. +- Firebase Storage bytes and object count under `users/`. +- Firestore reads/writes/deletes and top collections by volume. +- Rate-limit block counts. +- `askAuraStream` non-200 responses and aborted streams. +- Product link import failure rate by domain. +- Image ingestion failure rate. +- Account deletion success counts and any deletion errors. + +## Recommended Before External TestFlight + +1. Enable App Check for Firestore, Storage, and callable functions. +2. Add manual App Check verification to HTTP endpoints or convert streaming endpoint to callable-compatible approach. +3. Lower caps in `RATE_LIMITS` for external testing. +4. Add budget alerts in Google Cloud/OpenAI/SerpApi. +5. Add log-based metrics for expensive endpoint success/failure without logging prompts, URLs, or wardrobe details. +6. Add scheduled cleanup for stale AURA attachments and old failed drafts. diff --git a/GoogleService-Info.plist b/GoogleService-Info.plist new file mode 100644 index 0000000..0b2d715 --- /dev/null +++ b/GoogleService-Info.plist @@ -0,0 +1,36 @@ + + + + + CLIENT_ID + 1043970871162-4mbfs4taqvn9b81imga67hg6kn15k644.apps.googleusercontent.com + REVERSED_CLIENT_ID + com.googleusercontent.apps.1043970871162-4mbfs4taqvn9b81imga67hg6kn15k644 + ANDROID_CLIENT_ID + 1043970871162-metmeq51adnvabgt010mt2lhbvbthhio.apps.googleusercontent.com + API_KEY + AIzaSyBoHMpbfOeDplgjeqReR6NJ5ll_WTftYyU + GCM_SENDER_ID + 1043970871162 + PLIST_VERSION + 1 + BUNDLE_ID + com.kasat.aura + PROJECT_ID + closet-app-57146 + STORAGE_BUCKET + closet-app-57146.firebasestorage.app + IS_ADS_ENABLED + + IS_ANALYTICS_ENABLED + + IS_APPINVITE_ENABLED + + IS_GCM_ENABLED + + IS_SIGNIN_ENABLED + + GOOGLE_APP_ID + 1:1043970871162:ios:c757fdfe9dcbeb83ad50c3 + + \ No newline at end of file diff --git a/LAUNCH_PRIVACY_DATA_INVENTORY.md b/LAUNCH_PRIVACY_DATA_INVENTORY.md new file mode 100644 index 0000000..dfe3cb5 --- /dev/null +++ b/LAUNCH_PRIVACY_DATA_INVENTORY.md @@ -0,0 +1,81 @@ +# AURA Launch Privacy Data Inventory + +Audit date: 2026-05-11 + +Scope: repo evidence from the Expo app, Firebase rules, Firebase Functions, native config, and local environment key names. This is an engineering inventory, not final legal advice. + +## Standards Used + +- Firebase security checklist: https://firebase.google.com/support/guides/security-checklist +- Firebase App Check overview: https://firebase.google.com/docs/app-check +- Firebase API key guidance: https://firebase.google.com/docs/projects/api-keys +- Firebase Storage user security rules: https://firebase.google.com/docs/storage/security/user-securityBAK + +## Account Data + +| Data type | Source screen or flow | Storage or processor | Purpose | Retention and deletion | User deletion | App Store privacy disclosure | +|---|---|---|---|---|---|---| +| Email address | Email sign up/sign in in `app/(auth)/register.tsx` and `app/(auth)/login.tsx` | Firebase Auth; may also be visible through Auth user object | Account creation and sign in | Kept until account deletion | Deleted by `deleteAccountData` via Firebase Auth deletion | Yes: Contact Info, Email Address, linked to user | +| UID | Firebase Auth after sign in | Firebase Auth; Firestore path `users/{uid}`; Storage path `users/{uid}/...`; hashed UID in function rate limits | Access control, user data scoping, rate limiting | Kept until account deletion; hashed rate-limit docs deleted for known endpoints | Deleted by `deleteAccountData`; legacy `parseOutfitIntent` rate-limit doc is now included | Yes: User ID or Identifiers, linked to user | +| First/display name | Register, onboarding/profile/account screens | Firestore `users/{uid}` profile fields | Personalization and account display | Kept until account deletion | Deleted by `deleteAccountData` | Yes: Contact Info or Other User Content, linked to user | +| Profile photo | Profile account screen | Firebase Storage `users/{uid}/profile/avatar.jpg`; URL in Firestore/Auth profile fields if saved | Account personalization | Kept until account deletion | Deleted by account deletion storage prefix cleanup | Yes: User Content, Photos or Other User Content | + +## User Content + +| Data type | Source screen or flow | Storage or processor | Purpose | Retention and deletion | User deletion | App Store privacy disclosure | +|---|---|---|---|---|---|---| +| Clothing photos | Add item, closet quick add, AURA image attachments | Firebase Storage `users/{uid}/items/...`; temporary local file cache | Closet item creation, visual display, AI extraction | Stored until item/account deletion | Item deletion attempts owned Storage cleanup; account deletion removes `users/{uid}/` prefix | Yes: User Content, Photos, linked to user | +| Cutout/cleaned/normalized images | Native iOS background removal, server ingestion | Firebase Storage `users/{uid}/items/...cleaned.png`, `.normalized.png`, thumbs/crops | Display polished item images and AI ingestion | Stored until item/account deletion | Same as clothing photos | Yes: User Content, Photos | +| Item metadata | Manual add, AI ingestion, product link import | Firestore `users/{uid}/items/{itemId}` | Closet, laundry, styling, recommendations, planner | Stored until item/account deletion | Item delete and account delete | Yes: User Content or Other Data, linked to user | +| Product links and product metadata | Closet product link import, AURA chat product-link intent | Firestore item fields such as `productUrl`, `sourceUrl`, `linkMetadata`; Firebase Functions process URL; external merchant pages fetched server-side | Create clothing draft from a product URL | Stored with item until item/account deletion | Item/account deletion | Yes: User Content, Browsing/Search-like content depending App Store interpretation; needs owner/legal confirmation | +| Outfit plans | Calendar, Home, AURA actions, Wear Today | Firestore `users/{uid}/outfits/{dateKey}` | Daily planning and wear logging | Stored until user clears plan or account deletion | Clear plan, item/account deletion | Yes: User Content, linked to user | +| Saved looks and feedback | AURA chat, AURA swipe, Studio, profile My Looks | Firestore `users/{uid}/savedLooks`, `savedOutfits`, `outfitFeedback`, `styleEvents`, `assistantProfile` | Personalization and saved outfit history | Stored until deleted/account deletion | My Looks deletion and account deletion | Yes: User Content, Preferences, linked to user | +| AURA chat messages | AURA tab and chat drawer | Firestore `users/{uid}/aiChats/{chatId}/messages`; local MMKV cache | AI stylist conversation history and continuity | Stored until chat/account deletion; local cache cleared on sign out/account deletion | Chat delete and account deletion | Yes: User Content, Other User Content, linked to user | +| AURA image attachments | AURA chat image upload | Firebase Storage `users/{uid}/auraAttachments/...`; message metadata in Firestore | Image-aware styling, outfit analysis, add item from image | Stored until account deletion unless item draft consumes it | Account deletion | Yes: User Content, Photos | +| Voice transcripts | AURA composer voice dictation | Current composer uses native speech recognition to insert text into chat. Backend `transcribeAuraAudio` exists but no current call site was found. | Dictation into chat | Transcript becomes chat text if sent; temporary backend audio would be deleted in `finally` if endpoint is used | Chat/account deletion | Yes if enabled: Audio Data and User Content. Needs owner confirmation | + +## Device and Technical Data + +| Data type | Source screen or flow | Storage or processor | Purpose | Retention and deletion | User deletion | App Store privacy disclosure | +|---|---|---|---|---|---|---| +| Auth/session tokens | Firebase Auth SDK | Firebase Auth/AsyncStorage | Keep user signed in | Managed by Firebase Auth; removed on sign out | Sign out/account deletion | Usually not disclosed as collected data, but confirm | +| Local cache | Chat cache, weather cache, closet cache | MMKV `aura-storage` and AsyncStorage | Performance/offline UX | Until cleared or overwritten | `Storage.clearUserScopedData(uid)` clears user-scoped MMKV keys; weather cache is not UID-scoped | May not require disclosure unless tied to user | +| Location coordinates | Weather cards and calendar planning | Sent to Open-Meteo API request; city/weather stored locally | Weather-aware outfit planning | Weather cached locally for 30 minutes | User can deny permission; cache not explicitly cleared on account deletion if unscoped | Yes: Location, likely Precise Location if lat/lon sent | +| Calendar event titles/times | Home/calendar event cards | Read from device calendar; displayed in app. No direct Firestore storage found | Event-aware outfit planning | In memory during use | User can deny/revoke permission | Yes if collected/processed: Calendar or Other User Content. Needs owner/legal confirmation | +| Crash logs/analytics | No Crashlytics, Sentry, Segment, GA, or Firebase Analytics SDK found in package.json | Not confirmed in repo | Not applicable from code | Not confirmed | Not confirmed | NEEDS OWNER CONFIRMATION for App Store Connect | +| Device identifiers | Firebase SDKs may process installation/auth/device signals | Firebase | Auth/security/service operation | Managed by Firebase | Account deletion/sign out partly affects auth data | App Store answers need owner confirmation based on Firebase console and SDK behavior | + +## AI Processing + +| Data sent | Sent to | Code evidence | Purpose | Stored by AURA | Deletion | +|---|---|---|---|---|---| +| Chat prompt, recent history, compact profile, wardrobe context, required item IDs | OpenAI via Firebase Functions `askAura`/`askAuraStream` | `functions/src/askAura.ts`, `functions/src/askAuraStream.ts` | Generate AURA chat and outfit advice | Final chat stored in Firestore; OpenAI request not stored in repo code | Chat/account deletion for AURA storage; OpenAI retention must be reviewed in processor terms | +| Clothing image URLs | OpenAI vision via `ingestItemFromPhotos` | `functions/src/ingestItemFromPhotos.ts` | Extract item category, color, material, brand, details | Parsed metadata stored in Firestore; images stored in Storage | Item/account deletion | +| Product URL metadata and product images | Firebase Functions and OpenAI image candidate ranking/extraction | `functions/src/shared/productLinkExtractor.ts`, `auraUrlCandidatePreview.ts` | Build item draft from product link | Product metadata may be stored in item doc | Item/account deletion | +| Outfit photo attachments | OpenAI vision through AURA chat photo analysis | `functions/src/shared/auraOutfitPhotoAnalysis.ts` | Analyze worn outfit or add pieces | Output stored in chat if sent | Chat/account deletion | +| Voice audio | OpenAI transcription if dormant callable is wired | `functions/src/transcribeAuraAudio.ts`; no current client call found | Transcribe voice note | Audio temp file deleted after transcription; transcript returned | Transcript deletion if sent as chat | + +## Third Parties + +| Third party | Evidence | Data involved | Notes | +|---|---|---|---| +| Firebase / Google Cloud | Firebase SDKs, Auth, Firestore, Storage, Functions | Account data, UID, closet data, photos, chat, function logs, rate limits | Primary backend and storage processor | +| OpenAI | `openai` package and direct API calls in Functions | Chat text, wardrobe context, image URLs, product context, possible transcripts | Processor for AI responses. Owner must review current OpenAI API data terms | +| Apple | Apple Sign In, iOS permissions, TestFlight/App Store | Apple auth identifier, app metadata, TestFlight diagnostics if enabled | Owner must configure App Store privacy answers | +| Google Sign-In | `@react-native-google-signin/google-signin` | Google OAuth profile/email depending consent | Native Firebase/OAuth config currently mismatched and must be regenerated | +| SerpApi | `SERPAPI_API_KEY`, `searchLiveProducts` | Product search query and shopping results | Feature is gated by env flags | +| Skimlinks or affiliate provider | `SKIMLINKS_ID`, `wrapAffiliateLinks` | Product URLs for affiliate wrapping | Affiliate disclosure exists in shopping sheet; owner must confirm program and disclosure | +| Open-Meteo | Weather fetch URL in `useLocalWeather`/`weatherDaily` | Latitude/longitude and selected date | Not listed in original prompt but present in code | + +## Deletion Summary + +- In-app account deletion exists in `src/profile/screens.tsx` and calls `deleteAccountData`. +- Backend deletion removes Auth user, Firestore `users/{uid}` tree, Storage `users/{uid}/` files, hashed function rate-limit docs, and now the legacy `parseOutfitIntent` raw-UID rate-limit doc. +- Shared top-level caches such as `productSearchCache` are not user-specific and are not deleted by account deletion. +- Local user-scoped MMKV data is cleared on sign out/account deletion. Non-user-scoped weather cache is not explicitly cleared. + +## Human Review Required + +- Final Privacy Policy, Terms, App Store privacy labels, age rating, and AI disclosure. +- Whether Firebase/OpenAI/SerpApi/affiliate provider processor terms match AURA's intended launch. +- Whether TestFlight diagnostics, Apple crash data, or Firebase console features collect diagnostics not visible in repo code. diff --git a/PRIVACY_POLICY_DRAFT.md b/PRIVACY_POLICY_DRAFT.md new file mode 100644 index 0000000..a16e05c --- /dev/null +++ b/PRIVACY_POLICY_DRAFT.md @@ -0,0 +1,57 @@ +# Privacy Policy Draft + +Draft only. Must be reviewed and customized by the app owner/legal professional before launch. + +Effective date: [insert date] + +AURA: AI Personal Stylist ("AURA", "we", "us") helps you create a digital wardrobe, plan outfits, and receive AI styling suggestions based on the clothing items, preferences, and context you choose to provide. + +## Information We Collect + +We collect account information such as your email address, user ID, display name, and optional profile photo. We collect wardrobe content you add, including clothing photos, edited cutout images, item names, brands, colors, categories, sizes, notes, prices, product links, outfit plans, saved looks, laundry status, wear history, and AURA chat messages. + +If you grant permission, AURA may use your camera or photo library so you can add clothing photos. If you grant location permission, AURA may use your approximate location while you use the app to show local weather for outfit planning. If you grant calendar permission, AURA may read upcoming calendar event titles and times to help plan outfits around your day. + +AURA includes voice dictation features. Voice input may be processed by the device speech recognition service and, if enabled in the backend, may be sent to our Firebase Functions for transcription through OpenAI. The app owner must confirm the final voice behavior before launch. + +## AI Processing + +AURA uses Firebase Functions to call OpenAI for styling features. Depending on the feature, we may send OpenAI your chat prompt, recent chat context, wardrobe item metadata, style preferences, clothing image URLs, product link metadata, or outfit photos. We use this information to generate outfit suggestions, classify clothing items, extract product details, and respond to your styling requests. + +We do not put the OpenAI API key in the mobile app. AI requests are made from Firebase Functions. + +## Product Link Import and Shopping Features + +If you paste a product link, AURA may fetch the product page, read product metadata, analyze product images, and create a draft wardrobe item for your review. Some shopping or product links may be routed through affiliate providers, such as Skimlinks, if that feature is enabled. AURA may earn a commission from some shopping links. + +## Where Data Is Stored + +AURA uses Firebase Authentication for accounts, Cloud Firestore for wardrobe/profile/chat/outfit data, Cloud Storage for photos and audio files, and Firebase Functions for backend processing. AURA may also use OpenAI, Apple, Google Sign-In, SerpApi, affiliate providers, and weather APIs such as Open-Meteo depending on enabled features. + +## Retention and Deletion + +We keep your account and wardrobe data while your account is active. You can delete individual closet items and chats in the app. You can delete your account in the app. Account deletion is intended to delete your Firebase Auth account, Firestore user data, Storage files under your user folder, chats, outfits, saved looks, and related metadata. + +Some shared caches or provider logs may not be deleted by the in-app deletion flow. The app owner must confirm provider retention periods before launch. + +## Your Choices + +You can decline camera, photo library, location, calendar, microphone, and speech permissions. Some features may not work without those permissions. You can sign out, delete chats/items, export certain profile data, and request account deletion in the app. + +## Children + +AURA is not intended for children under 13, or under the minimum digital consent age in your region. If you believe a child has provided personal information, contact us so we can delete it. + +## Security + +We use Firebase Authentication, user-scoped Firestore and Storage rules, and server-side API keys in Firebase Functions. No system is perfectly secure. You should not upload content you do not want processed for styling features. + +## Changes + +We may update this Privacy Policy from time to time. We will update the effective date and provide notice when required. + +## Contact + +Contact: [insert support email] + +Privacy Policy URL: [insert URL] diff --git a/README.md b/README.md index 78f4065..fca29c6 100644 --- a/README.md +++ b/README.md @@ -1,14 +1,26 @@ -# Wardrobe AI +# AURA -**Wardrobe AI** is an AI-powered mobile application that helps users digitize their wardrobe and receive outfit recommendations based on their clothing inventory, occasion, and context. +**AURA: AI Personal Stylist** is a KASAT Labs mobile application that helps users digitize their wardrobe and receive outfit recommendations based on their clothing inventory, occasion, and context. + +## Performance Metrics + +- Real-time recommendation engine: ~1.2ms p50 ranking latency across 1000+ outfit combinations +- AURA AI assistant response latency: ~0.9s end-to-end +- Item creation time reduced by ~6× through automated image ingestion and attribute extraction +- Feature extraction pipeline generates 34 structured garment attributes per item +- Scales to ~800-item datasets with stable, near-constant inference performance The app combines **computer vision, mobile development, and AI intent parsing** to create a personal styling assistant that suggests outfits directly from a user's closet. +A KASAT product. + --- ## Demo -** +### Screenshots / Demo + +Screenshots can be added under `docs/screenshots/` when release images are ready. Example flow: @@ -42,7 +54,7 @@ Example flow: sequenceDiagram autonumber participant U as User - participant App as Wardrobe AI App + participant App as AURA App participant Fn as Cloud Function (parseOutfitIntent) participant DB as Firestore U->>App: "date night outfit, it's cold" @@ -91,6 +103,10 @@ sequenceDiagram ### AI / Vision - iOS Vision Framework (`VNGenerateForegroundInstanceMaskRequest`) - AI intent parsing for outfit requests +- LLM-driven contextual reasoning +- AURA — real-time AI styling assistant +- RAG-style structured context pipelines +- Prompt engineering for outfit intent parsing --- ## System Architecture @@ -187,31 +203,91 @@ npm install ### Run the App ``` -npx expo start +npx expo start --dev-client ``` -Then open the project using: +Then open the project using a development client or production build: - iOS Simulator - Android Emulator -- Expo Go +- TestFlight / App Store build +- Android release/internal build + +Expo Go is not expected to work for AURA because the app includes custom and +native modules such as `expo-vision-bg`, `@six33/react-native-bg-removal`, +`@react-native-google-signin/google-signin`, `@react-native-voice/voice`, +`react-native-mmkv`, `@d11/react-native-fast-image`, `@shopify/react-native-skia`, +`react-native-reanimated`, and `lottie-react-native`. --- ## Environment Setup -This project requires Firebase configuration. +AURA requires client environment values, native Firebase config files, and +Firebase Function secrets. Do not commit real secrets. -Create environment variables for: +Client `.env` / EAS environment values: ``` -FIREBASE_API_KEY -FIREBASE_AUTH_DOMAIN -FIREBASE_PROJECT_ID -FIREBASE_STORAGE_BUCKET -FIREBASE_MESSAGING_SENDER_ID -FIREBASE_APP_ID +EXPO_PUBLIC_FIREBASE_API_KEY +EXPO_PUBLIC_FIREBASE_AUTH_DOMAIN +EXPO_PUBLIC_FIREBASE_PROJECT_ID +EXPO_PUBLIC_FIREBASE_STORAGE_BUCKET +EXPO_PUBLIC_FIREBASE_MESSAGING_SENDER_ID +EXPO_PUBLIC_FIREBASE_APP_ID +EXPO_PUBLIC_GOOGLE_WEB_CLIENT_ID +EXPO_PUBLIC_GOOGLE_IOS_CLIENT_ID +EXPO_PUBLIC_AURA_VOICE_ENABLED +EXPO_PUBLIC_LIVE_PRODUCT_SEARCH_ENABLED +EXPO_PUBLIC_AFFILIATE_SHOPPING_ENABLED +``` + +Native Firebase files: + +``` +GoogleService-Info.plist +google-services.json +``` + +These files must match the configured app identifiers in `app.json` +(`com.kasat.aura` for iOS and Android). + +Firebase Function secret: + ``` +OPENAI_API_KEY +``` + +Set this through Firebase Secret Manager for deployed functions. It is required +for AURA chat, streaming chat, product-link preview/import, photo ingestion, and +voice transcription. + +Optional product search / shopping configuration: + +``` +PRODUCT_SEARCH_ENABLED +PRODUCT_SEARCH_PROVIDER +SERPAPI_API_KEY +PRODUCT_SEARCH_CACHE_TTL_HOURS +PRODUCT_SEARCH_DAILY_LIMIT +PRODUCT_SEARCH_COUNTRY +PRODUCT_SEARCH_LANGUAGE +MAX_LIVE_SEARCH_RESULTS +SKIMLINKS_ID +``` + +If live product search is disabled or the provider key is missing, AURA should +fall back to non-live shopping surfaces. If `SKIMLINKS_ID` is missing, affiliate +wrapping returns the original merchant URL. + +Developer script-only values: + +``` +FIREBASE_CLIENT_ID +FIREBASE_CLIENT_SECRET +``` + +Cloud Functions run on Node.js 22. --- @@ -237,7 +313,7 @@ FIREBASE_APP_ID Most wardrobe apps only store clothing items. -**Wardrobe AI focuses on building an intelligent wardrobe assistant that understands context and generates outfit suggestions using AI.** +**AURA focuses on building an intelligent wardrobe assistant that understands context and generates outfit suggestions using AI.** This project explores the intersection of: diff --git a/SECURITY_SECRETS_AUDIT.md b/SECURITY_SECRETS_AUDIT.md new file mode 100644 index 0000000..412f10a --- /dev/null +++ b/SECURITY_SECRETS_AUDIT.md @@ -0,0 +1,70 @@ +# AURA Security And Secrets Audit + +Audit date: 2026-05-11 + +Standards used: Firebase production rules, least privilege, API key restrictions, App Check, backend key protection, safe URL fetching, abuse controls, and deletion completeness. + +## Summary + +No committed OpenAI key, Firebase private key, service account JSON, hardcoded bearer token, or private key was found in tracked files. Firebase client API keys exist in ignored local config/env files; Firebase says these keys are not secrets for Firebase services, but they should still be restricted in Google Cloud. + +## Findings + +| Severity | Finding | File/path | Why it matters | Exact fix | +|---|---|---|---|---| +| Blocker | Firebase iOS native config is for old bundle `com.athfan.AURA`, not `com.kasat.aura` | `GoogleService-Info.plist`; `ios/AURA/GoogleService-Info.plist` | Firebase Auth/Google Sign-In/native services may fail or attach to the wrong registered app | In Firebase Console, add/download iOS app config for `com.kasat.aura`; replace both plist files; update iOS OAuth client env | +| Blocker | Firebase Android config is for old package `com.athfan.aura`; it also references `com.athfan.wardrobeai` OAuth | `google-services.json`; `android/app/google-services.json` | Android Firebase/Google Sign-In would be wrong if Android is built | Download `google-services.json` for `com.kasat.aura`; replace both files | +| Blocker | Firebase config files are ignored/untracked while app config references them | `.gitignore`, `app.json`, Firebase config files | EAS cloud builds may not receive required native Firebase config files | After regenerating correct configs, commit them intentionally or provide them through EAS secret file handling | +| High | App Check is not configured or enforced | No `initializeAppCheck`, no `enforceAppCheck`, no `consumeAppCheckToken` | Authenticated abuse from modified clients remains possible, especially expensive AI endpoints | Add Firebase App Check for iOS App Attest/DeviceCheck; enforce for callable functions and Firebase services after testing | +| High | Expensive `askAuraStream` is an HTTP `onRequest` endpoint with manual ID token verification but no App Check | `functions/src/askAuraStream.ts` | App Check callable protection does not automatically cover raw HTTP functions | Either convert to callable/streaming callable when viable, or manually verify App Check token for the HTTP endpoint | +| Medium | Local `.env` and Firebase config files exist but are ignored | `.env`, `.env.local`, `GoogleService-Info.plist`, `google-services.json` | Local secrets/config can leak outside git; Firebase API keys need restrictions | Keep ignored; rotate/restrict keys if shared; never commit service account JSON or OpenAI/SERPAPI keys | +| Medium | Backend logs still include redacted product URL paths and product metadata summaries | `functions/src/shared/productLinkExtractor.ts`, `auraUrlCandidatePreview.ts`, `productUrlMetadata.ts` | Redacted paths can still reveal product IDs or shopping behavior | For external launch, reduce to domain/count/status only and set short log retention | +| Medium | Function logs previously included wardrobe gap arrays and footwear metadata; reduced in this audit | `functions/src/askAura.ts`, `functions/src/askAuraStream.ts` | Wardrobe metadata in logs is personal styling data | Keep aggregate-only logs; verify production logs after QA | +| Medium | `outfitChatV1` stores user messages and debug constraints in Firestore | `functions/src/outfitChatV1.ts` | Expected product behavior, but debug fields can increase stored personal data | Confirm this legacy chat endpoint is used; remove debug fields or cap retention if not needed | +| Low | Legacy `parseOutfitIntent` rate-limit doc was not deleted on account deletion; fixed in this audit | `functions/src/deleteAccountData.ts`, `functions/src/index.ts` | Raw UID rate-limit metadata could remain after deletion | Patched account deletion to delete `functionRateLimits/parseOutfitIntent/users/{uid}` | +| Low | `functions/node_modules` has extraneous package and dry-run showed duplicate libvips Objective-C class warning | `functions/node_modules`, deploy dry-run output | Could cause local tooling/runtime instability | Run clean install in functions: `rm -rf functions/node_modules functions/package-lock.json` only if intentionally refreshing lock, then `npm install`; do not do this casually during release | + +## Rules Audit + +### Firestore + +- PASS: Default wildcard deny rules exist. +- PASS: `users/{uid}` reads/writes are owner-scoped. +- PASS: `items`, `outfits`, `threads/messages`, `aiChats/messages`, `styleEvents`, `savedLooks`, `auraLooks`, `savedOutfits`, `outfitFeedback`, and `commerceEvents` are scoped to `request.auth.uid == uid`. +- PASS: No public wildcard write rule found. +- RISK: `assistantProfile`, `profile`, `ai`, `auraLooks`, `savedOutfits`, and `outfitFeedback` allow owner writes with limited or generic validation. Acceptable for TestFlight, but harden schemas before public launch. +- NOTE: `allow delete: if false` on root `users/{uid}` is fine because account deletion uses Admin SDK. + +### Storage + +- PASS: `users/{uid}/{allPaths=**}` is owner-scoped. +- PASS: Image uploads are limited to `image/*` and 10 MB. +- PASS: Temporary transcription audio is owner-scoped and limited to 10 MB audio/video/mp4. +- PASS: Final wildcard denies all other paths. + +## Endpoint/Auth Audit + +| Endpoint | Type | Auth | Rate limit | Notes | +|---|---|---|---|---| +| `askAura` | Callable | Required | `auraChat`: 10/min, 80/day | OpenAI key server-side | +| `askAuraStream` | HTTP onRequest | Bearer Firebase ID token | `auraChat`: 10/min, 80/day | Needs App Check/manual App Check | +| `generateAuraSwipeBatch` | Callable | Required | `outfitGeneration`: 10/min, 100/day | Uses OpenAI | +| `generateOutfitsV1` | Callable | Required | `outfitGeneration`: 10/min, 100/day | Uses OpenAI | +| `outfitChatV1` | Callable | Required | `auraChat` or endpoint-specific logic in file | Legacy storage/debug should be reviewed | +| `previewProductLink` | Callable | Required | `productLink`: 5/min, 30/day | Uses safe URL fetch/extraction | +| `importProductLink` | Callable | Required | `productLink`: 5/min, 30/day | Creates item draft | +| `ingestItemFromPhotos` | Firestore trigger | Admin trigger | `imageIngestion`: 3/min, 25/day | Triggered from item writes | +| `transcribeAuraAudio` | Callable | Required | `voiceTranscription`: 6/min, 60/day | No current client call found | +| `searchLiveProducts` | Callable | Required | Env daily limit, default 20/day | Gated by `PRODUCT_SEARCH_ENABLED=true` | +| `wrapAffiliateLinks` | Callable | Not expensive | No user rate limit found | Add simple auth/rate limit if abused | +| `parseOutfitIntent` | HTTP onRequest | Bearer Firebase ID token | 20/min | Legacy endpoint; consider removal or App Check | + +## SSRF/Product URL Import + +- PASS: `safeFetch` rejects non-http(s), localhost, private/internal IPs, oversized responses, bad content types, and too many redirects. +- PASS: Product image URLs are also validated. +- RISK: DNS can change between validation and fetch in any SSRF design. For public launch, consider resolving and fetching by validated IP with Host header or using a hardened fetch proxy. + +## Secrets Search Terms Covered + +Searched for `OPENAI_API_KEY`, `FIREBASE_PRIVATE_KEY`, `SERPAPI`, API keys, bearer tokens, service account JSON, private keys, debug bypasses, unsafe eval/dynamic code, local URLs, and SSRF-sensitive fetches. diff --git a/TERMS_OF_USE_DRAFT.md b/TERMS_OF_USE_DRAFT.md new file mode 100644 index 0000000..6b70491 --- /dev/null +++ b/TERMS_OF_USE_DRAFT.md @@ -0,0 +1,55 @@ +# Terms Of Use Draft + +Draft only. Must be reviewed and customized by the app owner/legal professional before launch. + +Effective date: [insert date] + +These Terms govern your use of AURA: AI Personal Stylist ("AURA", "we", "us"). By using AURA, you agree to these Terms. + +## Use Of AURA + +AURA helps you organize wardrobe items, plan outfits, and receive AI-generated styling suggestions. You are responsible for the information, photos, links, and other content you add to the app. + +## AI Styling Disclaimer + +AURA uses AI systems to generate suggestions. AI output may be inaccurate, incomplete, repetitive, or inappropriate for your circumstances. You should use your own judgment before wearing an outfit, buying an item, following care guidance, or relying on any recommendation. + +## Product And Shopping Disclaimer + +AURA may display product information, prices, availability, shopping links, or affiliate links. Product data may be outdated or incorrect. AURA does not guarantee product availability, pricing, sizing, quality, merchant policies, or delivery. Some links may earn AURA a commission. + +## User Content + +You keep ownership of the photos, wardrobe data, messages, and other content you provide. You grant AURA permission to store, process, display, and analyze that content to operate and improve the app's styling features. + +You represent that you have the rights needed to upload your content and that your content does not violate another person's rights. + +## Acceptable Use + +Do not use AURA to upload illegal content, infringe intellectual property rights, harass others, attempt to reverse engineer the service, bypass rate limits, scrape the backend, attack Firebase/OpenAI systems, or use the app in a way that harms the service or other users. + +## Accounts + +You are responsible for keeping your account credentials secure. We may suspend or terminate accounts that violate these Terms, create security risk, or abuse paid or rate-limited services. + +## Account Deletion + +You may request account deletion in the app. Deletion is intended to remove your Firebase Auth account, Firestore user data, Storage files, chats, outfits, saved looks, and related metadata. Some provider logs, backups, or shared caches may remain for limited periods as permitted by law and provider policies. + +## No Warranty + +AURA is provided "as is" and "as available." We do not guarantee that the app will be uninterrupted, secure, accurate, or error-free. + +## Limitation Of Liability + +To the maximum extent permitted by law, AURA and its owner will not be liable for indirect, incidental, special, consequential, exemplary, or punitive damages, or for lost profits, data loss, wardrobe decisions, purchases, or reliance on AI-generated suggestions. + +## Changes + +We may update these Terms from time to time. Continued use of AURA after changes means you accept the updated Terms. + +## Contact + +Contact: [insert support email] + +Terms URL: [insert URL] diff --git a/TESTFLIGHT_ENV_FIX.md b/TESTFLIGHT_ENV_FIX.md new file mode 100644 index 0000000..db293fe --- /dev/null +++ b/TESTFLIGHT_ENV_FIX.md @@ -0,0 +1,88 @@ +# TestFlight Env Fix + +## Root Cause + +TestFlight Build 1 crashed on launch because Firebase Auth received an invalid or missing API key: + +`FirebaseError: Firebase: Error (auth/invalid-api-key)` + +The app initializes Firebase from `process.env.EXPO_PUBLIC_*` values in the JS bundle. EAS reported that the production environment had no variables, so the TestFlight bundle was built without the required Firebase config. + +## Required EAS Production Env Vars + +Required for app launch and Firebase Auth startup: + +- `EXPO_PUBLIC_FIREBASE_API_KEY` +- `EXPO_PUBLIC_FIREBASE_AUTH_DOMAIN` +- `EXPO_PUBLIC_FIREBASE_PROJECT_ID` +- `EXPO_PUBLIC_FIREBASE_STORAGE_BUCKET` +- `EXPO_PUBLIC_FIREBASE_MESSAGING_SENDER_ID` +- `EXPO_PUBLIC_FIREBASE_APP_ID` + +Required for Google sign-in: + +- `EXPO_PUBLIC_GOOGLE_WEB_CLIENT_ID` +- `EXPO_PUBLIC_GOOGLE_IOS_CLIENT_ID` + +Not required by the current client bundle: + +- `EXPO_PUBLIC_OUTFIT_INTENT_URL` is not referenced in the app code. Do not add it unless a legacy client endpoint is reintroduced. + +## Commands + +Run these from the `closet/` directory. Source local values first so `.env.local` can override `.env`: + +```sh +set -a +[ -f .env ] && source .env +[ -f .env.local ] && source .env.local +set +a +``` + +Create the production EAS environment variables: + +```sh +eas env:create --environment production --name EXPO_PUBLIC_FIREBASE_API_KEY --value "$EXPO_PUBLIC_FIREBASE_API_KEY" --visibility plaintext +eas env:create --environment production --name EXPO_PUBLIC_FIREBASE_AUTH_DOMAIN --value "$EXPO_PUBLIC_FIREBASE_AUTH_DOMAIN" --visibility plaintext +eas env:create --environment production --name EXPO_PUBLIC_FIREBASE_PROJECT_ID --value "$EXPO_PUBLIC_FIREBASE_PROJECT_ID" --visibility plaintext +eas env:create --environment production --name EXPO_PUBLIC_FIREBASE_STORAGE_BUCKET --value "$EXPO_PUBLIC_FIREBASE_STORAGE_BUCKET" --visibility plaintext +eas env:create --environment production --name EXPO_PUBLIC_FIREBASE_MESSAGING_SENDER_ID --value "$EXPO_PUBLIC_FIREBASE_MESSAGING_SENDER_ID" --visibility plaintext +eas env:create --environment production --name EXPO_PUBLIC_FIREBASE_APP_ID --value "$EXPO_PUBLIC_FIREBASE_APP_ID" --visibility plaintext +eas env:create --environment production --name EXPO_PUBLIC_GOOGLE_WEB_CLIENT_ID --value "$EXPO_PUBLIC_GOOGLE_WEB_CLIENT_ID" --visibility plaintext +eas env:create --environment production --name EXPO_PUBLIC_GOOGLE_IOS_CLIENT_ID --value "$EXPO_PUBLIC_GOOGLE_IOS_CLIENT_ID" --visibility plaintext +``` + +Confirm the production environment before rebuilding: + +```sh +eas env:list --environment production +``` + +Confirm those EAS values are readable by a process using the production EAS environment: + +```sh +eas env:exec --environment production 'npm run check:eas-env' +``` + +With `eas-cli/18.11.0`, `env:exec` uses the environment as a positional argument: + +```sh +eas env:exec production 'npm run check:eas-env' +``` + +`eas env:list` proves the variables exist in EAS. Because these values are plaintext/public, that command may print the actual values; do not paste its raw output into tickets, logs, or commits. `eas env:exec` with `npm run check:eas-env` proves they are readable by a process launched with that EAS environment and prints only redacted diagnostics. The actual TestFlight binary still requires a rebuild because `EXPO_PUBLIC_*` values are bundled into the JavaScript app at build time. + +Expo only inlines `EXPO_PUBLIC_*` variables when the app code references them with static dot notation, such as `process.env.EXPO_PUBLIC_FIREBASE_API_KEY`. Dynamic bracket access such as `process.env[name]` is not inlined into the production bundle. + +After adding or changing these EAS env vars, create a new build; installing an old binary will not pick them up. + +```sh +eas build --profile preview --platform ios +``` + +## Safety Notes + +- Do not commit `.env`, `.env.local`, `credentials.json`, `.p12`, or provisioning profile files. +- Firebase client API keys are not service-account secrets, but they should still be restricted in Google Cloud to the intended Firebase APIs and app identifiers. +- The app now validates required Firebase config before initialization and renders a production-safe configuration error instead of hard-crashing when the bundle is missing required values. +- Build 3 includes temporary redacted diagnostics on the configuration error screen. Remove them after confirming the production build receives the bundled Firebase values. diff --git a/TESTFLIGHT_SUBMISSION_CHECKLIST.md b/TESTFLIGHT_SUBMISSION_CHECKLIST.md new file mode 100644 index 0000000..9bcb8f8 --- /dev/null +++ b/TESTFLIGHT_SUBMISSION_CHECKLIST.md @@ -0,0 +1,53 @@ +# AURA TestFlight Submission Checklist + +Audit date: 2026-05-11 + +## Repo/Config Status + +| Item | Status | Evidence | Required action | +|---|---|---|---| +| App name | PASS | `app.json` name `AURA`; native display name `AURA` | Confirm App Store listing name remains `AURA: AI Personal Stylist` | +| Bundle ID | PARTIAL | Expo/iOS project use `com.kasat.aura` | Regenerate Firebase iOS config for this bundle | +| Android package | PARTIAL | Expo/Android Gradle use `com.kasat.aura` | Regenerate Firebase Android config for this package | +| Version/build number | FIXED | `version` 1.0.0, iOS `buildNumber` 1, Android `versionCode` 1 | Increment for every TestFlight upload | +| Icons | PASS WITH MANUAL QA | App icons referenced in `assets/` | Verify actual icon appearance in TestFlight | +| Splash screen | PASS WITH MANUAL QA | `expo-splash-screen` configured black background and splash icon | Verify first launch on device | +| Camera permission text | PASS | Specific AURA text in `app.json`/Info.plist | Test prompt on device | +| Photo library permission text | PASS | Specific AURA text in `app.json`/Info.plist | Test limited photo access | +| Microphone/speech text | PASS | AURA voice dictation text present | Confirm voice feature is intended for launch | +| Location text | FIXED | More specific foreground/location strings added | Verify no unwanted Always prompt appears | +| Calendar/reminders text | FIXED | Calendar/reminders strings made less vague | Confirm reminders permission is actually required | +| Support URL | MISSING | No repo/App Store metadata found | Add in App Store Connect | +| Privacy Policy URL | MISSING | Draft file created, no hosted URL | Host reviewed policy before external testing | +| Terms URL | MISSING | Draft file created, no hosted URL | Host reviewed terms before external testing | +| App category | MISSING | Not represented in repo | Set in App Store Connect | +| Minimum iOS version | PASS | Expo build properties deployment target 17.0 | Confirm iOS 17 minimum is acceptable | +| EAS build profiles | FIXED | `eas.json` added with development/preview/production | Run `eas build --profile preview --platform ios` | +| Firebase iOS config bundle match | FAIL | `GoogleService-Info.plist` says `com.athfan.AURA` | Download new `GoogleService-Info.plist` for `com.kasat.aura` | +| Firebase Android config package match | FAIL | `google-services.json` says `com.athfan.aura` | Download new `google-services.json` for `com.kasat.aura` | +| Firebase config included in EAS build | FAIL/RISK | Firebase config files are ignored/untracked by git | After replacing with correct configs, either commit them intentionally or configure EAS secret file handling so cloud builds receive them | +| Google Sign-In OAuth | FAIL/RISK | Reversed client IDs are tied to old Firebase config | Regenerate OAuth clients and `.env.local` values | +| No development-only endpoints | PARTIAL | No localhost client endpoints found; `parseOutfitIntent` HTTP endpoint exists | Decide whether `parseOutfitIntent` is still needed | +| No test keys | PASS FROM TRACKED CODE | No service account/private key tracked | Firebase API keys exist in ignored local files; restrict in Google Cloud | +| No hardcoded local URLs | PASS | `localhost` only blocked in server safe fetch or native dev config | None | +| No debug screens exposed | PASS/RISK | Debug logs mostly gated; AURA training route exists as product feature | Manually inspect route access | +| No sensitive console logs | PARTIAL/FIXED | Backend wardrobe context logs reduced; client debug logs gated by `__DEV__`/env | Review Functions logs after QA | +| App Check | FAIL/RISK | No App Check SDK or `enforceAppCheck` found | Configure before external TestFlight if possible | +| Privacy manifest | RISK | `ios/AURA/PrivacyInfo.xcprivacy` collected data array is empty | Legal/owner review required | + +## Required Before Internal TestFlight + +1. Register Firebase iOS app for `com.kasat.aura` and replace both root and native `GoogleService-Info.plist`. +2. Register Firebase Android app for `com.kasat.aura` and replace both root and native `google-services.json` if Android is kept. +3. Make sure the corrected Firebase config files are available to EAS cloud builds. +4. Verify Google Sign-In iOS/web client IDs in `.env.local`. +5. Run `eas build --profile preview --platform ios`. +6. Smoke test on a physical iPhone. + +## Required Before External TestFlight + +1. Host reviewed Privacy Policy and Terms. +2. Complete App Store Connect privacy answers. +3. Configure App Check or document why it is deferred for small internal-only launch. +4. Confirm deletion behavior from Firebase Console. +5. Set support URL, category, age rating, test instructions, and export compliance. diff --git a/TESTFLIGHT_UX_QA_CHECKLIST.md b/TESTFLIGHT_UX_QA_CHECKLIST.md new file mode 100644 index 0000000..13119f6 --- /dev/null +++ b/TESTFLIGHT_UX_QA_CHECKLIST.md @@ -0,0 +1,68 @@ +# AURA TestFlight UX QA Checklist + +Audit date: 2026-05-11 + +This checklist separates what was verified from code from what must be tested on a real iPhone TestFlight build. + +## Code-Verified Coverage + +- Onboarding/auth routes exist: `app/(auth)/*`, `app/(onboarding)/index.tsx`. +- Add item wizard exists for create/edit/duplicate: `app/(tabs)/add.tsx`, `src/addItem/AddItemWizard.tsx`. +- Manual details are edited in the add/edit wizard, not inline on item detail. Item detail routes "Edit" to `/(tabs)/add`. +- Photo add, native iOS background removal, refinement, and upload flows exist in `src/addItem/hooks/usePhotoStep.ts` and `modules/expo-vision-bg`. +- Product link import exists in Closet with preview/edit/add states. +- Closet grid, processing/failed states, and empty states exist in `app/(tabs)/closet.tsx`. +- AURA chat clears composer before sending, reserves one streaming message ID, replaces that message on final, and writes failures as system messages. +- Daily outfit planning and Wear Today exist in Home, Calendar, Today, and `src/lib/wearOutfit.ts`. +- Account deletion exists and calls backend `deleteAccountData`. + +## Real iPhone QA Script + +Use a fresh TestFlight install, not Expo Go. Run on at least one current iPhone and one older supported iPhone if available. + +| Pass | Severity if failed | Flow | Steps | Expected result | +|---|---|---|---|---| +| [ ] | Blocker | Install/open | Install TestFlight build, launch cold | Splash shows AURA, app does not crash, initial auth/onboarding state is correct | +| [ ] | Blocker | Sign up | Create email/password account with first name | Account is created, onboarding starts, no Firebase permission error | +| [ ] | High | Sign in/out | Sign out, sign back in with same account | Closet/profile/chats reload correctly | +| [ ] | High | Apple Sign In | Sign in with Apple on iPhone | Native sheet works and Firebase account links/creates | +| [ ] | High | Google Sign-In | Sign in with Google | Must work only after Firebase/OAuth configs are regenerated for `com.kasat.aura` | +| [ ] | High | Onboarding | Complete all onboarding screens | User lands in main app and preferences persist | +| [ ] | Blocker | Add item manually | Closet > add item > enter photo, brand, name, category, color, save | Item appears in closet and item detail | +| [ ] | High | Add from photo | Take a garment photo, allow camera, save | Upload completes, AI autofill runs or fails gracefully, no stuck spinner | +| [ ] | High | Photo library | Add from photo library, allow limited photo access | Picker works with limited access, photo uploads | +| [ ] | High | Background removal | Use iOS cutout/refine controls | Cutout renders, refine applies, original fallback is available on failure | +| [ ] | High | Failed upload | Enable poor network/airplane mode during photo upload | User sees retryable failure, draft is not silently lost | +| [ ] | High | Product link import | Paste Nike/Zara/H&M/Amazon product URL, Search, edit preview, Add to Wardrobe | Preview appears or recovery UI appears; saved item opens for review | +| [ ] | Medium | Product link failure | Paste invalid/local/private URL | App rejects it safely and suggests retry/screenshot/manual add | +| [ ] | Blocker | Closet grid | Add 0, 1, 5, 30 items | Empty state, few-item state, and scrolling grid are usable | +| [ ] | High | Item detail | Open item, edit, delete, mark worn, laundry actions | Edit opens edit wizard; delete removes doc/images; status changes persist | +| [ ] | High | AURA basic chat | Send "style me today" | Composer clears immediately, streaming appears once, final response replaces stream | +| [ ] | High | AURA multiline | Type a long multiline prompt | Input grows up to max height and send button stays reachable | +| [ ] | High | AURA failed stream | Turn network off mid-stream | No duplicate assistant messages; error appears; retry works | +| [ ] | High | AURA outfit cards | Ask for "3 outfits for dinner" | Structured cards render, buttons are tappable, composer does not cover cards/tab bar | +| [ ] | High | AURA image attach | Attach item/outfit photo and ask AURA to analyze/add | Attachment uploads, backend validates owned Storage path, result appears | +| [ ] | Medium | Voice dictation | Tap mic, speak, stop, send | Speech text appears or friendly unavailable/permission message appears | +| [ ] | High | Plan Today | From AURA card or Calendar, plan outfit for today | Firestore `users/{uid}/outfits/{dateKey}` updates and Home/Today reflect it | +| [ ] | High | Wear Today | Tap Wear Today from Home/Calendar/item | Wear counts update once; repeated tap says already marked worn | +| [ ] | Medium | Calendar date picker | Open Calendar, pick another date, copy/clear/swap plan | Date selection and swap sheet work without layout overlap | +| [ ] | High | Offline app start | Launch with no network after prior session | App does not crash; cached/local states are graceful | +| [ ] | High | Empty AURA | New account with zero closet items asks for outfit | AURA explains limits without crashing; no fake owned items | +| [ ] | High | Few closet items | Account with 1 top only asks for full outfit | AURA handles missing categories and shows suggestions or clear limitation | +| [ ] | Blocker | Account deletion | Delete account from Profile > Account | User is signed out; Auth user, Firestore `users/{uid}`, Storage `users/{uid}/` files are gone | +| [ ] | High | Reinstall after deletion | Delete app, reinstall, try old account | Old account cannot access deleted data | + +## Manual Firebase Console Checks + +- Confirm the TestFlight bundle uses Firebase iOS app registered for `com.kasat.aura`. +- Confirm Firestore has no remaining `users/{uid}` tree after account deletion. +- Confirm Storage has no `users/{uid}/` objects after account deletion. +- Confirm Functions logs do not include raw prompts, emails, full image URLs, or full product URLs during the QA run. +- Confirm rate limits trigger with repeated AURA chat/product link/image ingestion attempts. + +## Known UX Risks To Watch + +- Product link import depends heavily on retailer page markup and network behavior. +- AURA chat has multiple fallback routes; verify no duplicate final messages under retry/poor network. +- Calendar and weather permissions must be tested on real iOS because simulator permission behavior is not enough. +- Native background removal must be tested on physical iPhone, not just TypeScript/build. diff --git a/android/app/google-services.json b/android/app/google-services.json new file mode 100644 index 0000000..1b72d77 --- /dev/null +++ b/android/app/google-services.json @@ -0,0 +1,90 @@ +{ + "project_info": { + "project_number": "1043970871162", + "project_id": "closet-app-57146", + "storage_bucket": "closet-app-57146.firebasestorage.app" + }, + "client": [ + { + "client_info": { + "mobilesdk_app_id": "1:1043970871162:android:986f363d6c1a57afad50c3", + "android_client_info": { + "package_name": "com.athfan.aura" + } + }, + "oauth_client": [ + { + "client_id": "1043970871162-metmeq51adnvabgt010mt2lhbvbthhio.apps.googleusercontent.com", + "client_type": 1, + "android_info": { + "package_name": "com.athfan.aura", + "certificate_hash": "5e8f16062ea3cd2c4a0d547876baa6f38cabf625" + } + }, + { + "client_id": "1043970871162-dn5aolk4kto8vqv1rbhidlgtql0okbav.apps.googleusercontent.com", + "client_type": 3 + } + ], + "api_key": [ + { + "current_key": "AIzaSyCC-WJc6mdAWOwXT_kIeKzlTmtyOa5A5-M" + } + ], + "services": { + "appinvite_service": { + "other_platform_oauth_client": [ + { + "client_id": "1043970871162-dn5aolk4kto8vqv1rbhidlgtql0okbav.apps.googleusercontent.com", + "client_type": 3 + }, + { + "client_id": "1043970871162-4mbfs4taqvn9b81imga67hg6kn15k644.apps.googleusercontent.com", + "client_type": 2, + "ios_info": { + "bundle_id": "com.kasat.aura" + } + } + ] + } + } + }, + { + "client_info": { + "mobilesdk_app_id": "1:1043970871162:android:4932579e9f8cccdbad50c3", + "android_client_info": { + "package_name": "com.kasat.aura" + } + }, + "oauth_client": [ + { + "client_id": "1043970871162-dn5aolk4kto8vqv1rbhidlgtql0okbav.apps.googleusercontent.com", + "client_type": 3 + } + ], + "api_key": [ + { + "current_key": "AIzaSyCC-WJc6mdAWOwXT_kIeKzlTmtyOa5A5-M" + } + ], + "services": { + "appinvite_service": { + "other_platform_oauth_client": [ + { + "client_id": "1043970871162-dn5aolk4kto8vqv1rbhidlgtql0okbav.apps.googleusercontent.com", + "client_type": 3 + }, + { + "client_id": "1043970871162-4mbfs4taqvn9b81imga67hg6kn15k644.apps.googleusercontent.com", + "client_type": 2, + "ios_info": { + "bundle_id": "com.kasat.aura" + } + } + ] + } + } + } + ], + "configuration_version": "1" +} \ No newline at end of file diff --git a/app.json b/app.json index b0d7a6f..37427dd 100644 --- a/app.json +++ b/app.json @@ -1,23 +1,45 @@ { "expo": { - "name": "closet", - "slug": "closet", + "name": "AURA", + "slug": "aura-ai-personal-stylist", "version": "1.0.0", "orientation": "portrait", - "icon": "./assets/images/icon.png", - "scheme": "closet", + "icon": "./assets/icon.png", + "scheme": "aura", "userInterfaceStyle": "automatic", "newArchEnabled": true, "ios": { + "icon": "./assets/icon.png", "supportsTablet": true, - "bundleIdentifier": "com.anonymous.closet", - "deploymentTarget": "17.0", + "usesAppleSignIn": true, + "bundleIdentifier": "com.kasat.aura", + "buildNumber": "4", + "googleServicesFile": "./GoogleService-Info.plist", "infoPlist": { - "NSLocationWhenInUseUsageDescription": "We use your location to show local weather in your wardrobe insights.", - "NSCalendarsUsageDescription": "We use your calendar to show today’s events in your wardrobe insights." + "ITSAppUsesNonExemptEncryption": false, + "CFBundleURLTypes": [ + { + "CFBundleURLSchemes": [ + "com.googleusercontent.apps.1043970871162-4mbfs4taqvn9b81imga67hg6kn15k644" + ] + } + ], + "NSLocationWhenInUseUsageDescription": "AURA uses your approximate location while you use the app to show local weather for outfit planning.", + "NSLocationAlwaysAndWhenInUseUsageDescription": "AURA uses location only while you use the app to show local weather for outfit planning.", + "NSLocationAlwaysUsageDescription": "AURA uses location only while you use the app to show local weather for outfit planning.", + "NSCalendarsUsageDescription": "We use your calendar to show today's events in your wardrobe insights.", + "NSCalendarsFullAccessUsageDescription": "We use your calendar to show today's events in your wardrobe insights.", + "NSRemindersUsageDescription": "AURA uses reminders only if calendar access includes them for outfit planning context.", + "NSRemindersFullAccessUsageDescription": "AURA uses reminders only if calendar access includes them for outfit planning context.", + "NSCameraUsageDescription": "AURA uses your camera so you can add clothing items to your wardrobe.", + "NSPhotoLibraryUsageDescription": "AURA uses your photo library so you can select clothing photos for your wardrobe.", + "NSPhotoLibraryAddUsageDescription": "AURA may save edited clothing images to your photo library when you choose to.", + "NSMicrophoneUsageDescription": "Allow AURA to use your microphone for voice dictation.", + "NSSpeechRecognitionUsageDescription": "Allow AURA to turn your speech into chat text." } }, "android": { + "googleServicesFile": "./google-services.json", "adaptiveIcon": { "backgroundColor": "#E6F4FE", "foregroundImage": "./assets/images/android-icon-foreground.png", @@ -26,7 +48,12 @@ }, "edgeToEdgeEnabled": true, "predictiveBackGestureEnabled": false, - "package": "com.anonymous.closet" + "package": "com.kasat.aura", + "versionCode": 1, + "permissions": [ + "android.permission.RECORD_AUDIO", + "android.permission.MODIFY_AUDIO_SETTINGS" + ] }, "web": { "output": "static", @@ -40,7 +67,7 @@ "image": "./assets/images/splash-icon.png", "imageWidth": 200, "resizeMode": "contain", - "backgroundColor": "#ffffff", + "backgroundColor": "#000000", "dark": { "backgroundColor": "#000000" } @@ -53,11 +80,35 @@ "deploymentTarget": "17.0" } } - ] + ], + "expo-audio", + [ + "@react-native-voice/voice", + { + "microphonePermission": "Allow AURA to use your microphone for voice dictation.", + "speechRecognitionPermission": "Allow AURA to turn your speech into chat text." + } + ], + [ + "expo-image-picker", + { + "photosPermission": "AURA uses your photo library so you can select clothing photos for your wardrobe.", + "cameraPermission": "AURA uses your camera so you can add clothing items to your wardrobe." + } + ], + "@react-native-google-signin/google-signin", + "expo-apple-authentication", + "expo-asset" ], "experiments": { "typedRoutes": true, "reactCompiler": true + }, + "extra": { + "router": {}, + "eas": { + "projectId": "a5ab8afd-5c1a-4ec6-9218-b835a9c43d0d" + } } } } diff --git a/app/(auth)/forgot-password.tsx b/app/(auth)/forgot-password.tsx new file mode 100644 index 0000000..f975304 --- /dev/null +++ b/app/(auth)/forgot-password.tsx @@ -0,0 +1,66 @@ +import { router } from "expo-router"; +import { sendPasswordResetEmail } from "firebase/auth"; +import React, { useState } from "react"; +import { Alert, Text, View } from "react-native"; + +import { + AuthInlineLink, + AuthInput, + AuthScaffold, + PrimaryAuthButton, +} from "@/src/components/auth/AuthScaffold"; +import { getAuthErrorMessage } from "@/src/auth/authErrors"; +import { useAppTheme } from "@/src/hooks/useAppTheme"; +import { auth } from "@/src/lib/firebase"; + +export default function ForgotPasswordScreen() { + const { colors } = useAppTheme(); + const [email, setEmail] = useState(""); + const [loading, setLoading] = useState(false); + const [sent, setSent] = useState(false); + + async function onReset() { + const normalizedEmail = email.trim().toLowerCase(); + if (!normalizedEmail) return Alert.alert("Missing email", "Enter your email."); + + try { + setLoading(true); + await sendPasswordResetEmail(auth, normalizedEmail); + setSent(true); + } catch (error: any) { + Alert.alert("Reset failed", getAuthErrorMessage(error)); + } finally { + setLoading(false); + } + } + + return ( + + router.replace("/(auth)/login")} /> + + } + > + + + {sent ? ( + + Reset link sent. Check your inbox and spam folder. + + ) : null} + + ); +} diff --git a/app/(auth)/login.tsx b/app/(auth)/login.tsx index d148e85..bd4bf76 100644 --- a/app/(auth)/login.tsx +++ b/app/(auth)/login.tsx @@ -1,104 +1,291 @@ -import { Link, router } from "expo-router"; +import AntDesign from "@expo/vector-icons/AntDesign"; +import * as AppleAuthentication from "expo-apple-authentication"; +import { router } from "expo-router"; import { signInWithEmailAndPassword } from "firebase/auth"; -import React, { useState } from "react"; -import { Alert, Pressable, Text, TextInput, View } from "react-native"; +import React, { useEffect, useRef, useState } from "react"; +import { ActivityIndicator, Alert, Platform, Pressable, StyleSheet, Text, TextInput, View } from "react-native"; -import { auth } from "../../src/lib/firebase"; +import { + AuthInlineLink, + AuthInput, + AuthScaffold, + PrimaryAuthButton, +} from "@/src/components/auth/AuthScaffold"; +import { getAuthErrorMessage } from "@/src/auth/authErrors"; +import { signInWithApple } from "@/src/auth/appleAuth"; +import { signInWithGoogle } from "@/src/auth/googleAuth"; +import { useAppTheme } from "@/src/hooks/useAppTheme"; +import { trackLaunchEvent } from "@/src/lib/analytics"; +import { auth } from "@/src/lib/firebase"; -function norm(value: string) { +function normalize(value: string) { return value.trim(); } +const WEB_AUTH_INPUT_STYLE_ID = "aura-auth-web-inputs"; + export default function LoginScreen() { + const { colors } = useAppTheme(); const [email, setEmail] = useState(""); const [password, setPassword] = useState(""); const [loading, setLoading] = useState(false); + const [appleAvailable, setAppleAvailable] = useState(false); + const [appleLoading, setAppleLoading] = useState(false); + const [googleLoading, setGoogleLoading] = useState(false); + const passwordRef = useRef(null); + const authInProgress = loading || appleLoading || googleLoading; - async function onLogin() { - const e = norm(email).toLowerCase(); - const p = password; + useEffect(() => { + let cancelled = false; + if (Platform.OS !== "ios") return; + void AppleAuthentication.isAvailableAsync().then((available) => { + if (!cancelled) setAppleAvailable(available); + }); + return () => { + cancelled = true; + }; + }, []); + + useEffect(() => { + if (Platform.OS !== "web" || typeof document === "undefined") return; + + let styleTag = document.getElementById(WEB_AUTH_INPUT_STYLE_ID) as HTMLStyleElement | null; + if (!styleTag) { + styleTag = document.createElement("style"); + styleTag.id = WEB_AUTH_INPUT_STYLE_ID; + document.head.appendChild(styleTag); + } + + styleTag.textContent = ` + input[data-testid="auth-email"], + input[data-testid="auth-password"], + #auth-email, + #auth-password { + color: ${colors.text} !important; + -webkit-text-fill-color: ${colors.text} !important; + caret-color: ${colors.softPurple} !important; + opacity: 1 !important; + font-weight: 700 !important; + background: ${colors.surfaceElevated} !important; + } + + input[data-testid="auth-email"]::placeholder, + input[data-testid="auth-password"]::placeholder, + #auth-email::placeholder, + #auth-password::placeholder { + color: ${colors.textMuted} !important; + -webkit-text-fill-color: ${colors.textMuted} !important; + } - if (!e) return Alert.alert("Missing email", "Enter your email."); - if (!p) return Alert.alert("Missing password", "Enter your password."); + input[data-testid="auth-email"]:-webkit-autofill, + input[data-testid="auth-email"]:-webkit-autofill:hover, + input[data-testid="auth-email"]:-webkit-autofill:focus, + #auth-email:-webkit-autofill, + #auth-email:-webkit-autofill:hover, + #auth-email:-webkit-autofill:focus, + input[data-testid="auth-password"]:-webkit-autofill, + input[data-testid="auth-password"]:-webkit-autofill:hover, + input[data-testid="auth-password"]:-webkit-autofill:focus { + -webkit-text-fill-color: ${colors.text} !important; + -webkit-box-shadow: 0 0 0 1000px ${colors.surfaceElevated} inset !important; + box-shadow: 0 0 0 1000px ${colors.surfaceElevated} inset !important; + transition: background-color 9999s ease-out 0s !important; + } + + #auth-password:-webkit-autofill, + #auth-password:-webkit-autofill:hover, + #auth-password:-webkit-autofill:focus { + -webkit-text-fill-color: ${colors.text} !important; + -webkit-box-shadow: 0 0 0 1000px ${colors.surfaceElevated} inset !important; + box-shadow: 0 0 0 1000px ${colors.surfaceElevated} inset !important; + transition: background-color 9999s ease-out 0s !important; + } + `; + }, [colors.softPurple, colors.surfaceElevated, colors.text, colors.textMuted]); + + async function onLogin() { + const normalizedEmail = normalize(email).toLowerCase(); + if (!normalizedEmail) return Alert.alert("Missing email", "Enter your email."); + if (!password) return Alert.alert("Missing password", "Enter your password."); try { setLoading(true); - await signInWithEmailAndPassword(auth, e, p); - router.replace("/(tabs)"); - } catch (err: any) { - Alert.alert("Login failed", err?.message ?? "Unable to sign in."); + const credential = await signInWithEmailAndPassword(auth, normalizedEmail, password); + void trackLaunchEvent({ + userId: credential.user.uid, + eventName: "auth_sign_in_succeeded", + properties: { + provider: "password", + isNewUser: false, + }, + }); + } catch (error: any) { + Alert.alert("Sign in failed", getAuthErrorMessage(error)); } finally { setLoading(false); } } - return ( - - - Login - + async function onAppleSignIn() { + try { + setAppleLoading(true); + await signInWithApple(); + } catch (error) { + const message = getAuthErrorMessage(error); + if (message !== "Sign in cancelled") Alert.alert("Apple sign in failed", message); + } finally { + setAppleLoading(false); + } + } + + async function onGoogleSignIn() { + try { + setGoogleLoading(true); + await signInWithGoogle(); + } catch (error) { + const message = getAuthErrorMessage(error); + if (message !== "Sign in cancelled") Alert.alert("Google sign in failed", message); + } finally { + setGoogleLoading(false); + } + } - + router.push("/(auth)/register")} /> + router.push("/(auth)/forgot-password")} /> + + } + > + {Platform.OS === "ios" && appleAvailable ? ( + + + {appleLoading ? ( + + + + ) : null} + + ) : null} + [ + styles.googleButton, + { + backgroundColor: colors.surfaceSoft, + borderColor: colors.borderStrong, + opacity: authInProgress ? 0.62 : pressed ? 0.82 : 1, + }, + ]} + > + {googleLoading ? ( + + ) : ( + <> + + Continue with Google + + )} + + + + or + + + passwordRef.current?.focus()} /> - - - - - {loading ? "Signing in..." : "Sign in"} - - - - - Create account - - - + + + Your styling profile, saved looks, and planner stay synced to this account. + + ); } -const input = { - borderWidth: 1, - borderColor: "#ddd", - borderRadius: 12, - paddingHorizontal: 12, - paddingVertical: 12, - fontSize: 16, -} as const; - -const btnPrimary = { - marginTop: 4, - paddingVertical: 14, - borderRadius: 12, - backgroundColor: "#111", - alignItems: "center", -} as const; - -const btnPrimaryText = { - color: "#fff", - fontWeight: "900", -} as const; - -const btnSecondary = { - paddingVertical: 12, - borderRadius: 12, - borderWidth: 1, - borderColor: "#ddd", - alignItems: "center", -} as const; - -const btnSecondaryText = { - color: "#111", - fontWeight: "800", -} as const; +const styles = StyleSheet.create({ + oauthButtonFrame: { + borderRadius: 18, + height: 50, + overflow: "hidden", + }, + appleButton: { + height: 50, + width: "100%", + }, + loadingOverlay: { + ...StyleSheet.absoluteFillObject, + alignItems: "center", + justifyContent: "center", + }, + googleButton: { + alignItems: "center", + borderRadius: 18, + borderWidth: 1, + flexDirection: "row", + height: 50, + justifyContent: "center", + paddingHorizontal: 16, + width: "100%", + }, + googleIcon: { + left: 18, + position: "absolute", + }, + googleButtonText: { + fontSize: 15, + fontWeight: "700", + }, + dividerRow: { + alignItems: "center", + flexDirection: "row", + gap: 12, + paddingVertical: 2, + }, + dividerLine: { + flex: 1, + height: 1, + }, + dividerText: { + fontSize: 13, + fontWeight: "700", + }, +}); diff --git a/app/(auth)/register.tsx b/app/(auth)/register.tsx index 67b4fb6..50152bf 100644 --- a/app/(auth)/register.tsx +++ b/app/(auth)/register.tsx @@ -1,116 +1,127 @@ -import { Link, router } from "expo-router"; +import { router } from "expo-router"; import { createUserWithEmailAndPassword } from "firebase/auth"; -import React, { useState } from "react"; -import { Alert, Pressable, Text, TextInput, View } from "react-native"; - -import { auth } from "../../src/lib/firebase"; - -function norm(value: string) { +import React, { useRef, useState } from "react"; +import { Alert, TextInput, View } from "react-native"; + +import { + AuthInlineLink, + AuthInput, + AuthScaffold, + PrimaryAuthButton, +} from "@/src/components/auth/AuthScaffold"; +import { getAuthErrorMessage } from "@/src/auth/authErrors"; +import { auth } from "@/src/lib/firebase"; +import { trackLaunchEvent } from "@/src/lib/analytics"; +import { EMPTY_USER_PROFILE_PREFERENCES, saveUserAccountProfile, saveUserProfilePreferences } from "@/src/lib/userProfile"; + +function normalize(value: string) { return value.trim(); } export default function RegisterScreen() { + const [name, setName] = useState(""); const [email, setEmail] = useState(""); const [password, setPassword] = useState(""); const [confirmPassword, setConfirmPassword] = useState(""); const [loading, setLoading] = useState(false); + const emailRef = useRef(null); + const passwordRef = useRef(null); + const confirmPasswordRef = useRef(null); async function onRegister() { - const e = norm(email).toLowerCase(); - const p = password; - const cp = confirmPassword; - - if (!e) return Alert.alert("Missing email", "Enter your email."); - if (!p) return Alert.alert("Missing password", "Enter a password."); - if (p.length < 6) return Alert.alert("Weak password", "Use at least 6 characters."); - if (p !== cp) return Alert.alert("Password mismatch", "Passwords do not match."); + const normalizedName = normalize(name); + const normalizedEmail = normalize(email).toLowerCase(); + if (!normalizedName) return Alert.alert("Missing name", "Enter your first name."); + if (!normalizedEmail) return Alert.alert("Missing email", "Enter your email."); + if (!password) return Alert.alert("Missing password", "Enter a password."); + if (password.length < 6) return Alert.alert("Weak password", "Use at least 6 characters."); + if (password !== confirmPassword) { + return Alert.alert("Password mismatch", "Passwords do not match."); + } try { setLoading(true); - await createUserWithEmailAndPassword(auth, e, p); - router.replace("/(tabs)"); - } catch (err: any) { - Alert.alert("Registration failed", err?.message ?? "Unable to create account."); + const credential = await createUserWithEmailAndPassword(auth, normalizedEmail, password); + await Promise.all([ + saveUserAccountProfile(credential.user.uid, { + name: normalizedName, + displayName: normalizedName, + }), + saveUserProfilePreferences(credential.user.uid, { + ...EMPTY_USER_PROFILE_PREFERENCES, + firstName: normalizedName, + onboardingCompleted: false, + }), + ]); + void trackLaunchEvent({ + userId: credential.user.uid, + eventName: "auth_signed_up", + properties: { + provider: "password", + }, + }); + router.replace("/(onboarding)"); + } catch (error: any) { + Alert.alert("Create account failed", getAuthErrorMessage(error)); } finally { setLoading(false); } } return ( - - - Register - - - + router.replace("/(auth)/login")} /> + + } + > + emailRef.current?.focus()} + /> + passwordRef.current?.focus()} /> - - confirmPasswordRef.current?.focus()} /> - - - - - {loading ? "Creating..." : "Create account"} - - - - - Back to login - - - + + ); } - -const input = { - borderWidth: 1, - borderColor: "#ddd", - borderRadius: 12, - paddingHorizontal: 12, - paddingVertical: 12, - fontSize: 16, -} as const; - -const btnPrimary = { - marginTop: 4, - paddingVertical: 14, - borderRadius: 12, - backgroundColor: "#111", - alignItems: "center", -} as const; - -const btnPrimaryText = { - color: "#fff", - fontWeight: "900", -} as const; - -const btnSecondary = { - paddingVertical: 12, - borderRadius: 12, - borderWidth: 1, - borderColor: "#ddd", - alignItems: "center", -} as const; - -const btnSecondaryText = { - color: "#111", - fontWeight: "800", -} as const; diff --git a/app/(auth)/welcome.tsx b/app/(auth)/welcome.tsx new file mode 100644 index 0000000..4f33ac2 --- /dev/null +++ b/app/(auth)/welcome.tsx @@ -0,0 +1,74 @@ +import { router } from "expo-router"; +import React from "react"; +import { Pressable, Text, View } from "react-native"; +import Animated, { Easing, FadeIn, FadeInDown, FadeInUp } from "react-native-reanimated"; + +import { Fonts } from "@/constants/theme"; +import AuraGlowBackground from "@/src/components/aura/AuraGlowBackground"; +import AuraGradientButton from "@/src/components/aura/AuraGradientButton"; +import AuraLogoHero from "@/src/components/aura/AuraLogoHero"; +import { SafeScreen } from "@/src/components/SafeScreen"; +import { useAppTheme } from "@/src/hooks/useAppTheme"; + +export default function WelcomeScreen() { + const { colors, theme } = useAppTheme(); + + return ( + + + + + + + + + + + + + Your personal AI stylist. + + + + + + router.push("/(auth)/register")} /> + + router.push("/(auth)/login")} style={({ pressed }) => ({ opacity: pressed ? 0.72 : 1 })}> + + Already have an account? Sign In + + + router.push("/(auth)/forgot-password")} style={({ pressed }) => ({ opacity: pressed ? 0.72 : 1 })}> + + Forgot password + + + + + + + + ); +} diff --git a/app/(onboarding)/_layout.tsx b/app/(onboarding)/_layout.tsx new file mode 100644 index 0000000..6e7951f --- /dev/null +++ b/app/(onboarding)/_layout.tsx @@ -0,0 +1,7 @@ +import { Stack } from "expo-router"; +import React from "react"; + +export default function OnboardingLayout() { + return ; +} + diff --git a/app/(onboarding)/index.tsx b/app/(onboarding)/index.tsx new file mode 100644 index 0000000..7785336 --- /dev/null +++ b/app/(onboarding)/index.tsx @@ -0,0 +1,1167 @@ +import Slider from "@react-native-community/slider"; +import { router } from "expo-router"; +import React, { useEffect, useMemo, useRef, useState } from "react"; +import { + ActivityIndicator, + Alert, + KeyboardAvoidingView, + LayoutChangeEvent, + NativeScrollEvent, + NativeSyntheticEvent, + Platform, + Pressable, + ScrollView, + StyleSheet, + Text, + TextInput, + View, + useWindowDimensions, +} from "react-native"; +import { Gesture, GestureDetector } from "react-native-gesture-handler"; +import Animated, { Easing, FadeInUp, runOnJS } from "react-native-reanimated"; + +import { SafeScreen } from "@/src/components/SafeScreen"; +import { useAuth } from "@/src/hooks/useAuth"; +import { useAppTheme } from "@/src/hooks/useAppTheme"; +import { + ONBOARDING_CATEGORY_OPTIONS, + normalizeProfileSizePayload, +} from "@/src/lib/adaptiveSizing"; +import { + EMPTY_USER_PROFILE_PREFERENCES, + loadUserProfilePreferences, + saveUserAccountProfile, + saveUserProfilePreferences, +} from "@/src/lib/userProfile"; +import { trackLaunchEvent } from "@/src/lib/analytics"; +import { + FIT_OPTIONS, + BUDGET_OPTIONS, + OCCASION_OPTIONS, + SHOE_SIZES_EU, + STEPS, + STYLE_OPTIONS, + WARDROBE_MODE_CARDS, +} from "@/src/onboarding/onboardingOptions"; +import { + euShoeConversion, + formatHeight, + getLocaleParts, + humanize, + messageForOnboardingSaveError, + shoeValueFromDraft, + toggleValue, + unitsForPreference, +} from "@/src/onboarding/onboardingFormat"; +import type { + UnitsPreference, + UserProfilePreferences, + WardrobeMode, +} from "@/src/types/UserProfilePreferences"; + +export default function OnboardingScreen() { + const { user } = useAuth(); + const { colors } = useAppTheme(); + const { width } = useWindowDimensions(); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [saveError, setSaveError] = useState(null); + const [autoAdvancing, setAutoAdvancing] = useState(false); + const [stepIndex, setStepIndex] = useState(0); + const [draft, setDraft] = useState(EMPTY_USER_PROFILE_PREFERENCES); + const autoAdvanceTimer = useRef | null>(null); + const saveInFlightRef = useRef(false); + + const detected = useMemo(() => getLocaleParts(), []); + const step = STEPS[stepIndex]; + const progress = (stepIndex + 1) / STEPS.length; + const isFirstStep = step.key === "wardrobeMode"; + const canContinue = + step.key === "style" + ? draft.styleAesthetics.length > 0 + : step.key === "categories" + ? draft.selectedCategories.length > 0 + : step.key === "profile" + ? Boolean((draft.firstName ?? "").trim()) && Boolean(draft.preferredFit) + : true; + + useEffect(() => { + let cancelled = false; + if (!user?.uid) { + router.replace("/(auth)/welcome"); + return; + } + + void loadUserProfilePreferences(user.uid) + .then((profile) => { + if (cancelled) return; + const unitsPreference = profile.onboardingCompleted + ? profile.unitsPreference + : detected.unitsPreference; + const heightDefault = unitsPreference === "imperial" ? 67 : 170; + setDraft({ + ...EMPTY_USER_PROFILE_PREFERENCES, + ...profile, + region: profile.region ?? detected.region, + unitsPreference, + units: unitsForPreference(unitsPreference), + firstName: + profile.firstName ?? + user.displayName?.split(" ")[0] ?? + user.email?.split("@")[0] ?? + "", + body: { + ...profile.body, + height: profile.body.height ?? profile.height.value ?? heightDefault, + }, + height: { + value: profile.height.value ?? profile.body.height ?? heightDefault, + unit: unitsPreference === "imperial" ? "ft_in" : "cm", + }, + defaultSizes: { + ...profile.defaultSizes, + shoes: profile.defaultSizes.shoes ?? "EU 42", + }, + preferredFit: profile.preferredFit ?? "regular", + }); + }) + .catch(() => { + if (cancelled) return; + const units = unitsForPreference(detected.unitsPreference); + const heightDefault = detected.unitsPreference === "imperial" ? 67 : 170; + setDraft({ + ...EMPTY_USER_PROFILE_PREFERENCES, + region: detected.region, + unitsPreference: detected.unitsPreference, + units, + firstName: + user.displayName?.split(" ")[0] ?? + user.email?.split("@")[0] ?? + "", + body: { + height: heightDefault, + }, + height: { + value: heightDefault, + unit: detected.unitsPreference === "imperial" ? "ft_in" : "cm", + }, + defaultSizes: { + shoes: "EU 42", + }, + preferredFit: "regular", + }); + }) + .finally(() => { + if (!cancelled) setLoading(false); + }); + + return () => { + cancelled = true; + if (autoAdvanceTimer.current) clearTimeout(autoAdvanceTimer.current); + }; + }, [detected.region, detected.unitsPreference, user?.displayName, user?.email, user?.uid]); + + const units = useMemo( + () => unitsForPreference(draft.unitsPreference), + [draft.unitsPreference], + ); + + function goToStep(nextIndex: number) { + setStepIndex(Math.max(0, Math.min(nextIndex, STEPS.length - 1))); + } + + function goBack() { + if (stepIndex > 0) goToStep(stepIndex - 1); + } + + function selectWardrobeMode(value: Exclude) { + setDraft((prev) => ({ ...prev, wardrobeMode: value })); + setAutoAdvancing(true); + if (autoAdvanceTimer.current) clearTimeout(autoAdvanceTimer.current); + autoAdvanceTimer.current = setTimeout(() => { + setAutoAdvancing(false); + goToStep(1); + }, 400); + } + + async function onFinish() { + if (saveInFlightRef.current) return; + if (!user?.uid) { + const message = "Please sign in again before finishing setup."; + setSaveError(message); + Alert.alert("Couldn't finish setup", message); + return; + } + saveInFlightRef.current = true; + try { + setSaving(true); + setSaveError(null); + const normalizedSizingDraft = normalizeProfileSizePayload({ + ...draft, + region: draft.region ?? detected.region, + units, + defaultSizes: { + ...draft.defaultSizes, + shoes: draft.defaultSizes.shoes ?? "EU 42", + }, + }); + + const heightValue = + normalizedSizingDraft.body.height ?? + normalizedSizingDraft.height.value ?? + (draft.unitsPreference === "imperial" ? 67 : 170); + const goals = normalizedSizingDraft.goals.length + ? normalizedSizingDraft.goals + : ["outfit_suggestions"]; + const favoriteColors = + normalizedSizingDraft.favoriteColors.length > 0 + ? normalizedSizingDraft.favoriteColors + : (normalizedSizingDraft.stylePreferences.favoriteColors ?? []); + const avoidedColors = + normalizedSizingDraft.avoidedColors.length > 0 + ? normalizedSizingDraft.avoidedColors + : (normalizedSizingDraft.stylePreferences.avoidedColors ?? []); + + const profileToSave: UserProfilePreferences = { + ...normalizedSizingDraft, + onboardingCompleted: true, + displayName: + (draft.firstName ?? "").trim() || + user.displayName || + user.email?.split("@")[0] || + null, + region: normalizedSizingDraft.region ?? detected.region, + unitsPreference: draft.unitsPreference, + units, + favoriteColors, + avoidedColors, + accessoryPreferences: normalizedSizingDraft.accessoryPreferences, + goals, + defaultSizes: { + ...normalizedSizingDraft.defaultSizes, + shoes: normalizedSizingDraft.defaultSizes.shoes ?? "EU 42", + top: + normalizedSizingDraft.defaultSizes.top ?? + normalizedSizingDraft.defaultSizes.tops ?? + null, + tops: + normalizedSizingDraft.defaultSizes.tops ?? + normalizedSizingDraft.defaultSizes.top ?? + null, + bottomWaist: + normalizedSizingDraft.defaultSizes.bottomWaist ?? + normalizedSizingDraft.defaultSizes.bottomsWaist ?? + normalizedSizingDraft.defaultSizes.bottoms ?? + null, + bottomsWaist: + normalizedSizingDraft.defaultSizes.bottomsWaist ?? + normalizedSizingDraft.defaultSizes.bottomWaist ?? + null, + bottoms: + normalizedSizingDraft.defaultSizes.bottoms ?? + normalizedSizingDraft.defaultSizes.bottomWaist ?? + null, + bottomLength: + normalizedSizingDraft.defaultSizes.bottomLength ?? + normalizedSizingDraft.defaultSizes.bottomsLength ?? + null, + bottomsLength: + normalizedSizingDraft.defaultSizes.bottomsLength ?? + normalizedSizingDraft.defaultSizes.bottomLength ?? + null, + }, + height: { + value: heightValue, + unit: draft.unitsPreference === "imperial" ? "ft_in" : "cm", + }, + weight: { + value: normalizedSizingDraft.weight.value, + unit: draft.unitsPreference === "imperial" ? "lb" : "kg", + }, + body: { + ...normalizedSizingDraft.body, + height: heightValue, + weight: normalizedSizingDraft.weight.value ?? normalizedSizingDraft.body.weight ?? null, + }, + advancedFit: { + ...normalizedSizingDraft.advancedFit, + }, + stylePreferences: { + ...normalizedSizingDraft.stylePreferences, + preferredStyles: normalizedSizingDraft.styleAesthetics, + favoriteColors, + avoidedColors, + }, + fitPreferences: { + ...normalizedSizingDraft.fitPreferences, + tops: + normalizedSizingDraft.preferredFit ?? + normalizedSizingDraft.fitPreferences.tops ?? + null, + }, + }; + + await saveUserAccountProfile(user.uid, { + name: + (draft.firstName ?? "").trim() || + user.displayName || + user.email?.split("@")[0] || + null, + displayName: + (draft.firstName ?? "").trim() || + user.displayName || + user.email?.split("@")[0] || + null, + }); + await saveUserProfilePreferences(user.uid, profileToSave); + void trackLaunchEvent({ + userId: user.uid, + eventName: "onboarding_completed", + properties: { + goalsCount: goals.length, + styleAestheticCount: normalizedSizingDraft.styleAesthetics.length, + categoryCount: normalizedSizingDraft.selectedCategories.length, + unitsPreference: draft.unitsPreference, + }, + }); + router.replace("/(tabs)"); + } catch (error) { + const message = messageForOnboardingSaveError(error); + setSaveError(message); + Alert.alert("Couldn't finish setup", message); + } finally { + setSaving(false); + saveInFlightRef.current = false; + } + } + + async function nextStep() { + if (saving) return; + if (!canContinue) { + if (step.key === "style") { + Alert.alert("Choose your style", "Pick at least one style aesthetic."); + } else if (step.key === "categories") { + Alert.alert("Choose categories", "Pick the categories you actually wear."); + } else if (step.key === "profile") { + Alert.alert("Finish your profile", "Add your name and choose a preferred fit."); + } + return; + } + + if (stepIndex === STEPS.length - 1) { + await onFinish(); + return; + } + setSaveError(null); + goToStep(stepIndex + 1); + } + + const swipeGesture = Gesture.Pan() + .activeOffsetX([-999, 24]) + .failOffsetY([-18, 18]) + .onEnd((event) => { + if (event.translationX > 84 && event.velocityX > 250) { + runOnJS(goBack)(); + } + }); + + if (loading) { + return ( + + + + Preparing your style profile... + + + ); + } + + return ( + + + + + AURA SETUP + + + + {step.title} + {step.subtitle} + + + + + {step.key === "wardrobeMode" ? ( + + ) : null} + + {step.key === "style" ? ( + + setDraft((prev) => ({ + ...prev, + styleAesthetics: toggleValue(prev.styleAesthetics, value), + })) + } + onOccasionToggle={(value) => + setDraft((prev) => ({ + ...prev, + occasionPriority: toggleValue(prev.occasionPriority, value), + })) + } + /> + ) : null} + + {step.key === "categories" ? ( + + setDraft((prev) => ({ + ...prev, + selectedCategories: toggleValue(prev.selectedCategories, value), + })) + } + onSetAll={(selectedCategories) => + setDraft((prev) => ({ ...prev, selectedCategories })) + } + /> + ) : null} + + {step.key === "profile" ? ( + + ) : null} + + + + {!isFirstStep ? ( + + {saveError ? ( + {saveError} + ) : null} + [ + styles.primaryButton, + { + backgroundColor: colors.aiAccent, + opacity: saving || !canContinue ? 0.45 : pressed ? 0.86 : 1, + }, + ]} + > + + {stepIndex === STEPS.length - 1 + ? saving + ? "Saving..." + : "Finish setup" + : "Continue"} + + + [ + styles.backButton, + { opacity: pressed ? 0.72 : 1 }, + ]} + > + Back + + + ) : null} + + + + ); +} + +function WardrobeModeStep({ + selected, + disabled, + onSelect, +}: { + selected: WardrobeMode; + disabled: boolean; + onSelect: (value: Exclude) => void; +}) { + const { colors } = useAppTheme(); + return ( + + {WARDROBE_MODE_CARDS.map((card) => { + const active = selected === card.value; + return ( + onSelect(card.value)} + disabled={disabled} + style={({ pressed }) => [ + styles.modeCard, + { + backgroundColor: active ? "rgba(255,255,255,0.05)" : "#1A1A1A", + borderColor: active ? "rgba(255,255,255,0.92)" : "transparent", + borderWidth: active ? 2 : 0, + opacity: pressed ? 0.86 : 1, + }, + ]} + > + {card.icon} + {card.label} + + {card.description} + + + ); + })} + + ); +} + +function StyleStep({ + styleAesthetics, + occasionPriority, + onStyleToggle, + onOccasionToggle, +}: { + styleAesthetics: string[]; + occasionPriority: string[]; + onStyleToggle: (value: string) => void; + onOccasionToggle: (value: string) => void; +}) { + const capped = styleAesthetics.length >= 3; + return ( + + + {styleAesthetics.length}/3 selected + + capped && !styleAesthetics.includes(value)} + /> + + + + ); +} + +function CategoriesStep({ + selected, + onToggle, + onSetAll, +}: { + selected: string[]; + onToggle: (value: string) => void; + onSetAll: (value: string[]) => void; +}) { + const { colors } = useAppTheme(); + const allSelected = selected.length === ONBOARDING_CATEGORY_OPTIONS.length; + return ( + + + + + What do you actually wear? + + + AURA will only suggest items in these categories + + + onSetAll(allSelected ? [] : [...ONBOARDING_CATEGORY_OPTIONS])} + style={({ pressed }) => [ + styles.selectAllButton, + { + borderColor: "rgba(255,255,255,0.12)", + opacity: pressed ? 0.72 : 1, + }, + ]} + > + + {allSelected ? "Clear all" : "Select all"} + + + + + + ); +} + +function QuickProfileStep({ + draft, + width, + onDraftChange, +}: { + draft: UserProfilePreferences; + width: number; + onDraftChange: React.Dispatch>; +}) { + const { colors } = useAppTheme(); + const heightMin = draft.unitsPreference === "imperial" ? 55 : 140; + const heightMax = draft.unitsPreference === "imperial" ? 84 : 215; + const heightValue = + draft.body.height ?? + draft.height.value ?? + (draft.unitsPreference === "imperial" ? 67 : 170); + const shoeValue = shoeValueFromDraft(draft); + + return ( + + + onDraftChange((prev) => ({ ...prev, firstName: value })) + } + placeholder="First name" + /> + + + Height + + onDraftChange((prev) => ({ + ...prev, + body: { ...prev.body, height: value }, + height: { + value, + unit: prev.unitsPreference === "imperial" ? "ft_in" : "cm", + }, + })) + } + /> + + + + Shoe size + + onDraftChange((prev) => ({ + ...prev, + defaultSizes: { ...prev.defaultSizes, shoes: `EU ${value}` }, + })) + } + /> + + + + onDraftChange((prev) => ({ + ...prev, + preferredFit: value as UserProfilePreferences["preferredFit"], + })) + } + singleSelect + /> + + + onDraftChange((prev) => ({ + ...prev, + budgetPreference: value as UserProfilePreferences["budgetPreference"], + })) + } + singleSelect + /> + + ); +} + +function HeightSlider({ + min, + max, + value, + unitsPreference, + onChange, +}: { + min: number; + max: number; + value: number; + unitsPreference: UnitsPreference; + onChange: (value: number) => void; +}) { + const { colors } = useAppTheme(); + const [trackWidth, setTrackWidth] = useState(0); + const clamped = Math.max(min, Math.min(max, Math.round(value))); + const progress = (clamped - min) / (max - min); + const pillLeft = trackWidth > 0 ? progress * Math.max(trackWidth - 64, 1) : 0; + + return ( + setTrackWidth(event.nativeEvent.layout.width)} + > + + + {formatHeight(clamped, unitsPreference)} + + + onChange(Math.round(next))} + style={styles.slider} + /> + + ); +} + +function ShoeSizePicker({ + value, + width, + onChange, +}: { + value: number; + width: number; + onChange: (value: number) => void; +}) { + const { colors } = useAppTheme(); + const itemWidth = 64; + const pickerWidth = Math.min(width - 40, 360); + const sidePadding = Math.max((pickerWidth - itemWidth) / 2, 0); + const scrollRef = useRef(null); + + useEffect(() => { + const index = SHOE_SIZES_EU.indexOf(value); + const id = requestAnimationFrame(() => { + scrollRef.current?.scrollTo({ x: Math.max(index, 0) * itemWidth, animated: false }); + }); + return () => cancelAnimationFrame(id); + }, [itemWidth, value]); + + function updateFromOffset(offsetX: number) { + const index = Math.max( + 0, + Math.min(SHOE_SIZES_EU.length - 1, Math.round(offsetX / itemWidth)), + ); + const next = SHOE_SIZES_EU[index]; + if (next !== value) onChange(next); + } + + return ( + + + ) => + updateFromOffset(event.nativeEvent.contentOffset.x) + } + onMomentumScrollEnd={(event) => updateFromOffset(event.nativeEvent.contentOffset.x)} + scrollEventThrottle={16} + > + {SHOE_SIZES_EU.map((size) => { + const active = size === value; + return ( + + + {size} + + + ); + })} + + + EU {value} · {euShoeConversion(value)} + + + ); +} + +function OnboardingInput({ + label, + value, + onChangeText, + placeholder, +}: { + label: string; + value: string; + onChangeText: (value: string) => void; + placeholder?: string; +}) { + const { colors } = useAppTheme(); + return ( + + {label} + + + ); +} + +function ChipGroup({ + label, + values, + selected, + onToggle, + disabledValue, + singleSelect, +}: { + label?: string; + values: string[]; + selected: string[]; + onToggle: (value: string) => void; + disabledValue?: (value: string) => boolean; + singleSelect?: boolean; +}) { + const { colors } = useAppTheme(); + return ( + + {label ? {label} : null} + + {values.map((value) => { + const active = selected.includes(value); + const disabled = disabledValue?.(value) ?? false; + return ( + { + if (singleSelect && active) return; + onToggle(value); + }} + style={({ pressed }) => [ + styles.chip, + { + backgroundColor: active ? "rgba(217,207,255,0.17)" : "rgba(255,255,255,0.04)", + borderColor: active ? "rgba(217,207,255,0.52)" : "rgba(255,255,255,0.08)", + opacity: disabled ? 0.5 : pressed ? 0.82 : 1, + }, + ]} + > + + {humanize(value)} + + + ); + })} + + + ); +} + +const styles = StyleSheet.create({ + screen: { + flex: 1, + }, + loadingScreen: { + flex: 1, + alignItems: "center", + justifyContent: "center", + gap: 12, + }, + loadingText: { + fontSize: 14, + fontWeight: "700", + }, + header: { + paddingHorizontal: 20, + paddingTop: 12, + gap: 10, + }, + eyebrow: { + fontSize: 12, + fontWeight: "800", + letterSpacing: 1.2, + }, + progressTrack: { + height: 6, + borderRadius: 999, + backgroundColor: "rgba(255,255,255,0.06)", + overflow: "hidden", + }, + progressFill: { + height: "100%", + borderRadius: 999, + }, + title: { + fontSize: 32, + lineHeight: 38, + fontWeight: "900", + }, + subtitle: { + fontSize: 15, + lineHeight: 22, + }, + content: { + flexGrow: 1, + paddingHorizontal: 20, + paddingTop: 22, + }, + stepBody: { + flex: 1, + }, + cardGrid: { + flexDirection: "row", + flexWrap: "wrap", + gap: 12, + }, + modeCard: { + width: "48%", + minHeight: 158, + borderRadius: 8, + padding: 16, + justifyContent: "space-between", + }, + modeIcon: { + fontSize: 24, + fontWeight: "900", + }, + modeLabel: { + fontSize: 19, + fontWeight: "900", + }, + modeDescription: { + fontSize: 13, + lineHeight: 18, + fontWeight: "600", + }, + sectionStack: { + gap: 22, + }, + sectionHeader: { + alignItems: "flex-start", + }, + sectionKicker: { + color: "rgba(255,255,255,0.54)", + fontSize: 12, + fontWeight: "800", + }, + sectionDivider: { + height: 1, + backgroundColor: "rgba(255,255,255,0.07)", + }, + sectionLabel: { + fontSize: 14, + fontWeight: "800", + }, + sectionSubtext: { + fontSize: 13, + lineHeight: 19, + }, + categoryHeader: { + flexDirection: "row", + alignItems: "flex-start", + gap: 12, + }, + selectAllButton: { + borderWidth: 1, + borderRadius: 999, + paddingHorizontal: 12, + paddingVertical: 8, + }, + selectAllText: { + fontSize: 12, + fontWeight: "900", + }, + chipWrap: { + flexDirection: "row", + flexWrap: "wrap", + gap: 10, + }, + chip: { + paddingHorizontal: 14, + paddingVertical: 10, + borderRadius: 999, + borderWidth: 1, + }, + chipText: { + fontWeight: "800", + fontSize: 13, + }, + controlSection: { + gap: 12, + }, + input: { + borderWidth: 1, + borderRadius: 18, + paddingHorizontal: 16, + paddingVertical: 15, + fontSize: 16, + }, + sliderWrap: { + paddingTop: 34, + }, + heightPill: { + position: "absolute", + top: 0, + minWidth: 64, + alignItems: "center", + borderRadius: 999, + borderWidth: 1, + paddingHorizontal: 10, + paddingVertical: 6, + }, + heightPillText: { + fontSize: 12, + fontWeight: "900", + }, + slider: { + width: "100%", + height: 44, + }, + pickerShell: { + alignSelf: "center", + gap: 8, + }, + pickerCenter: { + position: "absolute", + top: 0, + height: 56, + borderRadius: 8, + borderWidth: 1, + }, + shoeItem: { + height: 56, + alignItems: "center", + justifyContent: "center", + }, + shoeValue: { + fontWeight: "900", + }, + shoeConversion: { + textAlign: "center", + fontSize: 12, + fontWeight: "700", + }, + footer: { + paddingHorizontal: 20, + paddingTop: 12, + paddingBottom: 20, + gap: 10, + borderTopWidth: 1, + borderTopColor: "rgba(255,255,255,0.06)", + }, + saveErrorText: { + color: "#FFB4A8", + fontSize: 13, + fontWeight: "700", + lineHeight: 18, + textAlign: "center", + }, + primaryButton: { + borderRadius: 18, + paddingVertical: 16, + alignItems: "center", + justifyContent: "center", + }, + primaryButtonText: { + color: "#071018", + fontSize: 16, + fontWeight: "900", + }, + backButton: { + alignItems: "center", + paddingVertical: 8, + }, + backButtonText: { + fontWeight: "800", + }, +}); diff --git a/app/(tabs)/_layout.tsx b/app/(tabs)/_layout.tsx index 1d53ff5..d20e9c1 100644 --- a/app/(tabs)/_layout.tsx +++ b/app/(tabs)/_layout.tsx @@ -1,6 +1,7 @@ import { Tabs } from 'expo-router'; import React from 'react'; import FloatingGlassTabBar from '@/components/FloatingGlassTabBar'; +import { MotionDurations } from '@/src/constants/motion'; export default function TabLayout() { return ( @@ -9,6 +10,13 @@ export default function TabLayout() { screenOptions={{ headerShown: false, tabBarHideOnKeyboard: true, + animation: 'fade', + transitionSpec: { + animation: 'timing', + config: { + duration: MotionDurations.sm, + }, + }, }}> + diff --git a/app/(tabs)/add.tsx b/app/(tabs)/add.tsx index 10ebf89..1df92b9 100644 --- a/app/(tabs)/add.tsx +++ b/app/(tabs)/add.tsx @@ -1,14 +1,78 @@ +import { useFocusEffect } from "@react-navigation/native"; import { useLocalSearchParams } from "expo-router"; -import React, { useMemo } from "react"; +import React, { useMemo, useState } from "react"; import { AddItemWizard } from "@/src/addItem/AddItemWizard"; +import type { AddItemMode } from "@/src/addItem/controllerShared"; +import { Category } from "@/src/shared/wardrobeTaxonomy"; + +function parseSuggestedCategory(value?: string | string[]) { + const raw = Array.isArray(value) ? value[0] : value; + if (!raw) return null; + const normalized = String(raw).trim().toLowerCase(); + if (normalized === Category.TOP || normalized === "tops") return Category.TOP; + if (normalized === Category.BOTTOM || normalized === "bottoms") return Category.BOTTOM; + if (normalized === Category.FOOTWEAR || normalized === "shoes") return Category.FOOTWEAR; + if (normalized === Category.OUTERWEAR) return Category.OUTERWEAR; + if (normalized === Category.ACCESSORY || normalized === "accessories") return Category.ACCESSORY; + if (normalized === Category.ONE_PIECE) return Category.ONE_PIECE; + return null; +} export default function AddItemScreen() { - const { editId } = useLocalSearchParams<{ editId?: string }>(); + const { editId, duplicateId, sourceItemId, suggestedCategory, addSession } = useLocalSearchParams<{ + editId?: string; + duplicateId?: string; + sourceItemId?: string; + suggestedCategory?: string; + addSession?: string; + }>(); + const [createFocusSession, setCreateFocusSession] = useState(() => String(Date.now())); const editItemId = useMemo( () => (Array.isArray(editId) ? editId[0] : editId) || null, [editId] ); + const duplicateItemId = useMemo( + () => (Array.isArray(duplicateId) ? duplicateId[0] : duplicateId) || null, + [duplicateId] + ); + const duplicateSourceItemId = useMemo( + () => + (Array.isArray(sourceItemId) ? sourceItemId[0] : sourceItemId) || + duplicateItemId || + null, + [duplicateItemId, sourceItemId] + ); + const routeAddSession = useMemo( + () => (Array.isArray(addSession) ? addSession[0] : addSession) || null, + [addSession] + ); + const initialCategory = useMemo( + () => parseSuggestedCategory(suggestedCategory), + [suggestedCategory] + ); + const mode: AddItemMode = editItemId ? "edit" : duplicateItemId ? "duplicate" : "create"; + const formSessionKey = useMemo(() => { + if (mode === "edit") return `edit:${editItemId}`; + if (mode === "duplicate") return `duplicate:${duplicateItemId}:${routeAddSession ?? ""}`; + return `create:${routeAddSession ?? createFocusSession}:${initialCategory ?? ""}`; + }, [createFocusSession, duplicateItemId, editItemId, initialCategory, mode, routeAddSession]); + useFocusEffect( + React.useCallback(() => { + if (editItemId || duplicateItemId || routeAddSession) return; + setCreateFocusSession(String(Date.now())); + }, [duplicateItemId, editItemId, routeAddSession]) + ); - return ; + return ( + + ); } diff --git a/app/(tabs)/ai.tsx b/app/(tabs)/ai.tsx index a8876bb..91da35d 100644 --- a/app/(tabs)/ai.tsx +++ b/app/(tabs)/ai.tsx @@ -1,117 +1,1441 @@ import { LinearGradient } from "expo-linear-gradient"; -import { useLocalSearchParams } from "expo-router"; +import * as Clipboard from "expo-clipboard"; +import { router, useFocusEffect, useLocalSearchParams } from "expo-router"; import React, { useEffect, useMemo, useRef, useState } from "react"; -import { - Animated, - Alert, - Keyboard, - KeyboardEvent, - Platform, - Pressable, - Text, - View, -} from "react-native"; -import { Ionicons } from "@expo/vector-icons"; +import { Alert, AppState, Image, Keyboard, KeyboardEvent, Platform, Share, View, type AlertButton } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; -import { Fonts } from "@/constants/theme"; +import AuraHeader from "@/src/components/ai/AuraHeader"; +import AuraChatDrawer from "@/src/components/ai/AuraChatDrawer"; import AuraQuickChips from "@/src/components/ai/AuraQuickChips"; import ChatList from "@/src/components/ai/ChatList"; import InputBar from "@/src/components/ai/InputBar"; -import type { AIMessage } from "@/src/components/ai/chatTypes"; +import ShopOptionsSheet from "@/src/components/shop/ShopOptionsSheet"; +import AuraGlowBackground from "@/src/components/aura/AuraGlowBackground"; +import { AuraButton, AuraText, AuraTopSafeAreaScrim } from "@/src/components/ui/auraStylePrimitives"; +import { AuraSkeleton, AuraSkeletonLine } from "@/src/components/ui/AuraSkeleton"; +import { AURA_TRAINING_ROUTE } from "@/src/constants/routes"; +import { DOCK_HEIGHT, FLOATING_CONTROL_GAP } from "@/src/constants/dock"; +import type { + AIMessage, + ChatImageAttachment, + ChatOutfit, +} from "@/src/components/ai/chatTypes"; import { useAuth } from "@/src/hooks/useAuth"; +import { useAuraChatHydration } from "@/src/hooks/aura/useAuraChatHydration"; +import { useAuraComposerState } from "@/src/hooks/aura/useAuraComposerState"; +import { useAuraStreamingState } from "@/src/hooks/aura/useAuraStreamingState"; import { useAppTheme } from "@/src/hooks/useAppTheme"; import { useResponsiveLayout } from "@/src/hooks/useResponsiveLayout"; -import { askAuraStream } from "@/src/lib/aura"; -import { auraLookToPlannedOutfit, saveAuraLook } from "@/src/lib/auraLooks"; -import { listenToItems } from "@/src/lib/items"; +import { askAuraStream, isAuraStreamAbortError } from "@/src/lib/aura"; +import { + analyticsErrorProperties, + type AnalyticsProperties, + trackLaunchEvent, +} from "@/src/lib/analytics"; +import { handleSharedAuraLookAction } from "@/src/lib/auraActions"; +import { + appendUniqueSystemMessage, + type AuraChatIntent, + type AuraOutfitDiversityContext, + buildAuraOutfitDiversityContext, + buildChatSeedText, + buildShareTranscript, + buildStructuredOutfitBatchPrompt, + classifyAuraChatIntent, + createStreamingAssistantMessage, + createSystemMessage, + createMessageId, + createUserMessageWithAttachments, + isConcreteOutfitRefinementRequest, + latestAuraLook, + latestAuraLookCount, + nextLocalMessageSequence, + resolveStructuredBatchLookCount, + updateMessageById, + wantsStructuredOutfitBatch, + wantsStructuredOutfitRequest, +} from "@/src/lib/auraChatHelpers"; +import { updateAuraSessionContextFromPrompt } from "@/src/lib/auraMemory"; +import { + type AuraCandidateLocalPhoto, + createAuraItemDraftsFromCandidates, + createAuraItemDraftsFromDetectedOutfit, + uploadAuraAttachments, +} from "@/src/lib/auraAttachments"; +import { classifyAuraImageIntent } from "@/src/lib/auraIntent"; +import { generateAuraSwipeBatch } from "@/src/lib/auraSwipe"; +import { runHaptic } from "@/src/lib/haptics"; +import { getItemImageUrl } from "@/src/lib/itemImage"; +import { listenToItems, updateLaundryStatus } from "@/src/lib/items"; +import { buildMinimumClosetSummary } from "@/src/lib/minimumCloset"; +import { formatOutfitAnalysisSentence } from "@/src/lib/auraOutfitAnalysisDisplay"; +import { loadUserProfilePreferences } from "@/src/lib/userProfile"; +import { + buildAdHocWardrobeSuggestion, + buildWardrobeSuggestions, + type WardrobeSuggestion, +} from "@/src/lib/wardrobeSuggestions"; +import { Toast } from "@/src/lib/toast"; +import { sanitizeMultilineDisplayText } from "@/src/lib/text"; +import { Colors } from "@/constants/theme"; import { appendMessageToChat, createChatThread, + deleteChatThread, loadChatMessages, - loadLatestChatThread, - loadRecentChatThreads, + renameChatThread, + setChatArchived, + setChatPinned, + summarizeChatTitle, type AIChatThread, + updateChatThread, } from "@/src/lib/aiChats"; -import { clearLatestChatCache, loadLatestChatCache, saveLatestChatCache } from "@/src/lib/localChatCache"; -import type { AuraResponse } from "@/src/types/aura"; +import { + clearLatestChatCache, + isAuraChatSessionFresh, + loadAuraChatSessionMeta, + saveAuraChatSessionMeta, + saveLatestChatCache, +} from "@/src/lib/localChatCache"; +import { messageOrderMillis, orderChatMessages } from "@/src/lib/chatMessageOrder"; +import { + clearCachedRecentMessages, + getCachedRecentMessages, +} from "@/src/lib/localCache"; +import type { AuraCandidateAction, AuraCandidateItem, AuraLaundryConfirmationAction, AuraLookAction, AuraLookOptionMeta, AuraResponse } from "@/src/types/aura"; import type { ClothingItem } from "@/src/types/ClothingItem"; -import { savePlannedRecord } from "@/src/utils/dailyOutfits"; +import type { UserProfilePreferences } from "@/src/types/UserProfilePreferences"; +import { markAnalyzedOutfitWorn } from "@/src/utils/dailyOutfits"; -const DEFAULT_CHIPS = [ - "Build a casual look", - "What should I buy first?", - "Help me style this", - "Give me style advice", +const TRAIN_AURA_CHIP_LABEL = "Train AURA faster"; +const AURA_TOP_CHIPS = [ + "Style me today", + "Top priorities", ]; +const DEFAULT_CHIPS = AURA_TOP_CHIPS.filter((chip) => chip !== TRAIN_AURA_CHIP_LABEL); +const AURA_LOGO_SOURCE = require("../../assets/images/aura-tab-mark.png"); -const STARTER_CARDS = [ - { - title: "Build a casual look", - subtitle: "Start from scratch or use what I already own", - prompt: "Build a casual look", - }, - { - title: "What should I buy first?", - subtitle: "Build a wardrobe that gets smarter fast", - prompt: "What should I buy first to build a stronger wardrobe?", - }, - { - title: "Plan an outfit for tonight", - subtitle: "Give me a sharp direction for tonight", - prompt: "Plan an outfit for tonight", - }, - { - title: "Find gaps in my closet", - subtitle: "Use what I own and show me what is missing", - prompt: "Find gaps in my closet", - }, -]; +const DEFAULT_COMPOSER_HEIGHT = 50; +const CHAT_COMPOSER_TAB_GAP = 6; +const CHAT_BOTTOM_BREATHING_ROOM = 128; +const STREAM_FLUSH_INTERVAL_MS = 32; +const AURA_REPLY_START_HAPTIC = "light" as const; +const AURA_REPLY_FINISH_HAPTIC = "selection" as const; +const AURA_OFFLINE_MESSAGE = "AURA couldn't finish that. Check your connection and try again."; +const AURA_DRAFT_FAILURE_MESSAGE = "I couldn't create that wardrobe draft. Please try again."; +const AURA_ATTACHMENT_FAILURE_MESSAGE = "I couldn't upload that attachment. Please try again."; +const RECENT_CHAT_LIMIT = 24; +const DEBUG_AURA_CLIENT = + __DEV__ && process.env.EXPO_PUBLIC_AURA_DEBUG === "1"; +const OUTERWEAR_REQUEST_RE = + /\b(with jacket|with jackets|jackets?|outerwear|coat|blazer|hoodie|cardigan|overshirt|layered|layers)\b/i; +const WARDROBE_BUYING_ADVICE_RE = + /\b(what\s+(?:should|do)\s+i\s+(?:buy|add|get)|what(?:'s| is)?\s+missing|closet\s+gaps?|wardrobe\s+gaps?|improve\s+(?:my\s+)?wardrobe|complete\s+(?:my\s+)?wardrobe|shopping\s+list|top\s+priorit(?:y|ies)|smart\s+buys?)\b/i; +const MANUAL_SELECTED_OUTFIT_RE = + /\b(improve|refine|fix|polish|style)\b[\s\S]{0,80}\b(manually built outfit|selected pieces|selected closet|from my closet)\b/i; +const RECENT_ITEM_ANCHOR_PROMPT_RE = + /\b(suggest|recommend|build|make|create|pull|put together|plan|style|dress|what should i wear|outfit|look|fit)\b/i; +const RECENT_WARDROBE_ADD_RE = + /\b(added|saved|imported)\b[\s\S]{0,50}\b(wardrobe|closet)\b/i; +const AURA_EMPTY_STATE_PROMPT = "Style me today"; +type AskAuraOptions = { + retryUserMessage?: AIMessage; + removeMessageId?: string; + forceOutfitDiversity?: boolean; + diversityMessages?: AIMessage[]; + requiredItemIds?: string[]; +}; -const consumedPromptTokens = new Set(); -const consumedChatTokens = new Set(); -const DEFAULT_COMPOSER_HEIGHT = 70; +function logAuraChatState(event: string, payload?: Record) { + if (!DEBUG_AURA_CLIENT) return; + console.log("[AURA_CHAT_STATE]", event, payload ?? {}); +} -function createMessageId() { - return `msg-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; +function auraLookCount(response: AuraResponse) { + return Math.max(response.look ? 1 : 0, response.lookOptions?.length ?? 0); } -function createUserMessage(text: string): AIMessage { - return { - id: createMessageId(), - type: "user", - kind: "user_text", - text, - createdAt: Date.now(), +function trackAuraResponseSucceeded(params: { + userId: string; + source: string; + response: AuraResponse; + startedAt: number; + properties?: AnalyticsProperties; +}) { + const lookCount = auraLookCount(params.response); + const baseProperties: AnalyticsProperties = { + source: params.source, + elapsedMs: Date.now() - params.startedAt, + lookCount, + hasLook: lookCount > 0, + candidateItemCount: + params.response.candidateItems?.length ?? + params.response.candidates?.length ?? + 0, + presentation: params.response.presentation ?? null, + ...(params.properties ?? {}), }; + void trackLaunchEvent({ + userId: params.userId, + eventName: "ai_response_succeeded", + properties: baseProperties, + }); + if (lookCount > 0) { + void trackLaunchEvent({ + userId: params.userId, + eventName: "outfit_generated", + properties: baseProperties, + }); + } +} + +function AuraChatEmptyState({ + onPrompt, + onTrainingPress, +}: { + onPrompt: (prompt: string) => void; + onTrainingPress: () => void; +}) { + const { colors } = useAppTheme(); + const layout = useResponsiveLayout(); + + return ( + + + + + + + + + Ask your stylist + + + Start with a look for today, train AURA, or type whatever you need. + + + + + onPrompt(AURA_EMPTY_STATE_PROMPT)} + variant="primary" + size="compact" + haptic="selection" + hapticTrigger="press" + pressedScale={0.97} + pressedOpacity={0.9} + style={{ minHeight: 42 }} + textStyle={{ fontSize: 13, lineHeight: 17 }} + /> + + + + ); +} + +function AuraChatLoadingState() { + const { colors } = useAppTheme(); + const layout = useResponsiveLayout(); + return ( + + + + + + + + + + + ); +} + +function deriveAssistantChatTitle(prompt: string, message: AIMessage) { + return summarizeChatTitle({ + userText: prompt, + assistantText: + message.aura?.title ?? + message.aura?.reply ?? + message.assistantIntroText ?? + message.text, + }); +} + +function candidateImageSet(candidate: AuraCandidateItem) { + return new Set( + [ + candidate.primaryImageUrl, + ...(candidate.imageUrls ?? []), + ...(candidate.secondaryImageUrls ?? []), + ] + .map((url) => String(url ?? "").trim()) + .filter(Boolean) + ); +} + +function cleanIntroText(value: unknown) { + return String(value ?? "").replace(/\s+/g, " ").trim(); +} + +function cleanStructuredText(value: unknown) { + return sanitizeMultilineDisplayText(String(value ?? "")); +} + +function buildAssistantCardIntro(data: AuraResponse, userRequest?: string) { + const request = cleanIntroText(userRequest).toLowerCase(); + const directOutfitQuestion = + !!data.outfitAnalysis && + /\b(what\s+am\s+i\s+wearing|what\s+is\s+this\s+outfit|what\s+do\s+you\s+see|identify)\b/i.test(request); + if (directOutfitQuestion) { + return formatOutfitAnalysisSentence(data.outfitAnalysis); + } + const reply = cleanStructuredText(data.reply); + if (reply) return reply; + + const candidateItems = data.candidateItems ?? data.candidates ?? []; + const hasCandidates = candidateItems.length > 0; + const hasLooks = !!data.look || !!data.lookOptions?.length; + const lookCount = data.lookOptions?.length ?? (data.look ? 1 : 0); + + if (hasCandidates) { + if (/\b(add|save|store|closet|wardrobe|item)\b/.test(request)) { + return "I found this item. Review it before I add it to your wardrobe."; + } + return "I found this item. Give it a quick review and I can take the next step."; + } + + if (data.outfitAnalysis) { + return `${formatOutfitAnalysisSentence(data.outfitAnalysis)} Review the pieces below before saving or adding them.`; + } + + if (hasLooks) { + if (/\bjacket|jackets|coat|coats|blazer|blazers|outerwear\b/.test(request)) { + return "Got you — I kept jackets as the main layer and built the outfits around them."; + } + if (/\bformal|cleaner|dressier|more formal|tailored|polished\b/.test(request)) { + return "Done — I pushed these cleaner and more formal while keeping them wearable."; + } + if (/\bstreetwear|street\b/.test(request)) { + return "Done — I made these lean more streetwear without losing balance."; + } + if (/\bsafe\b/.test(request) && /\bbalanced\b/.test(request) && /\bbold\b/.test(request)) { + return "Got you — I built a few directions so you can compare the safer, balanced, and bolder takes side by side."; + } + if (lookCount > 1) { + return "Got you — I built a few looks from your closet that match that direction."; + } + return "Got you — I pulled a look together that stays close to that direction."; + } + + if (data.wardrobeSuggestions?.length) { + return reply || "I found the highest-leverage wardrobe gaps to work on first."; + } + + if (data.presentation === "candidate_preview") { + return "I found this item. Review it before I add it to your wardrobe."; + } + + if (data.presentation === "laundry_confirmation") { + return reply || "Which item did you mean?"; + } + + if (/\bswipe|training|taste|learn\b/.test(request)) { + return "Here are a few quick outfit edits. Swipe through them so I can learn your taste."; + } + + return "Got you — here’s what I’d do."; +} + +function resolveLocalPhotosForAuraCandidates( + messages: AIMessage[], + sourceMessage: AIMessage, + candidates: AuraCandidateItem[] +): Record { + const previousUserMessages = messages + .filter( + (message) => + message.type === "user" && + message.createdAt <= sourceMessage.createdAt && + message.attachments?.some((attachment) => attachment.type === "image") + ) + .sort((a, b) => b.createdAt - a.createdAt); + const imageAttachments = previousUserMessages.flatMap((message) => + (message.attachments ?? []).filter( + (attachment): attachment is ChatImageAttachment => attachment.type === "image" + ) + ); + const localByCandidateId: Record = {}; + + for (const candidate of candidates) { + if (candidate.sourceType === "link") { + localByCandidateId[candidate.candidateId] = undefined; + continue; + } + const urls = candidateImageSet(candidate); + const matchedAttachment = + imageAttachments.find((attachment) => urls.has(String(attachment.uri ?? "").trim())) ?? + (candidates.length === 1 && imageAttachments.length === 1 ? imageAttachments[0] : undefined); + if (!matchedAttachment?.localUri) continue; + localByCandidateId[candidate.candidateId] = { + localUri: matchedAttachment.localUri, + attachmentUri: matchedAttachment.uri, + width: matchedAttachment.width ?? null, + height: matchedAttachment.height ?? null, + }; + } + + return localByCandidateId; +} + +function resolveOutfitSourcePhoto(messages: AIMessage[], sourceMessage: AIMessage) { + const previousUserMessages = messages + .filter( + (message) => + message.type === "user" && + message.createdAt <= sourceMessage.createdAt && + message.attachments?.some((attachment) => attachment.type === "image") + ) + .sort((a, b) => b.createdAt - a.createdAt); + return previousUserMessages + .flatMap((message) => message.attachments ?? []) + .find((attachment): attachment is ChatImageAttachment => attachment.type === "image"); +} + +function hasRenderableAuraLook(look?: AuraResponse["look"] | null) { + return Boolean( + look?.pieces?.some((piece) => cleanIntroText(piece.itemName)), + ); +} + +function describeAuraLookForRefinement(look?: AuraResponse["look"] | null) { + if (!look) return ""; + const pieces = (look.pieces ?? []) + .map((piece) => [piece.role, piece.itemName].filter(Boolean).join(": ")) + .filter(Boolean) + .slice(0, 8) + .join("; "); + return [look.lookTitle, pieces].filter(Boolean).join(" — "); +} + +function formatAuraLookForClipboard(look?: AuraResponse["look"] | null) { + if (!look) return ""; + const pieces = (look.pieces ?? []) + .map((piece) => [piece.role, piece.itemName].filter(Boolean).join(": ")) + .filter(Boolean) + .join("\n"); + return [look.lookTitle, look.vibe, pieces].filter(Boolean).join("\n"); +} + +function formatAuraCandidatesForClipboard(candidates: AuraCandidateItem[]) { + return candidates + .map((candidate) => + [ + candidate.title, + candidate.brand, + [candidate.category, candidate.subCategory].filter(Boolean).join(" / "), + candidate.sourceUrl, + ] + .filter(Boolean) + .join(" - "), + ) + .filter(Boolean) + .join("\n"); } -function createAssistantMessage(data: AuraResponse, overrides?: Partial): AIMessage { +function messageTextForClipboard(message: AIMessage) { + const parts = [ + message.assistantIntroText, + message.text, + ]; + const aura = message.aura; + if (aura) { + if (aura.outfitAnalysis) parts.push(formatOutfitAnalysisSentence(aura.outfitAnalysis)); + if (aura.look) parts.push(formatAuraLookForClipboard(aura.look)); + if (aura.lookOptions?.length) parts.push(aura.lookOptions.map(formatAuraLookForClipboard).filter(Boolean).join("\n\n")); + const candidates = aura.candidateItems ?? aura.candidates ?? []; + if (candidates.length) parts.push(formatAuraCandidatesForClipboard(candidates)); + if (aura.wardrobeSuggestions?.length) { + parts.push( + aura.wardrobeSuggestions + .map((suggestion) => + [ + suggestion.itemType, + suggestion.reason, + suggestion.styleTags?.length ? suggestion.styleTags.join(", ") : null, + ] + .filter(Boolean) + .join(" - "), + ) + .join("\n"), + ); + } + } + return sanitizeMultilineDisplayText(parts.filter(Boolean).join("\n\n")) || ""; +} + +function createAssistantMessage( + data: AuraResponse, + overrides?: Partial, + options?: { userRequest?: string } +): AIMessage { + const candidateItems = data.candidateItems ?? data.candidates ?? []; + const surfaceData = candidateItems.length + ? { + ...data, + presentation: "candidate_preview" as const, + candidateItems, + candidates: candidateItems, + } + : data; + const normalizedData = { + ...surfaceData, + look: hasRenderableAuraLook(surfaceData.look) ? surfaceData.look : null, + lookOptions: surfaceData.lookOptions?.filter(hasRenderableAuraLook), + }; const shouldUseCard = - data.presentation === "card" || - !!data.look || - ((!!data.outfitItems?.length || - !!data.ownedPieces?.length || - !!data.recommendedAdditions?.length) && - (!!data.reason?.trim() || !!data.swapSuggestion?.trim())); + normalizedData.presentation === "card" || + normalizedData.presentation === "candidate_preview" || + normalizedData.presentation === "laundry_confirmation" || + normalizedData.presentation === "outfit_analysis" || + !!normalizedData.outfitAnalysis || + !!normalizedData.look || + !!normalizedData.lookOptions?.length || + !!normalizedData.wardrobeSuggestions?.length || + !!candidateItems.length || + ((!!normalizedData.outfitItems?.length || + !!normalizedData.ownedPieces?.length || + !!normalizedData.recommendedAdditions?.length) && + (!!normalizedData.reason?.trim() || !!normalizedData.swapSuggestion?.trim())); + if (DEBUG_AURA_CLIENT) { + console.log("[AURA_FRONTEND_PAYLOAD]", "createAssistantMessage payload", { + messageId: overrides?.id ?? null, + presentation: normalizedData.presentation, + hasLook: !!normalizedData.look, + lookOptionsCount: normalizedData.lookOptions?.length ?? 0, + candidateItemsCount: candidateItems.length, + shouldUseCard, + replyPreview: String(normalizedData.reply ?? "").slice(0, 120), + }); + } + if (DEBUG_AURA_CLIENT && candidateItems.length) { + console.log("[AURA_PARSE]", "creating assistant candidate message", { + candidateCount: candidateItems.length, + kind: shouldUseCard ? "aura_card" : "aura_text", + presentation: normalizedData.presentation, + rawKeys: Object.keys(data), + }); + } + const assistantText = shouldUseCard + ? buildAssistantCardIntro(normalizedData, options?.userRequest) + : cleanStructuredText(normalizedData.reply); + const createdAt = overrides?.createdAt ?? Date.now(); return { id: overrides?.id ?? createMessageId(), type: "assistant", kind: shouldUseCard ? "aura_card" : "aura_text", - text: data.reply, + text: assistantText || normalizedData.reply, + assistantIntroText: shouldUseCard ? assistantText || undefined : undefined, streaming: overrides?.streaming, - aura: shouldUseCard ? data : undefined, - createdAt: overrides?.createdAt ?? Date.now(), + aura: shouldUseCard ? normalizedData : undefined, + createdAt, + clientCreatedAt: + overrides?.clientCreatedAt ?? + createdAt, + localSequence: overrides?.localSequence, + replyToMessageId: overrides?.replyToMessageId ?? null, + }; +} + +function auraLookItemIds(look?: AuraResponse["look"] | null) { + const seen = new Set(); + const ids: string[] = []; + for (const piece of look?.pieces ?? []) { + const itemId = String(piece.itemId ?? "").trim(); + if (piece.source !== "closet" || !itemId || seen.has(itemId)) continue; + seen.add(itemId); + ids.push(itemId); + } + return ids; +} + +function auraLookSignature(look?: AuraResponse["look"] | null) { + return auraLookItemIds(look).sort().join("|"); +} + +function auraLookPreviousOverlap( + look: NonNullable, + diversity: AuraOutfitDiversityContext +) { + const previousIds = new Set(diversity.previousLookItemIds); + return auraLookItemIds(look).filter((itemId) => previousIds.has(itemId)).length; +} + +function enforceClientOutfitDiversity( + response: AuraResponse, + diversity: AuraOutfitDiversityContext +): AuraResponse { + if (!diversity.shouldAvoidRepeats) return response; + const originalLooks = [ + ...(response.lookOptions ?? []), + ...(response.look && !(response.lookOptions ?? []).includes(response.look) ? [response.look] : []), + ]; + if (!originalLooks.length) return response; + + const selected: NonNullable[] = []; + const selectedSignatures = new Set(); + const candidates = originalLooks.map((look) => { + const itemIds = auraLookItemIds(look); + const signature = itemIds.slice().sort().join("|"); + const previousOverlap = auraLookPreviousOverlap(look, diversity); + const exactPrevious = !!signature && diversity.previousLookSignatures.includes(signature); + const duplicateWithinResponse = !!signature && selectedSignatures.has(signature); + const selectedOverlapTooHigh = selected.some((existing) => { + const existingIds = new Set(auraLookItemIds(existing)); + const overlap = itemIds.filter((itemId) => existingIds.has(itemId)).length; + return overlap > Math.max(1, diversity.maxOverlap); + }); + const tooSimilar = exactPrevious || previousOverlap > diversity.maxOverlap; + const rejected = tooSimilar || duplicateWithinResponse || selectedOverlapTooHigh; + if (!rejected) { + selected.push(look); + if (signature) selectedSignatures.add(signature); + } + return { + title: look.lookTitle, + itemIds, + signature, + previousOverlap, + exactPrevious, + duplicateWithinResponse, + selectedOverlapTooHigh, + rejected, + }; + }); + + if (DEBUG_AURA_CLIENT) { + console.log("[AURA_DIVERSITY]", "frontend final guard", { + previousItemIds: diversity.previousLookItemIds, + excludedItemIds: diversity.excludedItemIds, + recentItemIds: diversity.recentItemIds, + previousLookSignatures: diversity.previousLookSignatures, + maxOverlap: diversity.maxOverlap, + candidates, + selected: selected.map((look) => ({ + title: look.lookTitle, + itemIds: auraLookItemIds(look), + signature: auraLookSignature(look), + overlap: auraLookPreviousOverlap(look, diversity), + })), + }); + } + + if (!selected.length) { + return { + title: "More pieces needed", + presentation: "chat", + reply: "I need more usable pieces to make this meaningfully different.", + reason: "AURA avoided repeating the previous outfit.", + outfitItems: [], + ownedPieces: [], + recommendedAdditions: [], + swapSuggestion: "", + missingPieces: [], + upgradeSuggestions: [], + chips: response.chips?.length ? response.chips : DEFAULT_CHIPS, + look: null, + lookOptions: [], + }; + } + + if (response.lookOptions?.length) { + const lookOptions = selected.slice(0, response.lookOptions.length); + return { + ...response, + look: lookOptions[0] ?? null, + lookOptions, + }; + } + + return { + ...response, + look: selected[0] ?? response.look ?? null, + }; +} + +function labelForLookPiece(look: NonNullable, role: string) { + return look.pieces.find((piece) => piece.role === role && piece.source === "closet")?.itemName ?? ""; +} + +function compactLookBase(look: NonNullable) { + const top = labelForLookPiece(look, "top"); + const bottom = labelForLookPiece(look, "bottom"); + const shoes = labelForLookPiece(look, "shoes"); + const outerwear = labelForLookPiece(look, "outerwear"); + return [top, bottom, shoes, outerwear].filter(Boolean).join(", "); +} + +function buildNoExistingOutfitResponse(): AuraResponse { + return { + title: "No outfit yet", + presentation: "chat", + reply: "I don’t have an outfit yet — want me to create one first?", + reason: "", + outfitItems: [], + ownedPieces: [], + recommendedAdditions: [], + swapSuggestion: "", + chips: ["Give me an outfit", "Style me today", "Build from my closet"], + }; +} + +function buildExistingOutfitStylingResponse( + prompt: string, + look: NonNullable, + intent: AuraChatIntent, +): AuraResponse { + const normalized = prompt.toLowerCase(); + const top = labelForLookPiece(look, "top"); + const bottom = labelForLookPiece(look, "bottom"); + const shoes = labelForLookPiece(look, "shoes"); + const base = compactLookBase(look); + const anchor = top || bottom || look.lookTitle || "the strongest piece"; + const baseLine = base ? `Base: ${base}.` : `Base: ${look.lookTitle}.`; + const pieceRelationship = + top && bottom + ? `Keep ${top} and ${bottom} as the main relationship.` + : "Keep the main pieces visually connected."; + + let title = "How to style it"; + let reply = [ + "My call:", + `${baseLine} Keep ${anchor} as the anchor.`, + "", + "Why:", + "- Keep the proportions clean and avoid adding extra focal points.", + `- ${pieceRelationship}`, + "", + "Do this:", + "- Change one thing only: cleaner shoes for polish, a relaxed shoe for ease, or a simple layer if the weather needs it.", + "", + "Styling note:", + "The outfit should feel edited, not busy.", + ].join("\n"); + + if (/\b(dressier|formal|sharper|office|work)\b/.test(normalized)) { + title = "Make it dressier"; + reply = [ + "My call:", + "Make one polished swap and keep the base intact.", + "", + "Keep:", + `- ${anchor} as the base.`, + `- ${pieceRelationship}`, + "", + "Swap / add:", + `- ${shoes ? `Trade ${shoes} for loafers, boots, or your cleanest low-profile shoes.` : "Use your cleanest low-profile shoes."}`, + "- Add a structured jacket, watch, or simple chain if you own one.", + "", + "Why:", + "- Cleaner lines make the same outfit read more intentional.", + "- One polished swap raises the formality without rebuilding the look.", + "", + "Do this:", + "The goal is refined, not overdressed.", + ].join("\n"); + } else if (/\b(casual|relaxed|easy|everyday)\b/.test(normalized)) { + title = "Make it more casual"; + reply = [ + "My call:", + "Soften the styling without changing the core outfit.", + "", + "Keep:", + `- ${anchor} as the anchor.`, + `- ${pieceRelationship}`, + "", + "Swap / add:", + `- ${shoes ? `Keep ${shoes} if they feel easy, or swap to a softer sneaker.` : "Use a softer sneaker."}`, + "- Skip heavy accessories and add a light overshirt only if it needs shape.", + "", + "Why:", + "- Relaxed lines make the outfit feel more natural for everyday wear.", + "- Keeping one clear anchor stops the casual version from looking random.", + "", + "Do this:", + "Make it easier, not sloppier.", + ].join("\n"); + } else if (/\b(streetwear|bold|bolder|statement|edge)\b/.test(normalized)) { + title = "Push the styling"; + reply = [ + "My call:", + "Push the silhouette, not the whole outfit.", + "", + "Keep:", + `- ${anchor} as the attitude piece.`, + `- ${pieceRelationship}`, + "", + "Swap / add:", + "- Add contrast through proportion: a stronger layer, chunkier shoe, or one statement accessory.", + "- Keep the color story tight so the bolder styling does not get crowded.", + "", + "Why:", + "- Streetwear reads best when the silhouette has confidence.", + "- One statement is stronger than several competing ones.", + "", + "Do this:", + "Push the shape first, then the accessories.", + ].join("\n"); + } else if (intent === "MODIFY_OUTFIT") { + title = "Refine this look"; + reply = [ + "My call:", + "Refine one anchor-adjacent piece instead of rebuilding the look.", + "", + "Keep:", + `- The core outfit intact, especially ${anchor}.`, + `- ${pieceRelationship}`, + "", + "Swap / add:", + "- Adjust one anchor-adjacent piece: shoes, outerwear, or one accessory.", + "- Leave the rest alone so the outfit still feels deliberate.", + "", + "Why:", + "- Small edits preserve the strongest parts of the look.", + "- Changing one piece gives you a clearer mood shift than rebuilding everything.", + "", + "Do this:", + "This needs a sharper edit, not a full reset.", + ].join("\n"); + } + const attachedLook: NonNullable = { + ...look, + lookTitle: + title === "Make it dressier" + ? `Dressier ${look.lookTitle || "closet look"}` + : title === "Make it more casual" + ? `Relaxed ${look.lookTitle || "closet look"}` + : look.lookTitle || title, + vibe: + title === "Make it dressier" + ? "dressier closet edit" + : title === "Make it more casual" + ? "casual closet edit" + : look.vibe || "closet edit", + shortExplanation: + title === "Make it dressier" + ? "AURA is keeping the same closet pieces visible while styling them in a cleaner direction." + : title === "Make it more casual" + ? "AURA is keeping the same closet pieces visible while softening the styling." + : look.shortExplanation || "AURA is using the current look as the styling context.", + stylingNote: + title === "Make it dressier" + ? "Use cleaner finishing details so the outfit reads sharper without pretending you own different pieces." + : title === "Make it more casual" + ? "Relax the styling details and keep the outfit easy." + : look.stylingNote || "Use the card below as the current outfit context.", + pieces: (look.pieces ?? []).map((piece) => ({...piece})), + fromCloset: [...(look.fromCloset ?? [])], + addToComplete: [...(look.addToComplete ?? [])], + alternates: [...(look.alternates ?? [])], + actions: look.actions?.length ? look.actions : ["likeLook", "notMyVibe", "showMoreLikeThis", "makeItDressier"], + }; + + return { + title, + presentation: attachedLook.pieces.length ? "card" : "chat", + reply, + reason: "", + outfitItems: attachedLook.fromCloset, + ownedPieces: attachedLook.fromCloset, + recommendedAdditions: attachedLook.addToComplete, + swapSuggestion: "", + chips: ["Make it dressier", "Make it more casual", "Give me another one"], + look: attachedLook.pieces.length ? attachedLook : null, + }; +} + +function promptRequestsOuterwear(prompt: string) { + return OUTERWEAR_REQUEST_RE.test(String(prompt ?? "")); +} + +function promptRequestsWardrobeBuyingAdvice(prompt: string) { + return WARDROBE_BUYING_ADVICE_RE.test(String(prompt ?? "")); +} + +function promptHasManualSelectedOutfit(prompt: string) { + const text = String(prompt ?? ""); + return ( + /\bcloset item id:|selected item ids?:/i.test(text) && + (MANUAL_SELECTED_OUTFIT_RE.test(text) || /\bstyle this closet item\b/i.test(text)) + ); +} + +function uniquePromptItemIds(values: string[], max = 3) { + const seen = new Set(); + const ids: string[] = []; + for (const value of values) { + const id = String(value ?? "").trim().replace(/[),.;]+$/g, ""); + if (!id || seen.has(id)) continue; + seen.add(id); + ids.push(id); + if (ids.length >= max) break; + } + return ids; +} + +function selectedItemIdsFromPrompt(prompt: string) { + const ids: string[] = []; + for (const match of String(prompt ?? "").matchAll(/closet item id:\s*([^)\\\n;]+)/gi)) { + ids.push(match[1]); + } + const selectedIdsLine = String(prompt ?? "").match(/selected item ids?:\s*([^\n]+)/i)?.[1] ?? ""; + ids.push(...selectedIdsLine.split(/[,|]/g)); + return uniquePromptItemIds(ids); +} + +function requiredItemIdsFromRouteParam(value: unknown) { + const raw = Array.isArray(value) ? value.join(",") : String(value ?? ""); + return uniquePromptItemIds(raw.split(/[,|]/g), 8); +} + +function stripInternalItemIdsFromUserPrompt(prompt: string) { + const rawPrompt = String(prompt ?? ""); + const text = rawPrompt + .split("\n") + .filter((line) => !/^\s*(closet item id|required anchor item|selected item ids?)\s*:/i.test(line)) + .join("\n") + .replace(/\s*\((?:closet item id|item id):\s*[^)]+\)/gi, "") + .replace(/\bcloset item id:\s*[^\n.;]+[.;]?/gi, "") + .replace(/\brequired anchor item:\s*[^\n]+/gi, "") + .replace(/\bselected item ids?:\s*[^\n]+/gi, "") + .replace(/\n{3,}/g, "\n\n") + .trim(); + return text || (rawPrompt.trim() ? "Style this item with AURA." : ""); +} + +function lookIncludesRequiredItemIds( + look: import("@/src/types/aura").AuraLook | null | undefined, + requiredItemIds: string[], +) { + if (!requiredItemIds.length || !look) return true; + const itemIds = new Set( + (look.pieces ?? []) + .filter((piece) => piece.source === "closet") + .map((piece) => String(piece.itemId ?? "").trim()) + .filter(Boolean), + ); + return requiredItemIds.every((itemId) => itemIds.has(itemId)); +} + +function enforceRequiredItemsOnAuraResponse( + response: AuraResponse, + requiredItemIds: string[], +) { + if (!requiredItemIds.length) return response; + const lookOptions = (response.lookOptions ?? []).filter((look) => + lookIncludesRequiredItemIds(look, requiredItemIds), + ); + const look = + lookIncludesRequiredItemIds(response.look, requiredItemIds) + ? response.look + : lookOptions[0] ?? null; + return { + ...response, + look, + lookOptions, + }; +} + +function buildRequiredItemUnavailableResponse(): AuraResponse { + return { + presentation: "chat", + title: "Item unavailable", + reply: + "I couldn't find that closet item anymore, so I won't generate a random outfit around a substitute. Try reopening the item from your closet and I’ll style that exact piece.", + reason: "", + outfitItems: [], + ownedPieces: [], + recommendedAdditions: [], + swapSuggestion: "", + missingPieces: [], + upgradeSuggestions: [], + upgradeSuggestionItems: [], + chips: DEFAULT_CHIPS, + look: null, + lookOptions: [], + }; +} + +function buildRequiredItemNoOutfitResponse(): AuraResponse { + return { + presentation: "chat", + title: "Couldn’t build that anchored outfit", + reply: + "I found the selected item, but couldn't build a complete outfit around it from the current closet state. I didn't substitute another piece.", + reason: "", + outfitItems: [], + ownedPieces: [], + recommendedAdditions: [], + swapSuggestion: "", + missingPieces: [], + upgradeSuggestions: [], + upgradeSuggestionItems: [], + chips: DEFAULT_CHIPS, + look: null, + lookOptions: [], + }; +} + +function timestampMillis(value: unknown) { + if (typeof value === "number" && Number.isFinite(value)) return value; + if (!value || typeof value !== "object") return 0; + const timestamp = value as { toMillis?: () => number; seconds?: number; nanoseconds?: number }; + if (typeof timestamp.toMillis === "function") { + const millis = timestamp.toMillis(); + return Number.isFinite(millis) ? millis : 0; + } + if (typeof timestamp.seconds === "number") { + return timestamp.seconds * 1000 + Math.floor((timestamp.nanoseconds ?? 0) / 1000000); + } + return 0; +} + +function itemFreshnessMillis(item: ClothingItem) { + const updatedAt = timestampMillis((item as { updatedAt?: unknown }).updatedAt); + const createdAt = timestampMillis(item.createdAt); + return Math.max(updatedAt, createdAt); +} + +function hasRecentWardrobeAddSignal(messages: AIMessage[]) { + return messages.slice(-12).some((entry) => { + if (RECENT_WARDROBE_ADD_RE.test(String(entry.text ?? ""))) return true; + const candidates = entry.aura?.candidateItems ?? entry.aura?.candidates ?? []; + return candidates.some((candidate) => candidate.status === "added"); + }); +} + +function recentOutfitAnchorItemIds( + prompt: string, + items: ClothingItem[], + messages: AIMessage[], +) { + if (!RECENT_ITEM_ANCHOR_PROMPT_RE.test(String(prompt ?? ""))) return []; + const now = Date.now(); + const sawRecentAdd = hasRecentWardrobeAddSignal(messages); + const recentWindowMs = sawRecentAdd ? 6 * 60 * 60 * 1000 : 20 * 60 * 1000; + const freshest = items + .map((item) => ({ + itemId: item.id, + timestamp: itemFreshnessMillis(item), + })) + .filter((entry) => entry.itemId && entry.timestamp > 0 && now - entry.timestamp <= recentWindowMs) + .sort((a, b) => b.timestamp - a.timestamp)[0]; + return freshest ? [freshest.itemId] : []; +} + +function buildWardrobeBaseLine(items: ClothingItem[]) { + const categoryCounts = items.reduce( + (counts, item) => { + const category = String(item.category ?? "").toLowerCase(); + if (/\bhoodie|tee|t-?shirt|shirt|top|sweater|sweatshirt|polo\b/.test(`${category} ${item.name ?? ""} ${item.subCategory ?? ""}`.toLowerCase())) { + counts.tops += 1; + } else if (/\bjean|denim|pant|trouser|short|bottom|skirt\b/.test(`${category} ${item.name ?? ""} ${item.subCategory ?? ""}`.toLowerCase())) { + counts.bottoms += 1; + } else if (/\bshoe|sneaker|boot|loafer|footwear\b/.test(`${category} ${item.name ?? ""} ${item.subCategory ?? ""}`.toLowerCase())) { + counts.footwear += 1; + } else if (/\bjacket|coat|outerwear|hoodie|blazer|overshirt\b/.test(`${category} ${item.name ?? ""} ${item.subCategory ?? ""}`.toLowerCase())) { + counts.outerwear += 1; + } + return counts; + }, + { tops: 0, bottoms: 0, footwear: 0, outerwear: 0 }, + ); + const strengths = [ + categoryCounts.tops > 1 ? "tops" : "", + categoryCounts.bottoms > 1 ? "bottoms" : "", + categoryCounts.footwear > 1 ? "shoes" : "", + categoryCounts.outerwear > 1 ? "layers" : "", + ].filter(Boolean); + + if (!items.length) { + return "Your closet is still early, which is useful: the next piece can shape the whole system."; + } + if (strengths.length >= 2) { + return `Your closet already has a real base, especially around ${strengths.slice(0, 3).join(", ")}.`; + } + if (strengths.length === 1) { + return `Your closet already has a starting point, especially around ${strengths[0]}.`; + } + return "Your closet already has a few useful pieces; the next move is making them easier to style together."; +} + +function buildWardrobeSuggestionReply(suggestions: WardrobeSuggestion[], items: ClothingItem[]) { + const first = suggestions[0]; + if (!first) { + return [ + "My call:", + "Your core wardrobe looks covered right now.", + "", + "Top priorities:", + "1. Wear the strongest pieces you already own.", + "2. Repeat the outfits that feel easiest.", + "3. Only add a piece when a real outfit keeps asking for it.", + "", + "Do this:", + "Build from your closet first, then shop with a specific gap in mind.", + ].join("\n"); + } + const estimate = Math.max(0, Math.round(first.outfitsUnlockedEstimate)); + const topSuggestions = suggestions.slice(0, 3); + const priorityLines = topSuggestions.map((suggestion, index) => `${index + 1}. ${suggestion.itemType}`); + const whyLines = topSuggestions.map((suggestion) => { + const reason = suggestion.reason.replace(/\s*Estimated impact:.*$/i, "").trim(); + return `- ${reason || `${suggestion.itemType} unlocks more outfit paths.`}`; + }); + return [ + "My call:", + `${buildWardrobeBaseLine(items)} Start with ${first.itemType}; it is the highest-leverage gap.`, + "", + "Top priorities:", + ...priorityLines, + "", + "Why these help:", + ...whyLines, + "", + "Do this:", + estimate > 0 + ? `Find one strong ${first.itemType} before lower-impact buys; it should unlock about ${estimate} new outfit${estimate === 1 ? "" : "s"}.` + : `Find one strong ${first.itemType} before adding anything lower impact.`, + ].join("\n").replace(/\n{3,}/g, "\n\n").trim(); +} + +function buildWardrobeSuggestionAuraResponse(suggestions: WardrobeSuggestion[], items: ClothingItem[]): AuraResponse { + if (!suggestions.length) { + return { + title: "Closet gaps", + presentation: "chat", + reply: buildWardrobeSuggestionReply(suggestions, items), + reason: "", + outfitItems: [], + ownedPieces: [], + recommendedAdditions: [], + swapSuggestion: "", + missingPieces: [], + upgradeSuggestions: [], + upgradeSuggestionItems: [], + wardrobeSuggestions: [], + chips: ["Build from my closet", "Use unworn pieces", "Make it dressier"], + }; + } + + const first = suggestions[0]; + return { + title: "What to add next", + presentation: "card", + reply: buildWardrobeSuggestionReply(suggestions, items), + reason: `Start with ${first.itemType}. It is the cleanest gap to fill first, and it gives AURA more range without making the closet feel cluttered.`, + outfitItems: [], + ownedPieces: [], + recommendedAdditions: suggestions.map((suggestion) => suggestion.itemType), + swapSuggestion: "", + missingPieces: suggestions.map((suggestion) => suggestion.itemType), + upgradeSuggestions: suggestions.slice(1).map((suggestion) => suggestion.itemType), + upgradeSuggestionItems: suggestions.map((suggestion) => ({ + label: suggestion.itemType, + searchQuery: suggestion.itemType, + })), + wardrobeSuggestions: suggestions, + chips: ["Find options", "Use only my closet", "Build outfits first"], + }; +} + +function lookHasOuterwear(look?: AuraResponse["look"] | null) { + return Boolean( + look?.pieces?.some((piece) => { + const tokens = `${String(piece.role ?? "")} ${String(piece.itemName ?? "")}`.toLowerCase(); + return ( + piece.role === "outerwear" || + /\b(jacket|coat|outerwear|overshirt|blazer|hoodie|cardigan|shacket|trench|parka|bomber)\b/.test(tokens) + ); + }), + ); +} + +function closetHasOuterwear(items: ClothingItem[]) { + return items.some((item) => { + const tokens = [ + String(item.category ?? ""), + String(item.subCategory ?? ""), + String(item.type ?? ""), + String(item.name ?? ""), + ] + .join(" ") + .toLowerCase(); + + return ( + String(item.category ?? "").trim().toLowerCase() === "outerwear" || + /\b(jacket|coat|outerwear|overshirt|blazer|hoodie|cardigan|shacket|trench|parka|bomber)\b/.test(tokens) + ); + }); +} + +function countClosetOuterwear(items: ClothingItem[]) { + return items.filter((item) => { + const tokens = [ + String(item.category ?? ""), + String(item.subCategory ?? ""), + String(item.type ?? ""), + String(item.name ?? ""), + ] + .join(" ") + .toLowerCase(); + + return ( + String(item.category ?? "").trim().toLowerCase() === "outerwear" || + /\b(jacket|coat|outerwear|overshirt|blazer|hoodie|cardigan|shacket|trench|parka|bomber)\b/.test(tokens) + ); + }).length; +} + +function listClosetOuterwear(items: ClothingItem[]) { + return items.filter((item) => { + const tokens = [ + String(item.category ?? ""), + String(item.subCategory ?? ""), + String(item.type ?? ""), + String(item.name ?? ""), + ] + .join(" ") + .toLowerCase(); + + return ( + String(item.category ?? "").trim().toLowerCase() === "outerwear" || + /\b(jacket|coat|outerwear|overshirt|blazer|hoodie|cardigan|shacket|trench|parka|bomber|denim jacket)\b/.test(tokens) + ); + }); +} + +function buildOuterwearPiece(item: ClothingItem) { + const imageUrl = getItemImageUrl(item, { variant: "thumb" }); + + return { + role: "outerwear" as const, + itemName: + String(item.name ?? "").trim() || + String(item.subCategory ?? "").trim() || + String(item.type ?? "").trim() || + "Outerwear piece", + source: "closet" as const, + itemId: item.id, + imageUrl, + }; +} + +function repairLookForOuterwearRequirement( + look: NonNullable, + outerwearPool: ClothingItem[], + usedOuterwearIds: Set, +) { + if (lookHasOuterwear(look) || outerwearPool.length === 0) return look; + + const chosen = + outerwearPool.find((item) => !usedOuterwearIds.has(item.id)) ?? + outerwearPool[0]; + if (!chosen) return look; + usedOuterwearIds.add(chosen.id); + const outerwearPiece = buildOuterwearPiece(chosen); + const fromCloset = Array.from( + new Set([outerwearPiece.itemName, ...(look.fromCloset ?? [])].filter(Boolean)), + ); + const lookTitle = String(look.lookTitle ?? "").trim(); + const nextTitle = lookTitle.toLowerCase().includes(outerwearPiece.itemName.toLowerCase()) + ? lookTitle + : `${outerwearPiece.itemName} + ${lookTitle || "Layered outfit"}`; + + return { + ...look, + lookTitle: nextTitle, + stylingIntelligence: null, + stylingNote: look.stylingNote + ? `${look.stylingNote} Layer in ${outerwearPiece.itemName.toLowerCase()} to complete the silhouette.` + : `Layer in ${outerwearPiece.itemName.toLowerCase()} to complete the silhouette.`, + pieces: [outerwearPiece, ...(look.pieces ?? [])], + fromCloset, + }; +} + +function ensureOuterwearLooks( + lookOptions: NonNullable, + items: ClothingItem[], +) { + const outerwearPool = listClosetOuterwear(items); + const usedOuterwearIds = new Set(); + return lookOptions.map((look) => + repairLookForOuterwearRequirement(look, outerwearPool, usedOuterwearIds), + ); +} + +function buildAuraResponseFromSwipeBatch( + batch: Awaited>, + options?: { prompt?: string; items?: ClothingItem[] }, +): AuraResponse { + const wantsOuterwear = promptRequestsOuterwear(options?.prompt ?? ""); + const availableOuterwearCount = countClosetOuterwear(options?.items ?? []); + const repairedLookOptions = + wantsOuterwear && availableOuterwearCount > 0 + ? ensureOuterwearLooks(batch.lookOptions.map((entry) => entry.look), options?.items ?? []) + : batch.lookOptions.map((entry) => entry.look); + const lookOptions = repairedLookOptions; + const primaryLook = lookOptions[0] ?? null; + const count = lookOptions.length; + const hasOuterwearLooks = lookOptions.some((look) => lookHasOuterwear(look)); + const hasOuterwearInCloset = closetHasOuterwear(options?.items ?? []); + if (__DEV__) { + console.log("[AURA_OUTERWEAR]", "frontend structured batch summary", { + prompt: options?.prompt ?? "", + requiresOuterwear: wantsOuterwear, + availableOuterwearCount, + finalLookIds: batch.lookOptions.map((entry) => entry.id), + lookOuterwearCounts: lookOptions.map((look, index) => ({ + index, + count: look.pieces?.filter((piece) => piece.role === "outerwear").length ?? 0, + title: look.lookTitle, + })), + }); + } + return { + presentation: "card", + title: + wantsOuterwear && hasOuterwearLooks + ? count > 1 + ? `${count} Jacket Options` + : "Jacket Option" + : count > 1 + ? `${count} Outfit Options` + : "Outfit Option", + reply: + wantsOuterwear && !hasOuterwearLooks && !hasOuterwearInCloset + ? "A jacket or layer would open this up. I’ll keep the current looks to pieces you already own." + : wantsOuterwear && !hasOuterwearLooks + ? "I couldn't build reliable jacket looks from the current generator, so I repaired the closest structured options with outerwear from your closet." + : count > 1 + ? `Here are ${count} structured options from your closet.` + : "Here’s one structured option from your closet.", + reason: "", + outfitItems: primaryLook?.fromCloset ?? [], + ownedPieces: primaryLook?.fromCloset ?? [], + recommendedAdditions: primaryLook?.addToComplete ?? [], + swapSuggestion: "", + stylingIntelligence: primaryLook?.stylingIntelligence ?? null, + chips: [ + "Show me 3 more", + "Make them more formal", + "Push it more streetwear", + "Use only basics", + ], + look: primaryLook, + lookOptions, }; } +function summarizeAuraLookForHistory(look: NonNullable, index?: number) { + const title = cleanIntroText(look.lookTitle || (index ? `Look ${index}` : "Current look")); + const pieces = (look.pieces ?? []) + .slice(0, 6) + .map((piece) => { + const role = cleanIntroText(piece.role); + const name = cleanIntroText(piece.itemName); + const source = piece.source === "closet" ? "owned" : "suggested"; + const itemId = cleanIntroText(piece.itemId); + return [role, name, source, itemId ? `id ${itemId}` : ""].filter(Boolean).join(": "); + }) + .filter(Boolean); + return [`${index ? `Look ${index}` : "Current look"}: ${title}`, pieces.length ? `Pieces: ${pieces.join(" | ")}` : ""] + .filter(Boolean) + .join("\n"); +} + +function buildAssistantHistoryText(message: AIMessage) { + const parts = [cleanStructuredText(message.aura?.reply ?? message.text)].filter(Boolean); + const looks = message.aura?.lookOptions?.length + ? message.aura.lookOptions.slice(0, 3) + : message.aura?.look + ? [message.aura.look] + : []; + if (looks.length) { + parts.push( + [ + "Rendered outfit context for follow-ups:", + ...looks.map((look, index) => summarizeAuraLookForHistory(look, looks.length > 1 ? index + 1 : undefined)), + ].join("\n"), + ); + } + return parts.join("\n\n").trim(); +} + function buildAuraHistory(messages: AIMessage[]) { return messages .filter((message) => message.type === "user" || message.type === "assistant") .map((message) => { const text = message.type === "assistant" - ? String(message.aura?.reply ?? message.text ?? "").trim() + ? buildAssistantHistoryText(message) : String(message.text ?? "").trim(); if (!text) return null; return { @@ -123,20 +1447,40 @@ function buildAuraHistory(messages: AIMessage[]) { .slice(-8); } -function createSystemMessage(text: string): AIMessage { - return { - id: createMessageId(), - type: "system/action", - kind: "system", - text, - createdAt: Date.now(), - }; +function userFacingAuraError(error: unknown) { + const messageText = + error instanceof Error ? error.message : String((error as { message?: unknown })?.message ?? ""); + const lower = messageText.toLowerCase(); + if (lower.includes("permission") || lower.includes("denied")) { + return "I couldn't save that item because permission was denied."; + } + if (lower.includes("upload") || lower.includes("storage")) { + return AURA_ATTACHMENT_FAILURE_MESSAGE; + } + if (lower.includes("draft") || lower.includes("firestore") || lower.includes("item")) { + return AURA_DRAFT_FAILURE_MESSAGE; + } + if (lower.includes("parse") || lower.includes("json")) { + return "AURA had trouble reading that response. Try once more."; + } + if ( + lower.includes("network") || + lower.includes("request failed") || + lower.includes("timed out") || + lower.includes("offline") || + lower.includes("unavailable") + ) { + return AURA_OFFLINE_MESSAGE; + } + return AURA_OFFLINE_MESSAGE; } export default function AIScreen() { const params = useLocalSearchParams<{ prompt?: string | string[]; promptKey?: string | string[]; + requiredItemIds?: string | string[]; + requiredItemId?: string | string[]; chatId?: string | string[]; chatKey?: string | string[]; }>(); @@ -144,22 +1488,39 @@ export default function AIScreen() { const { colors } = useAppTheme(); const layout = useResponsiveLayout(); const insets = useSafeAreaInsets(); - const [message, setMessage] = useState(""); - const [messages, setMessages] = useState([]); + const uid = user?.uid ?? null; + const { + attachmentRole, + clearComposer, + handleAttachmentRoleChange, + handleMicPress, + handlePickImages, + handleRemoveAttachment, + handleTakePhoto, + message, + pendingAttachments, + recordingAudio, + setMessage, + setPendingAttachments, + stopVoiceInput, + } = useAuraComposerState({ uid }); const [loading, setLoading] = useState(false); - const [activeChatId, setActiveChatId] = useState(null); - const [isBooting, setIsBooting] = useState(true); - const [quickChips, setQuickChips] = useState(DEFAULT_CHIPS); - const [recentThreads, setRecentThreads] = useState([]); const [items, setItems] = useState([]); + const [profilePreferences, setProfilePreferences] = useState(null); + const [activeShopSuggestion, setActiveShopSuggestion] = useState(null); const [isComposerFocused, setIsComposerFocused] = useState(false); const [keyboardHeight, setKeyboardHeight] = useState(0); const [composerHeight, setComposerHeight] = useState(DEFAULT_COMPOSER_HEIGHT); - const latestMessagesRef = useRef([]); - const auraPulse = useRef(new Animated.Value(0)).current; - const auraThinking = useRef(new Animated.Value(0)).current; - const uid = user?.uid ?? null; - const itemsById = useMemo(() => new Map(items.map((item) => [item.id, item])), [items]); + const [chatDrawerOpen, setChatDrawerOpen] = useState(false); + const [focusScrollSignal, setFocusScrollSignal] = useState(0); + const [focusMessageId, setFocusMessageId] = useState(null); + const [chatRefreshing, setChatRefreshing] = useState(false); + const consumedPromptTokens = useRef(new Set()); + const { + handleStopGenerating, + stopStreamingRequestedRef, + streamAbortControllerRef, + } = useAuraStreamingState(); const routePrompt = useMemo(() => { const raw = Array.isArray(params.prompt) ? params.prompt[0] : params.prompt; @@ -171,6 +1532,15 @@ export default function AIScreen() { return typeof raw === "string" && raw.trim() ? raw.trim() : routePrompt; }, [params.promptKey, routePrompt]); + const routeRequiredItemIds = useMemo( + () => + uniquePromptItemIds([ + ...requiredItemIdsFromRouteParam(params.requiredItemIds), + ...requiredItemIdsFromRouteParam(params.requiredItemId), + ], 8), + [params.requiredItemId, params.requiredItemIds], + ); + const routeChatId = useMemo(() => { const raw = Array.isArray(params.chatId) ? params.chatId[0] : params.chatId; return typeof raw === "string" && raw.trim() ? raw.trim() : ""; @@ -181,11 +1551,35 @@ export default function AIScreen() { return typeof raw === "string" && raw.trim() ? raw.trim() : routeChatId; }, [params.chatKey, routeChatId]); + const { + activeChatId, + hasStreamingMessage, + isBooting, + latestMessagesRef, + orderedMessages, + recentThreads, + refreshRecentThreads, + setActiveChatId, + setMessages, + setQuickChips, + setRecentThreads, + } = useAuraChatHydration({ + uid, + routeChatId, + routeChatKey, + recentChatLimit: RECENT_CHAT_LIMIT, + defaultChips: DEFAULT_CHIPS, + debug: DEBUG_AURA_CLIENT, + }); + const itemsById = useMemo(() => new Map(items.map((item) => [item.id, item])), [items]); + const minimumClosetSummary = useMemo(() => buildMinimumClosetSummary(items), [items]); + useEffect(() => { if (!uid) { setItems([]); return; } + setItems([]); const unsub = listenToItems(uid, (next) => setItems(next as ClothingItem[]), { status: "ALL", sort: "NEWEST", @@ -196,391 +1590,1594 @@ export default function AIScreen() { }, [uid]); useEffect(() => { - latestMessagesRef.current = messages; - }, [messages]); + let active = true; + if (!uid) { + setProfilePreferences(null); + return () => { + active = false; + }; + } + void loadUserProfilePreferences(uid) + .then((preferences) => { + if (active) setProfilePreferences(preferences); + }) + .catch(() => { + if (active) setProfilePreferences(null); + }); + return () => { + active = false; + }; + }, [uid]); - useEffect(() => { - const loop = Animated.loop( - Animated.sequence([ - Animated.timing(auraPulse, { - toValue: 1, - duration: 2600, - useNativeDriver: true, + const handleShareChatThread = React.useCallback( + async (thread: AIChatThread) => { + if (!uid) return; + const threadMessages = await loadChatMessages(uid, thread.chatId); + const message = buildShareTranscript(thread, threadMessages); + await Share.share({ + title: summarizeChatTitle({ + userText: thread.title, + assistantText: thread.lastMessagePreview, }), - Animated.timing(auraPulse, { - toValue: 0, - duration: 2600, - useNativeDriver: true, - }), - ]) - ); - loop.start(); - return () => loop.stop(); - }, [auraPulse]); - - const triggerThinkingPulse = React.useCallback(() => { - Animated.sequence([ - Animated.timing(auraThinking, { - toValue: 1, - duration: 160, - useNativeDriver: true, - }), - Animated.timing(auraThinking, { - toValue: 0, - duration: 420, - useNativeDriver: true, - }), - ]).start(); - }, [auraThinking]); + message, + }); + }, + [uid], + ); - useEffect(() => { - let cancelled = false; + const handleTogglePinnedThread = React.useCallback( + async (thread: AIChatThread) => { + if (!uid) return; + await setChatPinned(uid, thread.chatId, !thread.pinned); + await refreshRecentThreads(); + }, + [refreshRecentThreads, uid], + ); - async function hydrate() { - if (!uid) { - setMessages([]); - setActiveChatId(null); - setIsBooting(false); - return; + const handleRenameThread = React.useCallback( + async (thread: AIChatThread, title: string) => { + if (!uid) return; + await renameChatThread(uid, thread.chatId, title); + setRecentThreads((prev) => + prev.map((entry) => + entry.chatId === thread.chatId + ? { ...entry, title, titleEdited: true, updatedAt: Date.now() } + : entry, + ), + ); + if (activeChatId === thread.chatId) { + await saveLatestChatCache(uid, thread.chatId, null, latestMessagesRef.current); } + await refreshRecentThreads(); + }, + [activeChatId, latestMessagesRef, refreshRecentThreads, setRecentThreads, uid], + ); - setIsBooting(true); - - const cached = await loadLatestChatCache(); - if (!cancelled && cached?.messages?.length) { - setMessages(cached.messages); - setActiveChatId(cached.chatId ?? null); + const handleArchiveThread = React.useCallback( + async (thread: AIChatThread) => { + if (!uid) return; + await setChatArchived(uid, thread.chatId, true); + setRecentThreads((prev) => prev.filter((entry) => entry.chatId !== thread.chatId)); + if (activeChatId === thread.chatId) { + await clearCachedRecentMessages(uid, thread.chatId); + setActiveChatId(null); + setMessages([]); + await clearLatestChatCache(uid); + await saveAuraChatSessionMeta(uid, null); } + await refreshRecentThreads(); + }, + [activeChatId, refreshRecentThreads, setActiveChatId, setMessages, setRecentThreads, uid], + ); - try { - const shouldLoadSpecificChat = - !!routeChatId && !consumedChatTokens.has(`${routeChatKey}:${routeChatId}`); - if (shouldLoadSpecificChat) { - consumedChatTokens.add(`${routeChatKey}:${routeChatId}`); - const threadMessages = await loadChatMessages(uid, routeChatId); - const recent = await loadRecentChatThreads(uid, 6); - if (!cancelled) { - setMessages(threadMessages); - setActiveChatId(routeChatId); - setQuickChips(DEFAULT_CHIPS); - setRecentThreads(recent); - await saveLatestChatCache(routeChatId, null, threadMessages); - } - return; - } - - const latestThread = await loadLatestChatThread(uid); - const recent = await loadRecentChatThreads(uid, 6); - if (!cancelled && latestThread?.chatId) { - const threadMessages = await loadChatMessages(uid, latestThread.chatId); - setMessages(threadMessages); - setActiveChatId(latestThread.chatId); - setRecentThreads(recent); - await saveLatestChatCache(latestThread.chatId, latestThread.threadId, threadMessages); - } else if (!cancelled) { - setRecentThreads(recent); - } - } catch (error) { - console.log("[AURA] hydrate failed", error); - } finally { - if (!cancelled) setIsBooting(false); + const handleDeleteThread = React.useCallback( + async (thread: AIChatThread) => { + if (!uid) return; + await deleteChatThread(uid, thread.chatId); + setRecentThreads((prev) => prev.filter((entry) => entry.chatId !== thread.chatId)); + if (activeChatId === thread.chatId) { + await clearCachedRecentMessages(uid, thread.chatId); + setActiveChatId(null); + setMessages([]); + await clearLatestChatCache(uid); + await saveAuraChatSessionMeta(uid, null); } - } - - void hydrate(); - - return () => { - cancelled = true; - }; - }, [routeChatId, routeChatKey, uid]); + await refreshRecentThreads(); + }, + [activeChatId, refreshRecentThreads, setActiveChatId, setMessages, setRecentThreads, uid], + ); useEffect(() => { - if (isBooting) return; - void saveLatestChatCache(activeChatId, null, messages); - }, [activeChatId, isBooting, messages]); + const setLiveKeyboardHeight = (height: number) => { + const nextHeight = Math.max(0, Math.round(height)); + setKeyboardHeight((current) => (Math.abs(current - nextHeight) <= 1 ? current : nextHeight)); + }; - useEffect(() => { const updateKeyboardHeight = (event: KeyboardEvent) => { - const nextHeight = Math.max(0, layout.height - event.endCoordinates.screenY); - setKeyboardHeight(nextHeight); + const screenY = Number(event.endCoordinates.screenY); + const nextHeight = + event.endCoordinates.height <= 0 || !Number.isFinite(screenY) || screenY >= layout.height - 1 + ? 0 + : Math.max(0, layout.height - screenY); + setLiveKeyboardHeight(nextHeight); }; - const resetKeyboardHeight = () => setKeyboardHeight(0); + const resetKeyboardHeight = () => { + setKeyboardHeight(0); + }; const changeEvent = Platform.OS === "ios" ? "keyboardWillChangeFrame" : "keyboardDidShow"; - const hideEvent = Platform.OS === "ios" ? "keyboardWillHide" : "keyboardDidHide"; const changeSubscription = Keyboard.addListener(changeEvent, updateKeyboardHeight); - const hideSubscription = Keyboard.addListener(hideEvent, resetKeyboardHeight); + const willShowSubscription = + Platform.OS === "ios" ? Keyboard.addListener("keyboardWillShow", updateKeyboardHeight) : null; + const willHideSubscription = + Platform.OS === "ios" ? Keyboard.addListener("keyboardWillHide", resetKeyboardHeight) : null; + const didHideSubscription = Keyboard.addListener("keyboardDidHide", resetKeyboardHeight); return () => { changeSubscription.remove(); - hideSubscription.remove(); + willShowSubscription?.remove(); + willHideSubscription?.remove(); + didHideSubscription.remove(); }; }, [layout.height]); const handleAsk = React.useCallback( - async (override?: string) => { + async (override?: string, options?: AskAuraOptions) => { if (!uid) { Alert.alert("AURA", "Please sign in to chat with AURA."); return; } - const prompt = String(override ?? message).trim(); - if (!prompt || loading) return; + const retryUserMessage = options?.retryUserMessage; + const isRetry = !!retryUserMessage; + const prompt = String(retryUserMessage?.text ?? override ?? message).trim(); + const requiredItemIds = uniquePromptItemIds([ + ...(retryUserMessage?.requiredItemIds ?? []), + ...(options?.requiredItemIds ?? []), + ...selectedItemIdsFromPrompt(prompt), + ], 8); + const visiblePrompt = stripInternalItemIdsFromUserPrompt(prompt); + const outgoingAttachments = retryUserMessage + ? retryUserMessage.attachments ?? [] + : override + ? [] + : pendingAttachments; + if ((!prompt && outgoingAttachments.length === 0) || loading) return; + stopStreamingRequestedRef.current = false; + if (!isRetry) { + clearComposer(); + setComposerHeight(DEFAULT_COMPOSER_HEIGHT); + } - const userMessage = createUserMessage(prompt); - const nextLocalMessages = [...latestMessagesRef.current, userMessage]; - setMessages(nextLocalMessages); - if (!override) setMessage(""); + if (DEBUG_AURA_CLIENT) { + console.log("[AURA_SEND]", "sending message", { + uid, + hasPrompt: !!prompt, + attachmentCount: outgoingAttachments.length, + attachmentTypes: outgoingAttachments.map((attachment) => attachment.type), + attachments: outgoingAttachments.map((attachment) => ({ + id: attachment.id, + type: attachment.type, + mimeType: attachment.mimeType ?? null, + hasLocalUri: !!attachment.localUri, + hasRemoteUri: /^https?:\/\//i.test(String(attachment.uri ?? "")), + role: attachment.type === "image" ? attachment.role ?? null : null, + width: attachment.type === "image" ? attachment.width ?? null : null, + height: attachment.type === "image" ? attachment.height ?? null : null, + })), + }); + } setLoading(true); - triggerThinkingPulse(); + let uploadedAttachments = outgoingAttachments; + try { + uploadedAttachments = isRetry + ? outgoingAttachments + : outgoingAttachments.length + ? await uploadAuraAttachments(uid, outgoingAttachments) + : []; + if (DEBUG_AURA_CLIENT) { + console.log("[AURA_UPLOAD]", "attachment batch ready for send", { + uid, + count: uploadedAttachments.length, + attachments: uploadedAttachments.map((attachment) => ({ + id: attachment.id, + type: attachment.type, + mimeType: attachment.mimeType ?? null, + storagePath: attachment.storagePath ?? null, + uriHost: (() => { + try { + return new URL(attachment.uri).hostname; + } catch { + return null; + } + })(), + role: attachment.type === "image" ? attachment.role ?? null : null, + groupId: attachment.type === "image" ? attachment.groupId ?? null : null, + width: attachment.type === "image" ? attachment.width ?? null : null, + height: attachment.type === "image" ? attachment.height ?? null : null, + })), + }); + } + } catch (error: any) { + if (DEBUG_AURA_CLIENT) { + console.log("[AURA_ERROR]", "attachment upload failed", error); + } + void trackLaunchEvent({ + userId: uid, + eventName: "ai_response_failed", + properties: { + source: "attachment_upload", + attachmentCount: outgoingAttachments.length, + ...analyticsErrorProperties(error), + }, + }); + setLoading(false); + setMessages((prev) => orderChatMessages(appendUniqueSystemMessage(prev, AURA_ATTACHMENT_FAILURE_MESSAGE))); + Alert.alert("Attachments", error?.message ?? AURA_ATTACHMENT_FAILURE_MESSAGE); + return; + } + + const userMessage = retryUserMessage ?? { + ...createUserMessageWithAttachments(visiblePrompt, uploadedAttachments), + ...(requiredItemIds.length ? { requiredItemIds } : {}), + }; + logAuraChatState("message_created", { + messageId: userMessage.id, + type: userMessage.type, + kind: userMessage.kind, + attachmentCount: uploadedAttachments.length, + retry: isRetry, + }); + const chatSeedText = buildChatSeedText(visiblePrompt, uploadedAttachments); + const baselineMessages = latestMessagesRef.current.filter( + (entry) => entry.id !== options?.removeMessageId, + ); + const retryBaselineMessages = + isRetry && !baselineMessages.some((entry) => entry.id === userMessage.id) + ? [...baselineMessages, userMessage] + : baselineMessages; + const nextLocalMessages = orderChatMessages( + isRetry ? retryBaselineMessages : [...retryBaselineMessages, userMessage], + ); + const requestHistoryMessages = isRetry + ? nextLocalMessages.filter( + (entry) => !(entry.type === "assistant" && entry.replyToMessageId === userMessage.id), + ) + : nextLocalMessages; + const historyMessagesBeforeRequest = isRetry ? retryBaselineMessages : latestMessagesRef.current; + const intentContextMessages = options?.diversityMessages ?? historyMessagesBeforeRequest; + const lastLookForIntent = latestAuraLook(intentContextMessages); + const promptAnchorItemIds = requiredItemIds; + const outfitAnchorItemIds = recentOutfitAnchorItemIds( + prompt, + items, + intentContextMessages, + ); + const lockedOutfitAnchorItemIds = uniquePromptItemIds([ + ...promptAnchorItemIds, + ...outfitAnchorItemIds, + ]); + const chatIntent = classifyAuraChatIntent(prompt, { + attachmentCount: uploadedAttachments.length, + hasPreviousLook: !!lastLookForIntent, + hasRecentItemAnchor: lockedOutfitAnchorItemIds.length > 0, + }); + const isManualSelectedOutfit = requiredItemIds.length > 0 || promptHasManualSelectedOutfit(prompt); + const shouldForceConcreteRefinement = + !!lastLookForIntent && isConcreteOutfitRefinementRequest(prompt); + const structuredBatchPrompt = buildStructuredOutfitBatchPrompt(prompt, historyMessagesBeforeRequest); + const shouldForceStructuredBatch = wantsStructuredOutfitBatch( + prompt, + uploadedAttachments.length, + historyMessagesBeforeRequest, + { hasRecentItemAnchor: lockedOutfitAnchorItemIds.length > 0 }, + ); + const shouldForceStructuredOutfit = wantsStructuredOutfitRequest( + prompt, + uploadedAttachments.length, + historyMessagesBeforeRequest, + { hasRecentItemAnchor: lockedOutfitAnchorItemIds.length > 0 }, + ) || isManualSelectedOutfit || shouldForceConcreteRefinement; + const shouldRouteToExistingLook = + !isManualSelectedOutfit && + !shouldForceConcreteRefinement && + (chatIntent === "STYLE_EXISTING" || chatIntent === "MODIFY_OUTFIT"); + const routeChosen = shouldRouteToExistingLook + ? lastLookForIntent + ? "existing_outfit_styling" + : "existing_outfit_missing_context" + : shouldForceStructuredOutfit + ? "outfit_generator" + : "stream_stylist"; + const structuredOutfitPrompt = shouldForceStructuredBatch + ? structuredBatchPrompt + : shouldForceConcreteRefinement + ? `${prompt}. Previous outfit: ${describeAuraLookForRefinement(lastLookForIntent) || "use the most recent outfit card"}. Make a concrete improved outfit from my closet based on that previous look. Change at least one meaningful piece such as shoes, outerwear, accessory, top, or bottom. Return an updated outfit card and briefly say what changed and why it is better.` + : prompt; + const baseOutfitDiversity = buildAuraOutfitDiversityContext( + options?.forceOutfitDiversity ? "Try again" : prompt, + intentContextMessages, + { + multiLook: shouldForceStructuredBatch, + }, + ); + const promptAnchorIdSet = new Set(promptAnchorItemIds); + const outfitDiversity = promptAnchorItemIds.length + ? { + ...baseOutfitDiversity, + excludedItemIds: baseOutfitDiversity.excludedItemIds.filter((itemId) => !promptAnchorIdSet.has(itemId)), + } + : baseOutfitDiversity; + if (DEBUG_AURA_CLIENT) { + console.log("[AURA_INTENT]", "chat intent route", { + prompt, + detectedIntent: chatIntent, + hasPreviousLook: !!lastLookForIntent, + routeChosen, + outfitGeneratorWillRun: !shouldRouteToExistingLook && shouldForceStructuredOutfit, + anchorItemIds: lockedOutfitAnchorItemIds, + }); + } + if (DEBUG_AURA_CLIENT && outfitDiversity.shouldAvoidRepeats) { + console.log("[AURA_DIVERSITY]", "frontend outfit diversity context", { + previousItemIds: outfitDiversity.previousLookItemIds, + excludedItemIds: outfitDiversity.excludedItemIds, + recentItemIds: outfitDiversity.recentItemIds, + previousLookSignatures: outfitDiversity.previousLookSignatures, + maxOverlap: outfitDiversity.maxOverlap, + reason: outfitDiversity.reason, + }); + } + setMessages(nextLocalMessages); + setFocusMessageId(userMessage.id); const streamingMessageId = createMessageId(); - const streamingMessageCreatedAt = Date.now(); + const streamingMessageCreatedAt = Math.max(Date.now(), messageOrderMillis(userMessage) + 1); + const streamingMessageLocalSequence = nextLocalMessageSequence(); + logAuraChatState("streaming_message_reserved", { + messageId: streamingMessageId, + createdAt: streamingMessageCreatedAt, + replyToMessageId: userMessage.id, + }); let chatId = activeChatId; const startedAt = Date.now(); + void trackLaunchEvent({ + userId: uid, + eventName: "ai_request_started", + properties: { + source: routeChosen, + retry: isRetry, + attachmentCount: uploadedAttachments.length, + requiredItemCount: requiredItemIds.length, + closetItemCount: items.length, + chatIntent, + }, + }); + let cancelStreamingFlush: (() => void) | null = null; + let flushStreamingTextNow: (() => void) | null = null; + let activeStreamController: AbortController | null = null; + let streamedTextSoFar = ""; + let streamFinalized = false; try { if (!chatId) { - const chat = await createChatThread(uid, prompt); + const chat = await createChatThread(uid, chatSeedText); chatId = chat.chatId; setActiveChatId(chat.chatId); + await saveAuraChatSessionMeta(uid, chat.chatId); } - await appendMessageToChat(uid, chatId, userMessage, { titleFromUserText: prompt }); + void updateAuraSessionContextFromPrompt(uid, chatId, prompt); + if (!isRetry) { + await appendMessageToChat(uid, chatId, userMessage, { titleFromUserText: chatSeedText }); + } - setMessages([ - ...nextLocalMessages, - { + const imageIntent = classifyAuraImageIntent(prompt, uploadedAttachments); + if (DEBUG_AURA_CLIENT) { + console.log("[AURA_INTENT]", "classified image intent", { + uid, + prompt, + imageIntent, + attachmentCount: uploadedAttachments.length, + attachmentRoles: uploadedAttachments.map((attachment) => + attachment.type === "image" ? attachment.role ?? null : attachment.type + ), + }); + } + + if (DEBUG_AURA_CLIENT) { + console.log("[AURA_STREAM_REQUEST]", "ai screen request args", { + prompt, + requiredItemCount: requiredItemIds.length, + clientIntent: imageIntent, + attachmentCount: uploadedAttachments.length, + attachments: uploadedAttachments.map((attachment) => ({ + type: attachment.type, + mimeType: attachment.mimeType ?? null, + storagePath: attachment.storagePath ?? null, + uriHost: (() => { + try { + return new URL(attachment.uri).hostname; + } catch { + return null; + } + })(), + role: attachment.type === "image" ? attachment.role ?? null : null, + groupId: attachment.type === "image" ? attachment.groupId ?? null : null, + width: attachment.type === "image" ? attachment.width ?? null : null, + height: attachment.type === "image" ? attachment.height ?? null : null, + })), + }); + } + + if (requiredItemIds.length) { + const itemIdSet = new Set(items.map((item) => item.id)); + const missingRequiredItemIds = requiredItemIds.filter((itemId) => !itemIdSet.has(itemId)); + if (missingRequiredItemIds.length) { + const response = buildRequiredItemUnavailableResponse(); + const assistantMessage = createAssistantMessage(response, { + id: streamingMessageId, + createdAt: streamingMessageCreatedAt, + clientCreatedAt: streamingMessageCreatedAt, + localSequence: streamingMessageLocalSequence, + replyToMessageId: userMessage.id, + streaming: false, + }, { + userRequest: visiblePrompt, + }); + setMessages(orderChatMessages([...nextLocalMessages, assistantMessage])); + setQuickChips(DEFAULT_CHIPS); + await appendMessageToChat(uid, chatId, assistantMessage); + await updateChatThread(uid, chatId, { + title: deriveAssistantChatTitle(chatSeedText, assistantMessage), + }); + await refreshRecentThreads(); + return; + } + } + + if (promptRequestsWardrobeBuyingAdvice(prompt) && uploadedAttachments.length === 0) { + const savedLookSignals = requestHistoryMessages.flatMap((entry) => { + const looks = [ + entry.aura?.look, + ...(entry.aura?.lookOptions ?? []), + ].filter((look): look is import("@/src/types/aura").AuraLook => !!look); + return looks.map((look) => ({ + addToComplete: look.addToComplete, + })); + }); + const suggestions = buildWardrobeSuggestions({ + items, + profilePreferences, + savedLooks: savedLookSignals, + }); + const suggestionResponse = buildWardrobeSuggestionAuraResponse(suggestions, items); + const assistantMessage = createAssistantMessage(suggestionResponse, { id: streamingMessageId, - type: "assistant", - kind: "aura_text", - text: "", - streaming: true, createdAt: streamingMessageCreatedAt, - }, - ]); + clientCreatedAt: streamingMessageCreatedAt, + localSequence: streamingMessageLocalSequence, + replyToMessageId: userMessage.id, + streaming: false, + }, { + userRequest: prompt, + }); + logAuraChatState("message_created", { + messageId: assistantMessage.id, + type: assistantMessage.type, + kind: assistantMessage.kind, + source: "wardrobe_suggestions", + suggestionCount: suggestions.length, + }); + void trackLaunchEvent({ + userId: uid, + eventName: "wardrobe_recommendations_generated", + properties: { + source: "aura_chat", + suggestionCount: suggestions.length, + elapsedMs: Date.now() - startedAt, + }, + }); + trackAuraResponseSucceeded({ + userId: uid, + source: "wardrobe_suggestions", + response: suggestionResponse, + startedAt, + properties: { + suggestionCount: suggestions.length, + route: routeChosen, + }, + }); + setMessages(orderChatMessages([...nextLocalMessages, assistantMessage])); + setQuickChips(suggestionResponse.chips?.length ? suggestionResponse.chips : DEFAULT_CHIPS); + await appendMessageToChat(uid, chatId, assistantMessage); + await updateChatThread(uid, chatId, { + title: deriveAssistantChatTitle(chatSeedText, assistantMessage), + }); + await refreshRecentThreads(); + return; + } + if (shouldRouteToExistingLook) { + const stylingResponse = lastLookForIntent + ? buildExistingOutfitStylingResponse(prompt, lastLookForIntent, chatIntent) + : buildNoExistingOutfitResponse(); + if (DEBUG_AURA_CLIENT) { + console.log("[AURA_INTENT]", "existing outfit route selected", { + detectedIntent: chatIntent, + hasPreviousLook: !!lastLookForIntent, + outfitGeneratorCalled: false, + lookTitle: lastLookForIntent?.lookTitle ?? null, + }); + } + const assistantMessage = createAssistantMessage(stylingResponse, { + id: streamingMessageId, + createdAt: streamingMessageCreatedAt, + clientCreatedAt: streamingMessageCreatedAt, + localSequence: streamingMessageLocalSequence, + replyToMessageId: userMessage.id, + streaming: false, + }, { + userRequest: prompt, + }); + logAuraChatState("message_created", { + messageId: assistantMessage.id, + type: assistantMessage.type, + kind: assistantMessage.kind, + source: lastLookForIntent ? "style_existing_outfit" : "style_existing_missing_context", + }); + trackAuraResponseSucceeded({ + userId: uid, + source: lastLookForIntent ? "style_existing_outfit" : "style_existing_missing_context", + response: stylingResponse, + startedAt, + properties: { + route: routeChosen, + chatIntent, + }, + }); + setMessages(orderChatMessages([...nextLocalMessages, assistantMessage])); + setQuickChips(stylingResponse.chips?.length ? stylingResponse.chips : DEFAULT_CHIPS); + await appendMessageToChat(uid, chatId, assistantMessage); + await updateChatThread(uid, chatId, { + title: deriveAssistantChatTitle(chatSeedText, assistantMessage), + }); + await refreshRecentThreads(); + return; + } + + if (shouldForceStructuredOutfit) { + if (DEBUG_AURA_CLIENT) { + console.log("[AURA_INTENT]", "outfit generator route selected", { + detectedIntent: chatIntent, + outfitGeneratorCalled: true, + structuredBatch: shouldForceStructuredBatch, + }); + } + const desiredLookCount = shouldForceStructuredBatch + ? resolveStructuredBatchLookCount( + prompt, + structuredBatchPrompt, + latestMessagesRef.current, + ) + : 1; + if (DEBUG_AURA_CLIENT) { + console.log("[AURA_MULTI]", "frontend structured batch request", { + prompt, + structuredBatchPrompt: structuredOutfitPrompt, + desiredLookCount, + latestLookCount: latestAuraLookCount(latestMessagesRef.current), + }); + } + if (promptRequestsOuterwear(structuredOutfitPrompt) && !closetHasOuterwear(items)) { + const noOuterwearResponse: AuraResponse = { + title: "No outerwear found", + reply: + "A jacket or layer would open this up. Add one jacket, blazer, hoodie, coat, or overshirt and I can build layered looks without inventing pieces you do not own.", + reason: "", + outfitItems: [], + ownedPieces: [], + recommendedAdditions: [], + swapSuggestion: "", + chips: DEFAULT_CHIPS, + }; + const assistantMessage = createAssistantMessage(noOuterwearResponse, { + id: streamingMessageId, + createdAt: streamingMessageCreatedAt, + clientCreatedAt: streamingMessageCreatedAt, + localSequence: streamingMessageLocalSequence, + replyToMessageId: userMessage.id, + streaming: false, + }, { + userRequest: prompt, + }); + logAuraChatState("message_created", { + messageId: assistantMessage.id, + type: assistantMessage.type, + kind: assistantMessage.kind, + source: "structured_batch_no_outerwear", + }); + trackAuraResponseSucceeded({ + userId: uid, + source: "structured_batch_no_outerwear", + response: noOuterwearResponse, + startedAt, + properties: { + route: routeChosen, + chatIntent, + }, + }); + setMessages(orderChatMessages([...nextLocalMessages, assistantMessage])); + await appendMessageToChat(uid, chatId, assistantMessage); + await updateChatThread(uid, chatId, { + title: deriveAssistantChatTitle(chatSeedText, assistantMessage), + }); + await refreshRecentThreads(); + return; + } + let outfitBatch: Awaited>; + try { + outfitBatch = await generateAuraSwipeBatch({ + intentText: structuredOutfitPrompt, + numOutfits: desiredLookCount, + items, + anchorItemIds: lockedOutfitAnchorItemIds, + requiredItemIds, + excludeItemIds: outfitDiversity.excludedItemIds, + recentItemIds: outfitDiversity.recentItemIds, + previousLookItemIds: outfitDiversity.previousLookItemIds, + previousLookSignatures: outfitDiversity.previousLookSignatures, + maxOverlap: outfitDiversity.maxOverlap, + }); + } catch (error) { + if (!requiredItemIds.length) throw error; + const response = buildRequiredItemNoOutfitResponse(); + const assistantMessage = createAssistantMessage(response, { + id: streamingMessageId, + createdAt: streamingMessageCreatedAt, + clientCreatedAt: streamingMessageCreatedAt, + localSequence: streamingMessageLocalSequence, + replyToMessageId: userMessage.id, + streaming: false, + }, { + userRequest: visiblePrompt, + }); + setMessages(orderChatMessages([...nextLocalMessages, assistantMessage])); + setQuickChips(DEFAULT_CHIPS); + await appendMessageToChat(uid, chatId, assistantMessage); + await updateChatThread(uid, chatId, { + title: deriveAssistantChatTitle(chatSeedText, assistantMessage), + }); + await refreshRecentThreads(); + return; + } + const batchResponse = enforceClientOutfitDiversity( + enforceRequiredItemsOnAuraResponse( + buildAuraResponseFromSwipeBatch(outfitBatch, { + prompt: structuredOutfitPrompt, + items, + }), + requiredItemIds, + ), + outfitDiversity, + ); + if (DEBUG_AURA_CLIENT) { + console.log("[AURA_MULTI]", "frontend batch fallback response", { + prompt, + structuredBatchPrompt: structuredOutfitPrompt, + lookOptionsCount: batchResponse.lookOptions?.length ?? 0, + lookTitles: batchResponse.lookOptions?.map((look) => look.lookTitle) ?? [], + }); + } + const assistantMessage = createAssistantMessage(batchResponse, { + id: streamingMessageId, + createdAt: streamingMessageCreatedAt, + clientCreatedAt: streamingMessageCreatedAt, + localSequence: streamingMessageLocalSequence, + replyToMessageId: userMessage.id, + streaming: false, + }, { + userRequest: structuredOutfitPrompt, + }); + logAuraChatState("message_created", { + messageId: assistantMessage.id, + type: assistantMessage.type, + kind: assistantMessage.kind, + source: "structured_batch", + lookOptionsCount: batchResponse.lookOptions?.length ?? 0, + }); + trackAuraResponseSucceeded({ + userId: uid, + source: "structured_batch", + response: batchResponse, + startedAt, + properties: { + route: routeChosen, + chatIntent, + desiredLookCount, + requiredItemCount: requiredItemIds.length, + }, + }); + setMessages(orderChatMessages([...nextLocalMessages, assistantMessage])); + setQuickChips(batchResponse.chips?.length ? batchResponse.chips : DEFAULT_CHIPS); + await appendMessageToChat(uid, chatId, assistantMessage); + await updateChatThread(uid, chatId, { + title: deriveAssistantChatTitle(chatSeedText, assistantMessage), + }); + await refreshRecentThreads(); + return; + } + + let pendingStreamText = ""; + let streamFlushTimer: ReturnType | null = null; + const flushStreamingText = () => { + if (streamFlushTimer) { + clearTimeout(streamFlushTimer); + streamFlushTimer = null; + } + const nextText = pendingStreamText; + pendingStreamText = ""; + if (!nextText) return; + if (streamFinalized) return; + streamedTextSoFar += nextText; + logAuraChatState("stream_delta_flush", { + messageId: streamingMessageId, + length: nextText.length, + }); + setMessages((prev) => { + const next = updateMessageById(prev, streamingMessageId, (entry) => ({ + ...entry, + text: `${entry.text ?? ""}${nextText}`, + streaming: true, + })); + if (next === prev) { + logAuraChatState("stream_delta_missing_message", { + messageId: streamingMessageId, + length: nextText.length, + }); + } + return orderChatMessages(next); + }); + }; + const queueStreamingDelta = (delta: string) => { + if (streamFinalized) return; + pendingStreamText += delta; + if (streamFlushTimer) return; + streamFlushTimer = setTimeout(flushStreamingText, STREAM_FLUSH_INTERVAL_MS); + }; + cancelStreamingFlush = () => { + if (streamFlushTimer) { + clearTimeout(streamFlushTimer); + streamFlushTimer = null; + } + pendingStreamText = ""; + }; + flushStreamingTextNow = flushStreamingText; + + const streamingMessage = createStreamingAssistantMessage( + streamingMessageId, + streamingMessageCreatedAt, + userMessage.id, + streamingMessageLocalSequence, + ); + logAuraChatState("stream_started", { + messageId: streamingMessage.id, + chatId, + }); + void runHaptic(AURA_REPLY_START_HAPTIC); + const streamAbortController = new AbortController(); + activeStreamController = streamAbortController; + streamAbortControllerRef.current = streamAbortController; + setMessages(orderChatMessages([...nextLocalMessages, streamingMessage])); + await appendMessageToChat(uid, chatId, streamingMessage); + + if (DEBUG_AURA_CLIENT) { + console.log("[AURA_INTENT]", "stream stylist route selected", { + detectedIntent: chatIntent, + outfitGeneratorCalled: false, + }); + } const result = await askAuraStream( { - message: prompt, - history: buildAuraHistory(nextLocalMessages), + message: prompt, + chatId, + attachments: uploadedAttachments, + history: buildAuraHistory(requestHistoryMessages), + clientIntent: imageIntent, + clientContext: { + minimumCloset: minimumClosetSummary, + outfitDiversity, + requiredItemIds, + }, }, { + signal: streamAbortController.signal, onStatus: () => { - setMessages((prev) => { - const hasMessage = prev.some((entry) => entry.id === streamingMessageId); - if (hasMessage) return prev; - return [ - ...prev, - { - id: streamingMessageId, - type: "assistant", - kind: "aura_text", - text: "", - streaming: true, - createdAt: streamingMessageCreatedAt, - }, - ]; - }); + if (DEBUG_AURA_CLIENT) { + console.log("[AURA_STREAM]", "stream status received", { uid, chatId }); + } }, onDelta: (delta) => { - setMessages((prev) => - prev.map((entry) => - entry.id === streamingMessageId - ? { - ...entry, - text: `${entry.text ?? ""}${delta}`, - streaming: true, - } - : entry - ) - ); + queueStreamingDelta(delta); }, onFinal: (finalData) => { - setMessages((prev) => - prev.map((entry) => - entry.id === streamingMessageId - ? createAssistantMessage(finalData, { - id: streamingMessageId, - createdAt: streamingMessageCreatedAt, - streaming: false, - }) - : entry - ) - ); + flushStreamingText(); + if (DEBUG_AURA_CLIENT) { + console.log("[AURA_STREAM_FINAL]", "frontend final received", { + uid, + chatId, + hasLook: !!finalData.look, + candidateItemsCount: finalData.candidateItems?.length ?? 0, + candidatesCount: finalData.candidates?.length ?? 0, + presentation: finalData.presentation, + }); + } }, } ); + flushStreamingText(); + let finalResult = result; + if ( + shouldForceStructuredBatch && + !result.look && + !(result.lookOptions?.length) + ) { + const desiredLookCount = resolveStructuredBatchLookCount( + prompt, + structuredBatchPrompt, + nextLocalMessages, + ); + if (DEBUG_AURA_CLIENT) { + console.log("[AURA_MULTI]", "stream returned no structured looks; using frontend batch fallback", { + prompt, + structuredBatchPrompt, + desiredLookCount, + presentation: result.presentation, + outfitItemsCount: result.outfitItems?.length ?? 0, + ownedPiecesCount: result.ownedPieces?.length ?? 0, + }); + } + const outfitBatch = await generateAuraSwipeBatch({ + intentText: structuredBatchPrompt, + numOutfits: desiredLookCount, + items, + anchorItemIds: lockedOutfitAnchorItemIds, + requiredItemIds, + excludeItemIds: outfitDiversity.excludedItemIds, + recentItemIds: outfitDiversity.recentItemIds, + previousLookItemIds: outfitDiversity.previousLookItemIds, + previousLookSignatures: outfitDiversity.previousLookSignatures, + maxOverlap: outfitDiversity.maxOverlap, + }); + finalResult = buildAuraResponseFromSwipeBatch(outfitBatch, { + prompt: structuredBatchPrompt, + items, + }); + } + finalResult = enforceClientOutfitDiversity( + enforceRequiredItemsOnAuraResponse(finalResult, requiredItemIds), + outfitDiversity, + ); + if (requiredItemIds.length && !finalResult.look && !(finalResult.lookOptions?.length)) { + finalResult = buildRequiredItemNoOutfitResponse(); + } const elapsed = Date.now() - startedAt; if (elapsed < 300) { await new Promise((resolve) => setTimeout(resolve, 300 - elapsed)); } - const assistantMessage = createAssistantMessage(result, { + const assistantMessage = createAssistantMessage(finalResult, { id: streamingMessageId, createdAt: streamingMessageCreatedAt, + clientCreatedAt: streamingMessageCreatedAt, + localSequence: streamingMessageLocalSequence, + replyToMessageId: userMessage.id, streaming: false, + }, { + userRequest: structuredBatchPrompt || prompt, }); - setMessages((prev) => - prev.map((entry) => (entry.id === streamingMessageId ? assistantMessage : entry)) - ); - setQuickChips(result.chips?.length ? result.chips : DEFAULT_CHIPS); + streamFinalized = true; + logAuraChatState("stream_finalized", { + messageId: assistantMessage.id, + kind: assistantMessage.kind, + hasAura: !!assistantMessage.aura, + lookOptionsCount: assistantMessage.aura?.lookOptions?.length ?? 0, + candidateItemsCount: + assistantMessage.aura?.candidateItems?.length ?? + assistantMessage.aura?.candidates?.length ?? + 0, + }); + trackAuraResponseSucceeded({ + userId: uid, + source: "stream_stylist", + response: finalResult, + startedAt, + properties: { + route: routeChosen, + chatIntent, + requiredItemCount: requiredItemIds.length, + attachmentCount: uploadedAttachments.length, + }, + }); + setMessages((prev) => { + const next = updateMessageById(prev, streamingMessageId, () => assistantMessage); + if (next === prev) { + logAuraChatState("stream_finalize_missing_message", { + messageId: streamingMessageId, + }); + } + return orderChatMessages(next); + }); + void runHaptic(AURA_REPLY_FINISH_HAPTIC); + setQuickChips(finalResult.chips?.length ? finalResult.chips : DEFAULT_CHIPS); await appendMessageToChat(uid, chatId, assistantMessage); - const recent = await loadRecentChatThreads(uid, 6); - setRecentThreads(recent); + await updateChatThread(uid, chatId, { + title: deriveAssistantChatTitle(chatSeedText, assistantMessage), + }); + if (DEBUG_AURA_CLIENT) { + console.log("[AURA_STREAM]", "stream flow complete", { uid, chatId }); + } + await refreshRecentThreads(); } catch (error) { - console.log("[AURA] ask failed", error); - const fallback = createSystemMessage("AURA is offline right now."); - setMessages((prev) => [ - ...prev.filter((entry) => entry.id !== streamingMessageId), - fallback, - ]); + const wasStopped = stopStreamingRequestedRef.current || isAuraStreamAbortError(error); + if (wasStopped) { + flushStreamingTextNow?.(); + streamFinalized = true; + cancelStreamingFlush?.(); + const partialText = streamedTextSoFar.trim(); + const stoppedAssistantMessage: AIMessage | null = partialText + ? { + id: streamingMessageId, + type: "assistant", + kind: "aura_text", + text: partialText, + streaming: false, + createdAt: streamingMessageCreatedAt, + clientCreatedAt: streamingMessageCreatedAt, + localSequence: streamingMessageLocalSequence, + replyToMessageId: userMessage.id, + } + : null; + setMessages((prev) => { + if (!stoppedAssistantMessage) { + return orderChatMessages(prev.filter((entry) => entry.id !== streamingMessageId)); + } + return orderChatMessages(updateMessageById(prev, streamingMessageId, () => stoppedAssistantMessage)); + }); + if (stoppedAssistantMessage && chatId) { + await appendMessageToChat(uid, chatId, stoppedAssistantMessage); + await updateChatThread(uid, chatId, { + title: deriveAssistantChatTitle(chatSeedText, stoppedAssistantMessage), + }); + await refreshRecentThreads(); + } + if (DEBUG_AURA_CLIENT) { + console.log("[AURA_STREAM]", "stream stopped by user", { + uid, + chatId, + messageId: streamingMessageId, + partialLength: partialText.length, + }); + } + return; + } + flushStreamingTextNow?.(); + streamFinalized = true; + cancelStreamingFlush?.(); + if (DEBUG_AURA_CLIENT) { + console.log("[AURA_ERROR]", "ask failed", { + uid, + chatId, + prompt, + attachmentCount: uploadedAttachments.length, + error, + }); + } + void trackLaunchEvent({ + userId: uid, + eventName: "ai_response_failed", + properties: { + source: routeChosen, + chatIntent, + attachmentCount: uploadedAttachments.length, + requiredItemCount: requiredItemIds.length, + elapsedMs: Date.now() - startedAt, + ...analyticsErrorProperties(error), + }, + }); + const fallback = userFacingAuraError(error); + const partialText = streamedTextSoFar.trim(); + const partialAssistantMessage: AIMessage | null = partialText + ? { + id: streamingMessageId, + type: "assistant", + kind: "aura_text", + text: partialText, + streaming: false, + createdAt: streamingMessageCreatedAt, + clientCreatedAt: streamingMessageCreatedAt, + localSequence: streamingMessageLocalSequence, + replyToMessageId: userMessage.id, + } + : null; + const failureMessage: AIMessage = { + ...createSystemMessage(fallback), + replyToMessageId: userMessage.id, + }; + setMessages((prev) => { + const withPartial = partialAssistantMessage + ? updateMessageById(prev, streamingMessageId, () => partialAssistantMessage) + : prev.filter((entry) => entry.id !== streamingMessageId); + if (withPartial.some((entry) => entry.type === "system/action" && entry.text === fallback)) { + return orderChatMessages(withPartial); + } + return orderChatMessages([...withPartial, failureMessage]); + }); if (chatId) { - await appendMessageToChat(uid, chatId, fallback); + if (partialAssistantMessage) { + await appendMessageToChat(uid, chatId, partialAssistantMessage); + } + await appendMessageToChat(uid, chatId, failureMessage); + await refreshRecentThreads(); } + Toast.error("AURA couldn't finish", fallback); } finally { + cancelStreamingFlush?.(); + if (activeStreamController && streamAbortControllerRef.current === activeStreamController) { + streamAbortControllerRef.current = null; + } + stopStreamingRequestedRef.current = false; setLoading(false); } }, - [activeChatId, loading, message, triggerThinkingPulse, uid] + [ + activeChatId, + clearComposer, + items, + latestMessagesRef, + loading, + message, + minimumClosetSummary, + pendingAttachments, + profilePreferences, + refreshRecentThreads, + setActiveChatId, + setMessages, + setQuickChips, + stopStreamingRequestedRef, + streamAbortControllerRef, + uid, + ] + ); + + const handleComposerSend = React.useCallback(async () => { + if (recordingAudio) { + await stopVoiceInput({ discardTranscript: true }); + } + await handleAsk(); + }, [handleAsk, recordingAudio, stopVoiceInput]); + + const handleRetryAuraResponse = React.useCallback( + (sourceMessage: AIMessage) => { + if (loading) return; + const currentMessages = latestMessagesRef.current; + const sourceIndex = currentMessages.findIndex((entry) => entry.id === sourceMessage.id); + const startIndex = sourceIndex >= 0 ? sourceIndex - 1 : currentMessages.length - 1; + let retryUserMessage: AIMessage | null = null; + for (let index = startIndex; index >= 0; index -= 1) { + const candidate = currentMessages[index]; + if (candidate?.type === "user") { + retryUserMessage = candidate; + break; + } + } + if (!retryUserMessage) { + Toast.error("Nothing to retry", "Send a new message and AURA will pick it up."); + return; + } + void handleAsk(undefined, { + retryUserMessage, + removeMessageId: sourceMessage.id, + forceOutfitDiversity: true, + diversityMessages: + sourceIndex >= 0 + ? currentMessages.slice(0, sourceIndex + 1) + : currentMessages, + }); + }, + [handleAsk, latestMessagesRef, loading], + ); + + const handleCopyMessage = React.useCallback(async (sourceMessage: AIMessage) => { + const text = messageTextForClipboard(sourceMessage); + if (!text) { + Toast.error("Nothing to copy", "This message does not have copyable text."); + return; + } + await Clipboard.setStringAsync(text); + Toast.success("Copied", "Message copied to clipboard."); + }, []); + + const handleMessageLongPress = React.useCallback( + (sourceMessage: AIMessage) => { + if (sourceMessage.streaming) return; + if (sourceMessage.type === "user") { + Alert.alert("Message", undefined, [ + { + text: "Copy", + onPress: () => void handleCopyMessage(sourceMessage), + }, + { + text: "Edit and resend", + onPress: () => { + setMessage(sanitizeMultilineDisplayText(sourceMessage.text ?? "") || ""); + setPendingAttachments([]); + }, + }, + { text: "Cancel", style: "cancel" }, + ]); + return; + } + if (sourceMessage.type === "assistant" || sourceMessage.type === "system/action") { + const buttons: AlertButton[] = [ + { + text: "Copy", + onPress: () => void handleCopyMessage(sourceMessage), + }, + ...(!loading + ? [{ + text: "Regenerate", + onPress: () => handleRetryAuraResponse(sourceMessage), + }] + : []), + { text: "Cancel", style: "cancel" }, + ]; + Alert.alert("Message", undefined, buttons); + } + }, + [handleCopyMessage, handleRetryAuraResponse, loading, setMessage, setPendingAttachments], ); const handleAuraLookAction = React.useCallback( - async (action: import("@/src/types/aura").AuraLookAction, sourceMessage: AIMessage) => { - const look = sourceMessage.aura?.look; + async ( + action: AuraLookAction, + sourceMessage: AIMessage, + selectedLook?: import("@/src/types/aura").AuraLook, + lookOption?: AuraLookOptionMeta, + ) => { + const look = selectedLook ?? sourceMessage.aura?.look; const promptBase = look?.lookTitle || sourceMessage.aura?.title || "this look"; if (!look || !uid) return; - if (action === "saveLook") { - try { - await saveAuraLook(uid, look, { title: sourceMessage.aura?.title }); - Alert.alert("Saved", "Look saved to your profile."); - } catch (error: any) { - Alert.alert("Save failed", error?.message ?? "Unable to save this look."); + if (loading) return; + if (action === "shopMissingPieces") { + const missingPieces = look.addToComplete.filter(Boolean); + const suggestion = missingPieces[0] + ? buildAdHocWardrobeSuggestion(missingPieces[0]) + : buildWardrobeSuggestions({ items, profilePreferences })[0] ?? null; + if (suggestion) { + setActiveShopSuggestion(suggestion); + return; } + } + await handleSharedAuraLookAction({ + uid, + action, + look, + promptBase, + saveTitle: sourceMessage.aura?.title, + feedbackContext: { + chatId: activeChatId, + messageId: sourceMessage.id, + option: lookOption, + }, + onPrompt: (prompt) => { + void handleAsk(prompt); + }, + onAlert: (title, message, buttons) => Alert.alert(title, message, buttons), + onAfterSave: () => runHaptic("light"), + onAfterPlan: () => runHaptic("light"), + onAfterWear: () => runHaptic("light"), + }); + }, + [activeChatId, handleAsk, items, loading, profilePreferences, uid] + ); + + const updateCandidateStatuses = React.useCallback( + (sourceMessage: AIMessage, candidateIds: string[], status: AuraCandidateItem["status"]) => { + const ids = new Set(candidateIds); + const nextAura = sourceMessage.aura + ? { + ...sourceMessage.aura, + candidateItems: (sourceMessage.aura.candidateItems ?? sourceMessage.aura.candidates)?.map((candidate) => + ids.has(candidate.candidateId) ? { ...candidate, status } : candidate + ), + candidates: (sourceMessage.aura.candidateItems ?? sourceMessage.aura.candidates)?.map((candidate) => + ids.has(candidate.candidateId) ? { ...candidate, status } : candidate + ), + } + : null; + if (!nextAura) return; + setMessages((prev) => + prev.map((entry) => + entry.id === sourceMessage.id + ? { + ...entry, + aura: nextAura, + text: nextAura.reply, + } + : entry + ) + ); + }, + [setMessages] + ); + + const handleAuraCandidateAction = React.useCallback( + async (action: AuraCandidateAction, sourceMessage: AIMessage) => { + if (!uid) { + Alert.alert("AURA", "Please sign in to add items."); return; } - if (action === "planForToday") { - try { - await savePlannedRecord(uid, new Date(), auraLookToPlannedOutfit(look)); - Alert.alert("Planned", "This look is now attached to today."); - } catch (error: any) { - Alert.alert("Plan failed", error?.message ?? "Unable to plan this look for today."); + const candidates = sourceMessage.aura?.candidates ?? []; + const candidateItems = sourceMessage.aura?.candidateItems ?? candidates; + const targetCandidates = + action.type === "add_all_candidates" + ? candidateItems.filter((candidate) => candidate.status === "awaiting_confirmation") + : candidateItems.filter((candidate) => candidate.candidateId === action.candidateId); + if (!targetCandidates.length) return; + + if (action.type === "cancel_candidate") { + updateCandidateStatuses(sourceMessage, targetCandidates.map((candidate) => candidate.candidateId), "cancelled"); + return; + } + + try { + if (DEBUG_AURA_CLIENT) { + console.log("[AURA_CONFIRM]", "candidate confirm started", { + uid, + action, + candidateIds: targetCandidates.map((candidate) => candidate.candidateId), + candidateCount: targetCandidates.length, + }); + } + const localPhotosByCandidateId = resolveLocalPhotosForAuraCandidates( + latestMessagesRef.current, + sourceMessage, + targetCandidates + ); + if (DEBUG_AURA_CLIENT) { + console.log("[AURA_CONFIRM]", "candidate local photo resolution", { + uid, + candidateIds: targetCandidates.map((candidate) => candidate.candidateId), + localPhotoCandidateIds: Object.keys(localPhotosByCandidateId).filter( + (candidateId) => !!localPhotosByCandidateId[candidateId]?.localUri + ), + }); + } + const created = await createAuraItemDraftsFromCandidates({ + uid, + candidates: targetCandidates, + prompt: sourceMessage.text ?? sourceMessage.aura?.reply ?? "", + mode: action.type === "edit_candidate" ? "awaiting_confirmation" : "pending", + localPhotosByCandidateId, + }); + if (action.type === "edit_candidate") { + const first = created[0]; + if (first?.itemId) { + router.push({ + pathname: "/(tabs)/add", + params: { editId: first.itemId, sourceItemId: first.itemId, sourceRoute: "/(tabs)/ai" }, + }); + } + return; + } + updateCandidateStatuses(sourceMessage, targetCandidates.map((candidate) => candidate.candidateId), "added"); + const systemMessage = createSystemMessage( + created.length === 1 + ? "Added to your wardrobe. Processing it now." + : `Added ${created.length} items to your wardrobe. Processing them now.` + ); + setMessages((prev) => orderChatMessages(appendUniqueSystemMessage(prev, systemMessage.text ?? ""))); + if (activeChatId) { + await appendMessageToChat(uid, activeChatId, systemMessage); + await refreshRecentThreads(); + } + } catch (error) { + if (DEBUG_AURA_CLIENT) { + console.log("[AURA_CONFIRM_ERROR]", "candidate action failed", { + uid, + action, + errorCode: (error as { code?: unknown })?.code ?? null, + errorMessage: error instanceof Error ? error.message : String(error), + error, + }); } + updateCandidateStatuses(sourceMessage, targetCandidates.map((candidate) => candidate.candidateId), "failed"); + } + }, + [activeChatId, latestMessagesRef, refreshRecentThreads, setMessages, uid, updateCandidateStatuses] + ); + + const handleAuraOutfitPhotoAction = React.useCallback( + async (action: import("@/src/types/aura").AuraOutfitPhotoAction, sourceMessage: AIMessage) => { + if (!uid) { + Alert.alert("AURA", "Please sign in to save this outfit."); return; } - if (action === "showMoreLikeThis") { - void handleAsk(`Show me 3 more looks like ${promptBase}.`); + const analysis = sourceMessage.aura?.outfitAnalysis; + if (!analysis) return; + const sourcePhoto = resolveOutfitSourcePhoto(latestMessagesRef.current, sourceMessage); + const sourceImageUrl = analysis.sourceImageUrl || sourcePhoto?.uri || null; + + if (action.type === "improve_outfit") { + const pieces = analysis.detectedPieces + .map((piece) => [piece.color, piece.label].filter(Boolean).join(" ")) + .filter(Boolean) + .join(", "); + void handleAsk(`How do I improve this outfit? Visible pieces: ${pieces || "use the uploaded outfit photo context"}.`); return; } - if (action === "shopMissingPieces") { - const missingPieces = look.addToComplete.filter(Boolean); - Alert.alert( - "Missing pieces", - missingPieces.length - ? missingPieces.join("\n") - : "AURA does not see any missing pieces in this look yet.", - missingPieces.length - ? [ - { text: "Close", style: "cancel" }, - { - text: "Create shopping brief", - onPress: () => void handleAsk(`Turn ${promptBase} into a concise shopping brief for the missing pieces.`), - }, - ] - : [{ text: "Close", style: "cancel" }] - ); + + if (action.type === "add_pieces_to_closet") { + try { + const created = await createAuraItemDraftsFromDetectedOutfit({ + uid, + pieces: analysis.detectedPieces, + sourceImageUrl, + prompt: sourceMessage.text ?? sourceMessage.aura?.reply ?? "Outfit photo analysis", + }); + Toast.success( + "Drafts created", + created.length === 1 + ? "Added one outfit piece draft using the original photo as reference." + : `Added ${created.length} outfit piece drafts using the original photo as reference.` + ); + } catch (error: any) { + Toast.error("Drafts failed", error?.message ?? "Unable to create outfit piece drafts."); + } return; } - if (action === "useOnlyMyCloset") { - void handleAsk(`Rebuild ${promptBase} using only my closet.`); + + if (action.type === "save_worn_outfit") { + try { + await markAnalyzedOutfitWorn(uid, new Date(), { + detectedPieces: analysis.detectedPieces, + outfitVibe: analysis.outfitVibe ?? null, + stylingNotes: analysis.stylingNotes ?? [], + missingToComplete: analysis.missingToComplete ?? [], + sourceImageUrl, + wornAt: Date.now(), + }); + void runHaptic("light"); + Toast.success("Saved", "This outfit is saved as worn today."); + } catch (error: any) { + Toast.error("Save failed", error?.message ?? "Unable to save this worn outfit."); + } + } + }, + [handleAsk, latestMessagesRef, uid] + ); + + const handleAuraLaundryAction = React.useCallback( + async (action: AuraLaundryConfirmationAction) => { + if (!uid) { + Alert.alert("AURA", "Please sign in to update laundry."); return; } - if (action === "makeItDressier") { - void handleAsk(`Make ${promptBase} dressier.`); + try { + await updateLaundryStatus(uid, action.itemId, action.targetStatus); + const item = itemsById.get(action.itemId); + const label = item?.name || item?.subCategory || item?.category || "that item"; + const statusLabel = + action.targetStatus === "clean" + ? "clean" + : action.targetStatus === "in_laundry" + ? "in laundry" + : "needs wash"; + const systemMessage = createSystemMessage(`Done — marked ${label} as ${statusLabel}.`); + setMessages((prev) => orderChatMessages(appendUniqueSystemMessage(prev, systemMessage.text ?? ""))); + void runHaptic("light"); + Toast.laundryUpdated(`${label} is ${statusLabel}.`); + if (activeChatId) { + await appendMessageToChat(uid, activeChatId, systemMessage); + } + } catch (error: any) { + Toast.error("Laundry update failed", error?.message ?? "Could not update that item."); } }, - [handleAsk, uid] + [activeChatId, itemsById, setMessages, uid] ); useEffect(() => { if (!routePrompt || isBooting || !uid) return; - const token = `${routePromptKey}:${routePrompt}`; - if (consumedPromptTokens.has(token)) return; - consumedPromptTokens.add(token); - void handleAsk(routePrompt); - }, [handleAsk, isBooting, routePrompt, routePromptKey, uid]); - - const latestReply = useMemo(() => { - for (let index = messages.length - 1; index >= 0; index -= 1) { - const current = messages[index]; - if (current.type === "assistant" && current.aura) return current.aura; - } - return null; - }, [messages]); + const token = `${routePromptKey}:${routePrompt}:${routeRequiredItemIds.join("|")}`; + if (consumedPromptTokens.current.has(token)) return; + consumedPromptTokens.current.add(token); + void handleAsk(routePrompt, { requiredItemIds: routeRequiredItemIds }); + }, [handleAsk, isBooting, routePrompt, routePromptKey, routeRequiredItemIds, uid]); - const heroChips = latestReply?.chips?.length ? latestReply.chips : quickChips; - const orbScale = auraPulse.interpolate({ - inputRange: [0, 1], - outputRange: [1, 1.08], - }); - const orbGlow = auraPulse.interpolate({ - inputRange: [0, 1], - outputRange: [0.16, 0.34], - }); - const thinkingScale = auraThinking.interpolate({ - inputRange: [0, 1], - outputRange: [1, 1.08], - }); - const thinkingGlow = auraThinking.interpolate({ - inputRange: [0, 1], - outputRange: [0, 0.18], - }); - const restingComposerBottom = Math.max(10, layout.composerOffset - 18); + const chatBackgroundColors = useMemo( + () => ["#111014", Colors.dark.backgroundDeep, Colors.dark.backgroundDark] as const, + [], + ); + const chatBottomGlowColors = useMemo( + () => ["rgba(223,182,178,0.018)", "rgba(9,0,11,0.12)", "transparent"] as const, + [], + ); + const restingComposerBottom = + layout.composerOffset - Math.max(0, FLOATING_CONTROL_GAP - CHAT_COMPOSER_TAB_GAP); const isComposerActive = isComposerFocused || keyboardHeight > 0; - const composerBottom = keyboardHeight > 0 ? keyboardHeight + 6 : restingComposerBottom; - const composerContentPadding = - composerBottom + composerHeight + Math.max(layout.bottomDockPadding * 0.48, insets.bottom + 34); - const starterCardsPadding = composerBottom + composerHeight + 28 + Math.max(0, insets.bottom - 8); + const keyboardComposerBottom = + keyboardHeight > 0 + ? Math.max(12, keyboardHeight + (Platform.OS === "ios" ? 10 : 6)) + : 0; + const composerBottom = keyboardHeight > 0 ? keyboardComposerBottom : restingComposerBottom; + const dockBottomInset = Math.max(insets.bottom, layout.floatingDockBottom); + const floatingDockObstruction = dockBottomInset + DOCK_HEIGHT + CHAT_COMPOSER_TAB_GAP; + const composerObstruction = composerBottom + composerHeight; + const chatBottomInset = Math.max( + composerObstruction, + floatingDockObstruction + composerHeight, + insets.bottom + composerHeight + DOCK_HEIGHT + FLOATING_CONTROL_GAP, + ) + CHAT_BOTTOM_BREATHING_ROOM; + const lastMessageIdForFocus = orderedMessages[orderedMessages.length - 1]?.id ?? "empty"; + const focusScrollKey = `${activeChatId ?? "new"}:${lastMessageIdForFocus}:${orderedMessages.length}`; + const chatAutoScrollReady = chatBottomInset > 0 && composerHeight > 0 && layout.height > 0; + + useEffect(() => { + if (loading || !focusMessageId) return; + const timer = setTimeout(() => setFocusMessageId(null), 240); + return () => clearTimeout(timer); + }, [focusMessageId, loading]); + + const enforceAuraSessionWindow = React.useCallback(async () => { + if (!uid) return; + const session = await loadAuraChatSessionMeta(uid); + if (activeChatId) { + if (session && !isAuraChatSessionFresh(session)) { + setMessage(""); + setPendingAttachments([]); + setMessages([]); + setFocusMessageId(null); + setActiveChatId(null); + setQuickChips(DEFAULT_CHIPS); + await saveAuraChatSessionMeta(uid, null); + return; + } + await saveAuraChatSessionMeta(uid, activeChatId); + return; + } + if (orderedMessages.length === 0) { + await saveAuraChatSessionMeta(uid, null); + } + }, [ + activeChatId, + orderedMessages.length, + setActiveChatId, + setMessage, + setMessages, + setPendingAttachments, + setQuickChips, + uid, + ]); + useFocusEffect( + React.useCallback(() => { + let cancelled = false; + void (async () => { + await enforceAuraSessionWindow(); + if (cancelled) return; + })(); + return () => { + cancelled = true; + }; + }, [enforceAuraSessionWindow]), + ); + + useEffect(() => { + const subscription = AppState.addEventListener("change", (state) => { + if (state === "active") { + void enforceAuraSessionWindow(); + } + }); + return () => subscription.remove(); + }, [enforceAuraSessionWindow]); + + useFocusEffect( + React.useCallback(() => { + let cancelled = false; + const scheduleScroll = () => { + if (cancelled || !chatAutoScrollReady) return; + void focusScrollKey; + requestAnimationFrame(() => { + if (!cancelled) setFocusScrollSignal((value) => value + 1); + }); + }; + const firstTimer = setTimeout(scheduleScroll, 90); + const secondTimer = setTimeout(scheduleScroll, 220); + const finalTimer = setTimeout(scheduleScroll, 520); + return () => { + cancelled = true; + clearTimeout(firstTimer); + clearTimeout(secondTimer); + clearTimeout(finalTimer); + }; + }, [chatAutoScrollReady, focusScrollKey]) + ); + + const handleChatRefresh = React.useCallback(async () => { + if (!uid || chatRefreshing) return; + setChatRefreshing(true); + const startedAt = Date.now(); + try { + await refreshRecentThreads(); + if (activeChatId) { + const threadMessages = await loadChatMessages(uid, activeChatId); + setMessages(orderChatMessages(threadMessages)); + await saveLatestChatCache(uid, activeChatId, null, threadMessages); + await saveAuraChatSessionMeta(uid, activeChatId); + } + setFocusScrollSignal((value) => value + 1); + } catch { + Toast.error("Refresh failed", "AURA could not refresh this chat just now."); + } finally { + const remaining = Math.max(0, 450 - (Date.now() - startedAt)); + setTimeout(() => setChatRefreshing(false), remaining); + } + }, [ + activeChatId, + chatRefreshing, + refreshRecentThreads, + setMessages, + uid, + ]); + const handleTrainingPress = React.useCallback(() => router.push(AURA_TRAINING_ROUTE), []); + const showEmptyState = orderedMessages.length === 0 && !loading && !isBooting && !message.trim(); + const showKeyboardWatermark = keyboardHeight > 0 && orderedMessages.length < 2 && !showEmptyState; + const visibleHeroChips = + orderedMessages.length === 0 && !showEmptyState && !loading && !isBooting && !message.trim() + ? AURA_TOP_CHIPS.slice(0, 1) + : []; + const emptyChatState = useMemo( + () => + isBooting ? ( + + ) : showEmptyState ? ( + void handleAsk(prompt)} + onTrainingPress={handleTrainingPress} + /> + ) : ( + + ), + [handleAsk, handleTrainingPress, isBooting, showEmptyState], + ); + const handleSaveOutfitFromLegacyMessage = React.useCallback((_outfitId: string) => {}, []); + const handleMoreLikeThisFromLegacyMessage = React.useCallback( + (outfit: ChatOutfit) => { + if (outfit.reason) void handleAsk(`Refine this direction: ${outfit.reason}`); + }, + [handleAsk], + ); + const handleSwapFromLegacyMessage = React.useCallback( + (outfit: ChatOutfit) => { + if (outfit.reason) void handleAsk(`Keep the mood, but swap one piece: ${outfit.reason}`); + }, + [handleAsk], + ); return ( - - - + + + - setChatDrawerOpen(true)} + onReset={() => { + setMessage(""); + setPendingAttachments([]); + setMessages([]); + setActiveChatId(null); + setQuickChips(DEFAULT_CHIPS); + if (uid) { + void saveAuraChatSessionMeta(uid, null); + } }} - > - - { - if (!recentThreads.length || !uid) { - Alert.alert("AURA", "No recent chats yet."); + /> + + {visibleHeroChips.length ? ( + + { + if (chip === TRAIN_AURA_CHIP_LABEL) { + handleTrainingPress(); return; } - Alert.alert( - "Recent chats", - undefined, - [ - ...recentThreads.slice(0, 5).map((thread) => ({ - text: thread.title || "AURA chat", - onPress: async () => { - const threadMessages = await loadChatMessages(uid, thread.chatId); - setMessages(threadMessages); - setActiveChatId(thread.chatId); - await saveLatestChatCache(thread.chatId, thread.threadId, threadMessages); - }, - })), - { text: "Cancel", style: "cancel" as const }, - ] - ); - }} - style={({ pressed }) => ({ - width: 52, - height: 52, - borderRadius: 999, - alignItems: "center", - justifyContent: "center", - backgroundColor: "rgba(255,255,255,0.06)", - borderWidth: 1, - borderColor: "rgba(255,255,255,0.06)", - shadowColor: "#000", - shadowOpacity: 0.16, - shadowRadius: 12, - shadowOffset: { width: 0, height: 6 }, - opacity: pressed ? 0.84 : 1, - })} - > - - - - - - - - - - - - - - AURA - - - - { - setMessage(""); - setMessages([]); - setActiveChatId(null); - setQuickChips(DEFAULT_CHIPS); - void clearLatestChatCache(); - }} - style={({ pressed }) => ({ - width: 52, - height: 52, - borderRadius: 999, - alignItems: "center", - justifyContent: "center", - backgroundColor: "rgba(255,255,255,0.06)", - borderWidth: 1, - borderColor: "rgba(255,255,255,0.06)", - shadowColor: "#000", - shadowOpacity: 0.16, - shadowRadius: 12, - shadowOffset: { width: 0, height: 6 }, - opacity: pressed ? 0.82 : 1, - })} - > - - - - - {messages.length === 0 ? null : ( - void handleAsk(chip)} /> - )} - - - {messages.length === 0 && !loading && !isBooting && !message.trim() ? ( - - - - AURA - - - Talk through a look, ask a style question, or just start chatting. - - - AURA can build looks, help you shop smarter, style a piece, or use your closet when it has something to work with. - + /> + ) : null} - void handleAsk(chip)} - /> - - ) : ( - + } - onSaveOutfit={() => {}} - onMoreLikeThis={(outfit) => { - if (outfit.reason) void handleAsk(`Refine this direction: ${outfit.reason}`); - }} - onSwapOutfit={(outfit) => { - if (outfit.reason) void handleAsk(`Keep the mood, but swap one piece: ${outfit.reason}`); - }} + contentBottomPadding={chatBottomInset} + autoScrollSignal={focusScrollSignal} + focusMessageId={focusMessageId} + emptyState={emptyChatState} + refreshing={chatRefreshing} + onRefresh={handleChatRefresh} + onSaveOutfit={handleSaveOutfitFromLegacyMessage} + onMoreLikeThis={handleMoreLikeThisFromLegacyMessage} + onSwapOutfit={handleSwapFromLegacyMessage} onAuraAction={handleAuraLookAction} - /> - )} + onAuraCandidateAction={handleAuraCandidateAction} + onAuraOutfitPhotoAction={handleAuraOutfitPhotoAction} + onAuraLaundryAction={handleAuraLaundryAction} + onRetryAuraResponse={handleRetryAuraResponse} + onMessageLongPress={handleMessageLongPress} + /> + {showKeyboardWatermark ? ( + + + + ) : null} + - void handleAsk()} - onPlusPress={() => Alert.alert("AURA", "Image styling is coming soon.")} - /> - + onSend={() => void handleComposerSend()} + onStop={hasStreamingMessage ? handleStopGenerating : undefined} + onPickImages={() => void handlePickImages()} + onTakePhoto={() => void handleTakePhoto()} + attachments={pendingAttachments} + attachmentRole={attachmentRole} + onAttachmentRoleChange={handleAttachmentRoleChange} + onRemoveAttachment={handleRemoveAttachment} + onMicPress={() => void handleMicPress()} + recording={recordingAudio} + /> + setActiveShopSuggestion(null)} + /> + setChatDrawerOpen(false)} + onShareChat={handleShareChatThread} + onTogglePin={handleTogglePinnedThread} + onRenameChat={handleRenameThread} + onArchiveChat={handleArchiveThread} + onDeleteChat={handleDeleteThread} + onSelectChat={async (thread) => { + if (!uid) return; + const cachedMessages = await getCachedRecentMessages(uid, thread.chatId); + if (cachedMessages?.data?.length) { + setMessages(orderChatMessages(cachedMessages.data)); + setActiveChatId(thread.chatId); + } + await saveAuraChatSessionMeta(uid, thread.chatId); + const threadMessages = await loadChatMessages(uid, thread.chatId); + const orderedThreadMessages = orderChatMessages(threadMessages); + setMessages(orderedThreadMessages); + setActiveChatId(thread.chatId); + await saveLatestChatCache(uid, thread.chatId, thread.threadId, orderedThreadMessages); + await saveAuraChatSessionMeta(uid, thread.chatId); + setChatDrawerOpen(false); + }} + /> + + ); } diff --git a/app/(tabs)/calendar.tsx b/app/(tabs)/calendar.tsx index c0524a4..c32a193 100644 --- a/app/(tabs)/calendar.tsx +++ b/app/(tabs)/calendar.tsx @@ -1,9 +1,14 @@ import { router } from "expo-router"; -import { collection, doc, increment, onSnapshot, orderBy, query, serverTimestamp, writeBatch } from "firebase/firestore"; -import * as Haptics from "expo-haptics"; import { Ionicons } from "@expo/vector-icons"; import React, { useCallback, useEffect, useMemo, useState } from "react"; -import { Alert, Modal, Pressable, ScrollView, StyleSheet, Text, View } from "react-native"; +import { Alert, Modal, Pressable, RefreshControl, ScrollView, StyleSheet, Text, View } from "react-native"; +import Reanimated, { + Easing, + useAnimatedStyle, + useSharedValue, + withDelay, + withTiming, +} from "react-native-reanimated"; import AgendaCard from "@/src/components/AgendaCard"; import DailyOutfitCard from "@/src/components/DailyOutfitCard"; @@ -13,7 +18,16 @@ import DateRail from "@/src/components/calendar/DateRail"; import DayContextCard from "@/src/components/calendar/DayContextCard"; import SwapSheet from "@/src/components/calendar/SwapSheet"; import TimelineCard from "@/src/components/calendar/TimelineCard"; -import WeatherStrip from "@/src/components/calendar/WeatherStrip"; +import { AuraAnimatedSection } from "@/src/components/motion"; +import { + auraButtonStyle, + auraButtonTextStyle, + auraCardStyle, + auraSheetBackdropStyle, + AuraTopSafeAreaScrim, + auraTypography, +} from "@/src/components/ui/auraStylePrimitives"; +import { AuraSkeleton, AuraSkeletonLine } from "@/src/components/ui/AuraSkeleton"; import { useDayEvents } from "@/src/hooks/useDayEvents"; import { useDayWeather } from "@/src/hooks/useDayWeather"; import { useNow } from "@/src/hooks/useNow"; @@ -27,7 +41,6 @@ import { clearPlan, copyPlan, setPlanned, - setWorn, subscribeOutfitByDate, subscribeOutfitsInRange, } from "@/src/utils/dailyOutfits"; @@ -36,22 +49,19 @@ import { getDailyWeather } from "@/src/utils/weatherDaily"; import { getLoggedOutfitDays, getOutfitStreak } from "@/src/utils/streak"; import { useAppTheme } from "@/src/hooks/useAppTheme"; import { useAuth } from "@/src/hooks/useAuth"; -import { db } from "@/src/lib/firebase"; -import { MAX_WEARS_BEFORE_WASH, isVisibleWardrobeItem, toCanonicalCategory } from "@/src/lib/items"; +import { useReduceMotion } from "@/hooks/useReduceMotion"; +import { listenToItems, normalizeLaundryStatus, toCanonicalCategory } from "@/src/lib/items"; +import { impactLight } from "@/src/lib/haptics"; +import { Toast } from "@/src/lib/toast"; +import { markOutfitWorn, planOutfitForToday } from "@/src/lib/wearOutfit"; +import { FLOATING_TAB_BAR_HEIGHT } from "@/src/constants/dock"; import type { ClothingItem } from "@/src/types/ClothingItem"; type SectionIconName = "calendar" | "sparkles" | "chart.bar.xaxis"; -type SlotKey = keyof OutfitItemsByCategory; - -const DateTimePickerModule = (() => { - try { - // eslint-disable-next-line @typescript-eslint/no-require-imports - return require("@react-native-community/datetimepicker"); - } catch { - return null; - } -})(); -const NativeDatePicker = DateTimePickerModule?.default ?? null; +type SlotKey = Exclude; +type RailWeather = { high?: number; low?: number; label?: string }; + +const WEEKDAY_LABELS = ["S", "M", "T", "W", "T", "F", "S"] as const; function iconFallback(name: SectionIconName): keyof typeof Ionicons.glyphMap { if (name === "calendar") return "calendar-outline"; @@ -65,8 +75,25 @@ function SectionHeader({ icon, title }: { icon: SectionIconName; title: string } const styles = useMemo(() => createStyles(colors, layout), [colors, layout]); return ( - - {title} + + {title} + + ); +} + +function CalendarStatusSkeleton() { + const layout = useResponsiveLayout(); + return ( + + + ); } @@ -88,12 +115,8 @@ function computeDaysSinceLastWorn(item: ClothingItem) { return Math.max(0, Math.floor((Date.now() - date.getTime()) / (24 * 60 * 60 * 1000))); } -async function hapticLight() { - try { - await Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); - } catch { - // no-op - } +function hapticLight() { + return impactLight(); } function lookToPlanned(look: PlannedLook): PlannedOutfit { @@ -114,6 +137,32 @@ function buildRailDateKeys(anchor: Date) { return Array.from({ length: 121 }, (_, i) => toDayKey(addDays(anchor, i - 60))); } +function dateOnly(date: Date) { + return new Date(date.getFullYear(), date.getMonth(), date.getDate()); +} + +function startOfMonth(date: Date) { + return new Date(date.getFullYear(), date.getMonth(), 1); +} + +function addMonths(date: Date, offset: number) { + return new Date(date.getFullYear(), date.getMonth() + offset, 1); +} + +function buildCalendarWeeks(month: Date) { + const monthStart = startOfMonth(month); + const gridStart = new Date(monthStart); + gridStart.setDate(monthStart.getDate() - monthStart.getDay()); + + return Array.from({ length: 6 }, (_, weekIndex) => + Array.from({ length: 7 }, (_, dayIndex) => { + const date = new Date(gridStart); + date.setDate(gridStart.getDate() + weekIndex * 7 + dayIndex); + return date; + }), + ); +} + function DatePickerSheet({ visible, value, @@ -132,28 +181,152 @@ function DatePickerSheet({ const { colors } = useAppTheme(); const layout = useResponsiveLayout(); const styles = useMemo(() => createStyles(colors, layout), [colors, layout]); + const [visibleMonth, setVisibleMonth] = useState(() => startOfMonth(value)); + + useEffect(() => { + if (visible) { + setVisibleMonth(startOfMonth(value)); + } + }, [value, visible]); + + const todayKey = toDayKey(new Date()); + const selectedKey = useMemo(() => toDayKey(value), [value]); + const calendarWeeks = useMemo(() => buildCalendarWeeks(visibleMonth), [visibleMonth]); + const monthLabel = useMemo( + () => new Intl.DateTimeFormat(undefined, { month: "long", year: "numeric" }).format(visibleMonth), + [visibleMonth], + ); + + const onSelectDate = useCallback( + (date: Date) => { + const nextDate = dateOnly(date); + setVisibleMonth(startOfMonth(nextDate)); + onChange(nextDate); + void hapticLight(); + }, + [onChange], + ); + + const onDone = useCallback(() => { + void hapticLight(); + onConfirm(); + }, [onConfirm]); + return ( - {title} - {NativeDatePicker ? ( - { - if (nextDate) onChange(nextDate); - }} - /> - ) : ( - Date picker is unavailable in this client. - )} + + {title} + + + + + + + setVisibleMonth((month) => addMonths(month, -1))} + > + + + {monthLabel} + setVisibleMonth((month) => addMonths(month, 1))} + > + + + + + + {WEEKDAY_LABELS.map((label, index) => ( + + {label} + + ))} + + + + {calendarWeeks.map((week, weekIndex) => ( + + {week.map((date) => { + const dateKey = toDayKey(date); + const inVisibleMonth = + date.getFullYear() === visibleMonth.getFullYear() && date.getMonth() === visibleMonth.getMonth(); + const isSelected = dateKey === selectedKey; + const isToday = dateKey === todayKey; + return ( + onSelectDate(date)} + > + + + {date.getDate()} + + {isToday ? ( + + ) : null} + + + ); + })} + + ))} + + Cancel - + Done @@ -168,7 +341,7 @@ export default function CalendarScreen() { const { colors } = useAppTheme(); const layout = useResponsiveLayout(); const uid = user?.uid ?? null; - const bottomDockPadding = layout.bottomDockPadding; + const bottomDockPadding = layout.bottomDockPadding + 24; const { greeting, timeLabel } = useNow(); const day = useSelectedDate(); @@ -179,6 +352,7 @@ export default function CalendarScreen() { const [items, setItems] = useState([]); const [loadingItems, setLoadingItems] = useState(true); + const [refreshing, setRefreshing] = useState(false); const [saving, setSaving] = useState(false); const [record, setRecord] = useState(null); const [looks, setLooks] = useState([]); @@ -189,7 +363,7 @@ export default function CalendarScreen() { const [loggedDaySet, setLoggedDaySet] = useState>(new Set()); const [railStatuses, setRailStatuses] = useState>({}); const [weeklyFlags, setWeeklyFlags] = useState(Array.from({ length: 7 }, () => 0)); - const [weekWeather, setWeekWeather] = useState<{ label: string; high?: number; low?: number; selected: boolean }[]>([]); + const [railWeatherByDate, setRailWeatherByDate] = useState>({}); const [swapOpen, setSwapOpen] = useState(false); const [swapSlot, setSwapSlot] = useState(null); const [jumpPickerOpen, setJumpPickerOpen] = useState(false); @@ -222,7 +396,6 @@ export default function CalendarScreen() { }, [loadStreakData]); useEffect(() => { - let unsub: undefined | (() => void); if (!uid) { setItems([]); setLoadingItems(false); @@ -230,24 +403,27 @@ export default function CalendarScreen() { return; } - const itemsRef = collection(db, "users", uid, "items"); - const qItems = query(itemsRef, orderBy("createdAt", "desc")); - unsub = onSnapshot( - qItems, - (snap) => { - const next = snap.docs - .map((d) => ({ id: d.id, ...(d.data() as Omit) })) - .filter((item) => isVisibleWardrobeItem(item)); + setItems([]); + setLoadingItems(true); + const unsub = listenToItems( + uid, + (next) => { setItems(next as ClothingItem[]); setLoadingItems(false); }, - (err) => { - console.log(err); - Alert.alert("Firestore error", err.message); - setLoadingItems(false); + { + status: "ALL", + sort: "NEWEST", + onError: (message) => { + if (__DEV__) { + console.log(message); + } + Alert.alert("Firestore error", message); + setLoadingItems(false); + }, } ); - return () => unsub?.(); + return () => unsub(); }, [uid]); useEffect(() => { @@ -323,9 +499,9 @@ export default function CalendarScreen() { useEffect(() => { let cancelled = false; - async function loadWeekWeather() { + async function loadRailWeather() { if (weather.permission !== "granted") { - setWeekWeather([]); + setRailWeatherByDate({}); return; } @@ -354,26 +530,39 @@ export default function CalendarScreen() { const values = await Promise.all(dates.map((date) => getDailyWeather(lat, lon, date))); if (cancelled) return; - setWeekWeather( - values.map((value, index) => ({ - label: new Intl.DateTimeFormat(undefined, { weekday: "short" }).format(dates[index]), + const nextWeatherByDate: Record = {}; + values.forEach((value, index) => { + nextWeatherByDate[toDayKey(dates[index])] = { high: value.highC, low: value.lowC, - selected: toDayKey(dates[index]) === selectedDayKey, - })) - ); + label: value.conditionLabel, + }; + }); + setRailWeatherByDate(nextWeatherByDate); } catch { - if (!cancelled) setWeekWeather([]); + if (!cancelled) setRailWeatherByDate({}); } } - loadWeekWeather().catch(() => { - if (!cancelled) setWeekWeather([]); + loadRailWeather().catch(() => { + if (!cancelled) setRailWeatherByDate({}); }); return () => { cancelled = true; }; - }, [day.weekDates, selectedDayKey, weather.permission]); + }, [day.weekDates, weather.permission]); + + const dateRailWeather = useMemo(() => { + const next = { ...railWeatherByDate }; + if (weather.weather) { + next[selectedDayKey] = { + high: weather.weather.highC, + low: weather.weather.lowC, + label: weather.weather.conditionLabel, + }; + } + return next; + }, [railWeatherByDate, selectedDayKey, weather.weather]); const weatherSummary = useMemo(() => { if (weather.permission === "unknown" || weather.permission === "denied") return "Enable weather insights"; @@ -390,6 +579,7 @@ export default function CalendarScreen() { const contextEventSummary = useMemo(() => { if (events.events.length === 0) return "No events for this day"; const next = events.events[0]; + if (!next) return "No events for this day"; return `Next: ${next.timeLabel} — ${next.title}`; }, [events.events]); @@ -439,6 +629,22 @@ export default function CalendarScreen() { if (events.permission === "granted" && events.state === "error") return events.actions.refresh(); }, [events.actions, events.permission, events.state]); + const onRefresh = useCallback(async () => { + if (refreshing) return; + setRefreshing(true); + const startedAt = Date.now(); + try { + await Promise.allSettled([ + loadStreakData(), + events.actions.refresh(), + weather.actions.refresh(), + ]); + } finally { + const remaining = Math.max(0, 450 - (Date.now() - startedAt)); + setTimeout(() => setRefreshing(false), remaining); + } + }, [events.actions, loadStreakData, refreshing, weather.actions]); + const onSelectDate = useCallback(async (date: Date) => { day.setSelectedDate(date); await hapticLight(); @@ -454,24 +660,50 @@ export default function CalendarScreen() { if (record?.plannedOutfit) return record.plannedOutfit; if (!selectedLook) return null; const planned = lookToPlanned(selectedLook); - const next = await setPlanned(uid, selectedDayKey, planned); - setRecord(next); + await planOutfitForToday({ + uid, + source: "calendar", + title: selectedLook.label, + look: planned, + date: day.selectedDate, + }); + const next = { + dateKey: selectedDayKey, + plannedOutfit: planned, + }; + if (!next) return null; + setRecord((prev) => ({ ...(prev ?? { dateKey: selectedDayKey }), plannedOutfit: planned })); return next.plannedOutfit ?? null; - }, [record?.plannedOutfit, selectedDayKey, selectedLook, uid]); + }, [day.selectedDate, record?.plannedOutfit, selectedDayKey, selectedLook, uid]); const onUseOutfit = useCallback(async () => { if (!uid || !selectedLook) return; - const planned = lookToPlanned(selectedLook); - const next = await setPlanned(uid, selectedDayKey, planned); - setRecord(next); - setSelectedLookId(selectedLook.id); - await hapticLight(); - }, [selectedDayKey, selectedLook, uid]); + try { + const planned = lookToPlanned(selectedLook); + await planOutfitForToday({ + uid, + source: "calendar", + title: selectedLook.label, + look: planned, + date: day.selectedDate, + }); + setRecord((prev) => ({ ...(prev ?? { dateKey: selectedDayKey }), plannedOutfit: planned })); + setSelectedLookId(selectedLook.id); + Toast.success("Planned", "Outfit attached to this day."); + await hapticLight(); + } catch (error: any) { + Toast.error("Plan failed", error?.message ?? "Unable to plan this outfit."); + } + }, [day.selectedDate, selectedDayKey, selectedLook, uid]); const onClearPlan = useCallback(async () => { if (!uid) return; - const next = await clearPlan(uid, selectedDayKey); - setRecord(next); + try { + const next = await clearPlan(uid, selectedDayKey); + setRecord(next); + } catch (error: any) { + Toast.error("Clear plan failed", error?.message ?? "Unable to clear this plan."); + } }, [selectedDayKey, uid]); const onOpenCopyPicker = useCallback(() => { @@ -557,59 +789,49 @@ export default function CalendarScreen() { return; } - const wornItems = planned.itemsByCategory; - if (toDayKey(day.selectedDate) === toDayKey(new Date()) && uid) { - const ids = [wornItems.outerwear, wornItems.top, wornItems.bottom, wornItems.shoes].filter(Boolean) as string[]; - for (const itemId of ids) { - const item = itemsById.get(itemId); - if (!item) continue; - if (item.status === "IN_LAUNDRY") { - Alert.alert("Cannot mark outfit worn", `${item.name || item.category} is in laundry.`); - return; - } - if ((item.wearCountSinceWash ?? 0) >= MAX_WEARS_BEFORE_WASH) { - Alert.alert("Wash required", `${item.name || item.category} reached the wear limit.`); - return; - } - } - - try { - setSaving(true); - const batch = writeBatch(db); - ids.forEach((itemId) => { - const itemRef = doc(db, "users", uid, "items", itemId); - batch.update(itemRef, { - status: "WORN", - wearCountSinceWash: increment(1), - lastWornDate: serverTimestamp(), - }); - }); - await batch.commit(); - } catch (e: unknown) { - const err = e as { message?: string }; - Alert.alert("Error", err.message ?? "Failed to update worn status"); - } finally { - setSaving(false); + if (!uid) return; + try { + setSaving(true); + const result = await markOutfitWorn({ + uid, + source: "calendar", + title: selectedLook?.label ?? "Calendar outfit", + look: planned, + wornAt: day.selectedDate, + updateItemWearCounts: toDayKey(day.selectedDate) === toDayKey(new Date()), + }); + setRecord((prev) => ({ + ...(prev ?? { dateKey: selectedDayKey }), + plannedOutfit: planned ?? prev?.plannedOutfit, + wornOutfit: { + itemsByCategory: planned.itemsByCategory, + wornAt: day.selectedDate.getTime(), + source: "calendar", + title: selectedLook?.label ?? "Calendar outfit", + outfitSnapshot: result.outfitSnapshot, + }, + })); + if (result.alreadyMarked) { + Toast.success("Already marked worn today", "This outfit was not double-counted."); + } else { + Toast.worn(); } + await loadStreakData(); + await hapticLight(); + } catch (e: unknown) { + const err = e as { message?: string }; + Toast.error("Couldn't mark worn. Try again.", err.message); + } finally { + setSaving(false); } - - if (!uid) return; - - const next = await setWorn(uid, selectedDayKey, { - itemsByCategory: wornItems, - wornAt: Date.now(), - }); - setRecord(next); - await loadStreakData(); - await hapticLight(); - }, [day.selectedDate, itemsById, loadStreakData, record?.plannedOutfit, selectedDayKey, selectedLook, uid]); + }, [day.selectedDate, loadStreakData, record?.plannedOutfit, selectedDayKey, selectedLook, uid]); const swapOptions = useMemo(() => { if (!swapSlot) return []; const wantedCategory = slotToCategory(swapSlot); return items .filter((item) => toCanonicalCategory(item.category) === wantedCategory) - .filter((item) => item.status !== "IN_LAUNDRY") + .filter((item) => normalizeLaundryStatus(item) !== "in_laundry") .sort((a, b) => computeDaysSinceLastWorn(b) - computeDaysSinceLastWorn(a)) .map((item) => ({ id: item.id, @@ -631,11 +853,21 @@ export default function CalendarScreen() { return ( + + } contentContainerStyle={{ paddingHorizontal: layout.horizontalPadding, paddingTop: layout.topContentInset, - paddingBottom: Math.max(bottomDockPadding, 120), + paddingBottom: Math.max(bottomDockPadding, 160), }} showsVerticalScrollIndicator={false} > @@ -655,87 +887,101 @@ export default function CalendarScreen() { - - - - {weekWeather.length > 0 ? ( - - - + + + + + + + + + + {events.events.length ? ( + + + + + + ) : null} - - - - - - - - {isPast && !record?.wornOutfit ? ( - - No outfit logged for {selectedDateLabel}. - setSelectedLookId("casual")}> - Plan an outfit - - - ) : ( - setWhyOpen(true)} - onMarkWorn={onMarkWorn} - onClearPlan={onClearPlan} - onCopyPlan={onOpenCopyPicker} - onSwapSlot={onSwapSlot} - /> - )} + + + {isPast && !record?.wornOutfit ? ( + + No outfit logged + + Nothing was marked worn for {selectedDateLabel}. + + setSelectedLookId("casual")}> + Plan an outfit + + + ) : ( + setWhyOpen(true)} + onMarkWorn={onMarkWorn} + onClearPlan={onClearPlan} + onCopyPlan={onOpenCopyPicker} + onSwapSlot={onSwapSlot} + /> + )} + - - - - {weeklyFlags.map((value, index) => ( - - - - ))} + + + + + {weeklyFlags.map((value, index) => ( + + + + ))} + + + {weeklyInsights.map((line) => ( + {line} + ))} + - - {weeklyInsights.map((line) => ( - router.push("/(tabs)")}> - • {line} - - ))} - - + - {loadingItems ? Loading wardrobe… : null} + {loadingItems ? : null} {saving ? Saving worn status… : null} @@ -764,9 +1010,9 @@ export default function CalendarScreen() { title="Jump to date" onChange={setPickerDate} onCancel={() => setJumpPickerOpen(false)} - onConfirm={async () => { + onConfirm={() => { setJumpPickerOpen(false); - await onSelectDate(pickerDate); + day.setSelectedDate(pickerDate); }} /> @@ -782,6 +1028,33 @@ export default function CalendarScreen() { ); } +function AnimatedWeekBar({ active, index }: { active: boolean; index: number }) { + const { colors } = useAppTheme(); + const reduceMotion = useReduceMotion(); + const height = useSharedValue(reduceMotion ? (active ? 18 : 6) : 0); + + useEffect(() => { + const target = active ? 18 : 6; + if (reduceMotion) { + height.value = target; + return; + } + height.value = withDelay( + index * 80, + withTiming(target, { + duration: 400, + easing: Easing.out(Easing.cubic), + }), + ); + }, [active, height, index, reduceMotion]); + + const barStyle = useAnimatedStyle(() => ({ + height: height.value, + })); + + return ; +} + function createStyles( colors: ReturnType["colors"], layout: ReturnType @@ -799,11 +1072,11 @@ return StyleSheet.create({ gap: 8, }, sectionTitle: { - fontSize: 15, - fontWeight: "800", + ...auraTypography.eyebrow, + letterSpacing: 1.15, }, monthPicker: { - marginTop: 8, + marginTop: 6, marginBottom: 4, alignSelf: "flex-start", flexDirection: "row", @@ -812,27 +1085,42 @@ return StyleSheet.create({ borderRadius: 999, borderWidth: 1, borderColor: colors.border, - backgroundColor: colors.surface, - paddingHorizontal: 12, - paddingVertical: 7, + backgroundColor: colors.surfaceMuted, + paddingHorizontal: 13, + paddingVertical: 8, }, monthPickerText: { - color: colors.text, + color: colors.textSecondary, fontSize: 12, - fontWeight: "700", - }, - weatherStripWrap: { - marginTop: layout.sectionGap - 10, + fontWeight: "500", }, sectionGap: { height: layout.sectionGap - 4, }, card: { - borderWidth: 1, + ...auraCardStyle(colors, "card"), borderColor: colors.glassBorder, borderRadius: layout.mediumRadius, padding: layout.cardPadding, - backgroundColor: colors.surface, + }, + timelineQuiet: { + opacity: 0.72, + }, + insightsQuietCard: { + opacity: 0.82, + backgroundColor: colors.surfaceMuted, + borderColor: colors.border, + }, + emptyDayCard: { + backgroundColor: colors.surfaceMuted, + borderColor: colors.border, + }, + emptyDayTitle: { + color: colors.text, + fontSize: 18, + lineHeight: 24, + fontWeight: "700", + marginBottom: 4, }, muted: { color: colors.textSecondary, @@ -842,14 +1130,10 @@ return StyleSheet.create({ planCta: { marginTop: 10, alignSelf: "flex-start", - borderRadius: 999, - backgroundColor: colors.accent, - paddingHorizontal: 14, - paddingVertical: 10, + ...auraButtonStyle(colors, "primary", false, "compact"), }, planCtaText: { - color: "#fff", - fontWeight: "700", + ...auraButtonTextStyle(colors, "primary"), fontSize: 12, }, weekBars: { @@ -862,7 +1146,7 @@ return StyleSheet.create({ flex: 1, height: 20, borderRadius: 6, - backgroundColor: colors.overlay, + backgroundColor: colors.surfaceMuted, justifyContent: "flex-end", padding: 1, }, @@ -875,48 +1159,137 @@ return StyleSheet.create({ gap: 5, }, modalBackdrop: { - flex: 1, - backgroundColor: "rgba(0,0,0,0.24)", - justifyContent: "flex-end", + ...auraSheetBackdropStyle(colors), }, modalSheet: { - backgroundColor: colors.surface, - borderTopLeftRadius: layout.largeRadius, - borderTopRightRadius: layout.largeRadius, + ...auraCardStyle(colors, "sheet"), + marginHorizontal: layout.horizontalPadding, + marginBottom: layout.floatingDockBottom + FLOATING_TAB_BAR_HEIGHT + 12, + borderRadius: layout.largeRadius, + borderColor: colors.borderStrong, padding: layout.cardPadding, + gap: 13, + overflow: "hidden", + shadowColor: colors.shadow, + shadowOpacity: 0.14, + shadowRadius: 20, + shadowOffset: { width: 0, height: 10 }, + elevation: 5, + }, + modalHeaderRow: { + minHeight: 36, + flexDirection: "row", + alignItems: "center", + justifyContent: "space-between", gap: 12, }, modalTitle: { - fontSize: 16, - fontWeight: "800", + ...auraTypography.cardTitle, color: colors.text, + flex: 1, + }, + modalCloseButton: { + width: 44, + height: 44, + borderRadius: 22, + alignItems: "center", + justifyContent: "center", + borderWidth: 1, + borderColor: colors.border, + backgroundColor: colors.surfaceMuted, + }, + monthNavRow: { + minHeight: 42, + flexDirection: "row", + alignItems: "center", + justifyContent: "space-between", + gap: 12, + }, + monthNavButton: { + width: 44, + height: 44, + borderRadius: 22, + alignItems: "center", + justifyContent: "center", + borderWidth: 1, + borderColor: colors.border, + backgroundColor: colors.surfaceMuted, + }, + monthTitle: { + flex: 1, + color: colors.text, + fontSize: 17, + lineHeight: 22, + fontWeight: "600", + textAlign: "center", + }, + weekdayRow: { + flexDirection: "row", + alignItems: "center", + gap: 4, + }, + weekdayLabel: { + flex: 1, + color: colors.textSecondary, + fontSize: 11, + lineHeight: 14, + fontWeight: "600", + textAlign: "center", + }, + calendarGrid: { + gap: 5, + }, + calendarGridRow: { + minHeight: 38, + flexDirection: "row", + gap: 4, + }, + dateCell: { + flex: 1, + minHeight: 38, + alignItems: "center", + justifyContent: "center", + }, + dateCircle: { + width: 36, + height: 36, + borderRadius: 18, + borderWidth: 1, + alignItems: "center", + justifyContent: "center", + backgroundColor: colors.surfaceMuted, + }, + dateText: { + fontSize: 14, + lineHeight: 18, + fontWeight: "600", + textAlign: "center", + }, + todayMarker: { + position: "absolute", + bottom: 4, + width: 3.5, + height: 3.5, + borderRadius: 1.75, }, modalActions: { flexDirection: "row", gap: 10, + marginTop: 2, }, modalSecondary: { flex: 1, - borderRadius: 12, - borderWidth: 1, - borderColor: colors.border, - paddingVertical: 10, - alignItems: "center", + ...auraButtonStyle(colors, "secondary", false, "compact"), }, modalSecondaryText: { - color: colors.text, - fontWeight: "700", + ...auraButtonTextStyle(colors, "secondary"), }, modalPrimary: { flex: 1, - borderRadius: 12, - backgroundColor: colors.accent, - paddingVertical: 10, - alignItems: "center", + ...auraButtonStyle(colors, "primary", false, "compact"), }, modalPrimaryText: { - color: "#fff", - fontWeight: "800", + ...auraButtonTextStyle(colors, "primary"), }, }); } diff --git a/app/(tabs)/closet.tsx b/app/(tabs)/closet.tsx index a0264f8..eeee9ba 100644 --- a/app/(tabs)/closet.tsx +++ b/app/(tabs)/closet.tsx @@ -1,186 +1,717 @@ import { router } from "expo-router"; +import * as Clipboard from "expo-clipboard"; +import * as Haptics from "expo-haptics"; +import * as ImagePicker from "expo-image-picker"; import React, { useEffect, useMemo, useState } from "react"; import { ActivityIndicator, Alert, + FlatList, + KeyboardAvoidingView, LayoutAnimation, + Linking, + Modal, Platform, Pressable, + RefreshControl, + Share, ScrollView, Text, + TextInput, UIManager, View, } from "react-native"; import { Ionicons } from "@expo/vector-icons"; +import { doc, getDoc, updateDoc, writeBatch } from "firebase/firestore"; -import { ClosetCategorySection } from "@/src/components/closet/ClosetCategorySection"; -import { ClosetControlsRow } from "@/src/components/closet/ClosetControlsRow"; -import { ClosetFilterSheet } from "@/src/components/closet/ClosetFilterSheet"; +import { + ClosetFilterSheet, + type ClosetColorFilterOption, + type ClosetFilterOption, +} from "@/src/components/closet/ClosetFilterSheet"; import { ClosetHeader } from "@/src/components/closet/ClosetHeader"; +import { ClosetInventoryHeader, type ClosetInventoryCategoryTab } from "@/src/components/closet/ClosetInventoryHeader"; +import { ClosetItemCard } from "@/src/components/closet/ClosetItemCard"; +import MinimumClosetProgressCard from "@/src/components/closet/MinimumClosetProgressCard"; +import { ClosetProcessingSection } from "@/src/components/closet/ClosetProcessingSection"; import { ClosetSearchBar } from "@/src/components/closet/ClosetSearchBar"; +import AppImage from "@/src/components/common/AppImage"; +import AuraPressable from "@/src/components/aura/AuraPressable"; +import { AuraAnimatedListItem, AuraAnimatedSection } from "@/src/components/motion"; +import { + auraButtonStyle, + auraButtonTextStyle, + auraCardStyle, + auraChipStyle, + auraChipTextStyle, + auraSheetBackdropStyle, + auraSurfaceTiers, + AuraSheetBackdrop, + AuraSheetSurface, + AuraTopSafeAreaScrim, + auraTypography, +} from "@/src/components/ui/auraStylePrimitives"; +import { AuraSkeleton, AuraSkeletonLine } from "@/src/components/ui/AuraSkeleton"; +import type { ChatImageAttachment } from "@/src/components/ai/chatTypes"; +import { + type CategoryKey, + type ClosetListRow, + type ClosetSection, + buildClosetListRows, + buildSections, + normalizeText, + rankSearchItems, + sortItems, + type SortMode, + toMillis, + validHttpUrl, +} from "@/src/closet/closetListModel"; import { useAuth } from "@/src/hooks/useAuth"; import { useAppTheme } from "@/src/hooks/useAppTheme"; import { useResponsiveLayout } from "@/src/hooks/useResponsiveLayout"; +import { FLOATING_CONTROL_GAP } from "@/src/constants/dock"; +import { db } from "@/src/lib/firebase"; +import { deleteWardrobeItem } from "@/src/lib/deleteItem"; +import { + createAuraItemDraftsFromCandidates, + createAuraItemDraftsFromImages, + uploadAuraAttachments, +} from "@/src/lib/auraAttachments"; import { - type CanonicalCategory, type ClosetItem, - type ClothingStatus, + getItemLifecycleStatus, + isProcessingWardrobeItem, + isVisibleWardrobeItem, listenToItems, + normalizeLaundryStatus, + safeMarkWorn, toCanonicalCategory, } from "@/src/lib/items"; +import { logItemStyleEvent } from "@/src/lib/auraMemory"; +import { + getMinimumClosetProgress, + getSuggestedAddItemCategory, +} from "@/src/lib/minimumCloset"; +import { getStyleProfileConfig } from "@/src/lib/styleProfile"; +import { Toast } from "@/src/lib/toast"; +import { loadUserProfilePreferences } from "@/src/lib/userProfile"; +import { getCachedProfilePreferences } from "@/src/lib/localCache"; +import { + ClosetProductLinkError, + candidateFromProductLinkDraft, + draftFromProductLinkPreview, + previewProductLinkForCloset, + type ClosetProductLinkDraft, + type ClosetProductLinkPreview, +} from "@/src/lib/productLinkClosetImport"; +import { formatUrlForDisplay } from "@/src/lib/formatChatText"; import { sanitizeDisplayText } from "@/src/lib/text"; +import type { ClothingStatus } from "@/src/types/ClothingItem"; +import type { UserProfilePreferences } from "@/src/types/UserProfilePreferences"; -type SortMode = "RECENTLY_ADDED" | "RECENTLY_WORN" | "BRAND" | "MOST_WORN"; -type CategoryKey = CanonicalCategory; - -const CATEGORY_ORDER: CategoryKey[] = ["top", "outerwear", "bottom", "shoes", "accessory"]; -const CATEGORY_LABELS: Record = { - top: "Tops", - outerwear: "Outerwear", - bottom: "Bottoms", - shoes: "Footwear", - accessory: "Accessories", -}; - -const SUBCATEGORY_GROUPS: Record> = { - top: [ - { label: "T-shirts", matches: ["t-shirt", "tshirt", "tee"] }, - { label: "Shirts", matches: ["shirt", "dress shirt"] }, - { label: "Polos", matches: ["polo"] }, - { label: "Sweaters", matches: ["sweater", "knit", "jumper"] }, - { label: "Hoodies", matches: ["hoodie", "sweatshirt"] }, - ], - outerwear: [ - { label: "Jackets", matches: ["jacket"] }, - { label: "Coats", matches: ["coat", "parka", "trench"] }, - { label: "Overshirts", matches: ["overshirt", "shacket"] }, - { label: "Blazers", matches: ["blazer", "sport coat"] }, - ], - bottom: [ - { label: "Jeans", matches: ["jeans", "denim"] }, - { label: "Trousers", matches: ["trousers", "pants", "slacks"] }, - { label: "Joggers", matches: ["joggers", "sweatpants"] }, - { label: "Shorts", matches: ["shorts"] }, - ], - shoes: [ - { label: "Sneakers", matches: ["sneaker", "sneakers", "trainer"] }, - { label: "Loafers", matches: ["loafer", "loafers"] }, - { label: "Boots", matches: ["boot", "boots"] }, - { label: "Sandals", matches: ["sandal", "sandals", "slides"] }, - ], - accessory: [ - { label: "Watches", matches: ["watch", "watches"] }, - { label: "Bags", matches: ["bag", "bags", "backpack", "tote"] }, - { label: "Perfumes", matches: ["perfume", "fragrance", "cologne"] }, - { label: "Jewelry", matches: ["jewelry", "jewellery", "necklace", "ring", "bracelet"] }, - { label: "Belts", matches: ["belt", "belts"] }, - { label: "Sunglasses", matches: ["sunglasses", "glasses"] }, - { label: "Caps", matches: ["cap", "caps", "hat", "beanie"] }, - ], -}; - -const SORT_OPTIONS: Array<{ key: SortMode; label: string }> = [ +const SORT_OPTIONS: { key: SortMode; label: string }[] = [ { key: "RECENTLY_ADDED", label: "Recently added" }, { key: "RECENTLY_WORN", label: "Recently worn" }, { key: "BRAND", label: "Brand" }, { key: "MOST_WORN", label: "Most worn" }, ]; -const STATUS_OPTIONS: Array<{ key: "ALL" | ClothingStatus; label: string }> = [ +const STATUS_OPTIONS: { key: "ALL" | ClothingStatus; label: string }[] = [ { key: "ALL", label: "All" }, - { key: "AVAILABLE", label: "Available" }, - { key: "WORN", label: "Worn" }, - { key: "IN_LAUNDRY", label: "In Laundry" }, + { key: "AVAILABLE", label: "Clean" }, + { key: "WORN", label: "Needs wash" }, + { key: "IN_LAUNDRY", label: "In laundry" }, ]; -function normalizeText(value: unknown) { - return String(value ?? "") - .trim() - .toLowerCase(); -} +const INVENTORY_CATEGORY_TABS: ClosetInventoryCategoryTab[] = [ + { key: "ALL", label: "All" }, + { key: "top", label: "Tops" }, + { key: "bottom", label: "Bottoms" }, + { key: "outerwear", label: "Outerwear" }, + { key: "shoes", label: "Shoes" }, + { key: "accessory", label: "Accessories" }, + { key: "one_piece", label: "Dresses" }, +]; + +const PRODUCT_LINK_CATEGORY_OPTIONS = [ + { key: "top", label: "Tops" }, + { key: "bottom", label: "Bottoms" }, + { key: "outerwear", label: "Outerwear" }, + { key: "shoes", label: "Footwear" }, + { key: "accessory", label: "Accessories" }, + { key: "one_piece", label: "Dresses" }, +] as const; -function toMillis(value: unknown): number { - if (!value) return 0; - if (typeof value === "number") return Number.isFinite(value) ? value : 0; - if (value instanceof Date) return value.getTime(); - if (typeof (value as { toDate?: () => Date }).toDate === "function") { - const date = (value as { toDate: () => Date }).toDate(); - return date?.getTime?.() ?? 0; +const MULTICOLOR_FILTER_KEY = "__multicolor__"; +const STYLE_FILTER_OPTIONS: ClosetFilterOption[] = [ + { key: "relaxed", label: "Relaxed" }, + { key: "slim", label: "Slim" }, + { key: "oversized", label: "Oversized" }, + { key: "formal", label: "Formal" }, + { key: "casual", label: "Casual" }, +]; +const WEAR_FILTER_OPTIONS: ClosetFilterOption[] = [ + { key: "never_worn", label: "Never worn" }, + { key: "recently_worn", label: "Recently worn" }, + { key: "unworn_for_a_while", label: "Unworn for a while" }, +]; +const RECENTLY_WORN_MS = 14 * 24 * 60 * 60 * 1000; +const UNWORN_FOR_A_WHILE_MS = 45 * 24 * 60 * 60 * 1000; +const PROCESSING_STALE_TIMEOUT_MS = 15 * 60 * 1000; +const SEARCH_PREVIEW_LIMIT = 8; +const CLOSET_FAB_SIZE = 66; +const CLOSET_FAB_DOCK_GAP = 22; +const DEBUG_CLOSET_CLIENT = __DEV__ && process.env.EXPO_PUBLIC_AURA_DEBUG === "1"; +function debugClosetLog(...args: Parameters) { + if (DEBUG_CLOSET_CLIENT) { + console.log(...args); } - return 0; } -function itemSubcategory(item: ClosetItem) { - return sanitizeDisplayText(item.subCategory) || sanitizeDisplayText(item.type) || ""; +const closetRowKeyExtractor = (row: ClosetListRow) => row.key; + +function normalizeFilterToken(value: unknown) { + return sanitizeDisplayText(String(value ?? "")) + .toLowerCase() + .replace(/[_-]+/g, " ") + .replace(/\s+/g, " ") + .trim(); +} + +function titleCaseFilterLabel(value: string) { + return value + .split(" ") + .filter(Boolean) + .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) + .join(" "); +} + +function cleanProductLinkText(value: unknown) { + return sanitizeDisplayText(String(value ?? "")); } -function subcategoryBucket(item: ClosetItem, category: CategoryKey) { - const haystack = `${itemSubcategory(item)} ${sanitizeDisplayText(item.name)}`.toLowerCase(); - const group = SUBCATEGORY_GROUPS[category].find((option) => - option.matches.some((term) => haystack.includes(term)) +function productLinkCategoryLabel(value?: string | null) { + const normalized = cleanProductLinkText(value).toLowerCase().replace(/[_-]+/g, " "); + const option = PRODUCT_LINK_CATEGORY_OPTIONS.find( + (candidate) => candidate.key === value || candidate.key.replace("_", " ") === normalized ); - return group?.label ?? "Other"; + return option?.label ?? (normalized ? titleCaseFilterLabel(normalized) : "Category"); } -function searchMatches(item: ClosetItem, query: string) { - if (!query) return true; - const haystack = [ - item.name, - item.brand, - item.category, - item.subCategory, - item.type, +function productLinkPreviewImageUrl(preview: ClosetProductLinkPreview | null) { + if (!preview) return ""; + return ( + cleanProductLinkText(preview.candidate.primaryImageUrl) || + cleanProductLinkText(preview.candidate.imageUrls?.[0]) || + cleanProductLinkText(preview.candidate.secondaryImageUrls?.[0]) + ); +} + +function productLinkPriceLabel(preview: ClosetProductLinkPreview | null) { + if (!preview) return ""; + return ( + cleanProductLinkText(preview.metadata.priceDisplay) || + cleanProductLinkText(preview.metadata.price) || + cleanProductLinkText(preview.candidate.priceDisplay) + ); +} + +function productLinkSourceLabel(preview: ClosetProductLinkPreview | null) { + if (!preview) return ""; + return cleanProductLinkText(preview.metadata.domain) || cleanProductLinkText(preview.metadata.retailer); +} + +type ProductLinkRecoverableError = { + code: string; + title: string; + message: string; +}; + +function arrayFromUnknown(value: unknown) { + return Array.isArray(value) ? value : []; +} + +function itemColorValues(item: ClosetItem) { + const extended = item as ClosetItem & Record; + return [ item.primaryColor, + item.colorLabel, + item.displayColor, ...(item.colors ?? []), - ] - .map((value) => normalizeText(value)) - .join(" "); - return haystack.includes(query); + ...(item.displayColors ?? []), + ...arrayFromUnknown(extended.aiColors), + ].filter(Boolean); } -function sortItems(items: ClosetItem[], sortMode: SortMode) { - const next = [...items]; - if (sortMode === "RECENTLY_WORN") { - next.sort((a, b) => toMillis(b.lastWornDate) - toMillis(a.lastWornDate)); - return next; +function itemColorKeys(item: ClosetItem) { + return Array.from(new Set(itemColorValues(item).map(normalizeFilterToken).filter(Boolean))); +} + +function itemHasMultipleColors(item: ClosetItem) { + const explicitColors = [ + ...(item.colors ?? []), + ...(item.displayColors ?? []), + ].map(normalizeFilterToken).filter(Boolean); + if (new Set(explicitColors).size > 1) return true; + return itemColorValues(item).some((value) => normalizeFilterToken(value).includes("multi")); +} + +function itemStyleTokens(item: ClosetItem) { + const extended = item as ClosetItem & Record; + return new Set( + [ + item.fit, + item.style, + item.formality, + ...(item.aestheticTags ?? []), + ...(item.occasionTags ?? []), + ...(item.detailTags ?? []), + ...arrayFromUnknown(extended.styleTags), + ].map(normalizeFilterToken).filter(Boolean), + ); +} + +function itemMatchesStyleFilter(item: ClosetItem, filter: string) { + const tokens = itemStyleTokens(item); + if (tokens.has(filter)) return true; + return Array.from(tokens).some((token) => token.includes(filter)); +} + +function itemLastWornMillis(item: ClosetItem) { + return toMillis(item.lastWornAt) || toMillis(item.lastWornDate); +} + +function itemMatchesWearFilter(item: ClosetItem, filter: string) { + const lastWorn = itemLastWornMillis(item); + const now = Date.now(); + if (filter === "never_worn") return !lastWorn; + if (filter === "recently_worn") return !!lastWorn && now - lastWorn <= RECENTLY_WORN_MS; + if (filter === "unworn_for_a_while") return !!lastWorn && now - lastWorn >= UNWORN_FOR_A_WHILE_MS; + return false; +} + +function toggleFilterValue(values: string[], value: string) { + return values.includes(value) + ? values.filter((current) => current !== value) + : [...values, value]; +} + +const ClosetListSeparator = React.memo(function ClosetListSeparator() { + return ; +}); + +function buildGridRows(items: ClosetItem[], keyPrefix: string) { + const rows: ClosetListRow[] = []; + for (let index = 0; index < items.length; index += 2) { + rows.push({ + type: "items", + key: `${keyPrefix}:row:${index}`, + items: items.slice(index, index + 2), + animateOffset: index, + }); } - if (sortMode === "BRAND") { - next.sort((a, b) => normalizeText(a.brand).localeCompare(normalizeText(b.brand))); - return next; + return rows; +} + +function appendAddTileToRows(rows: ClosetListRow[], animateOffset: number) { + let lastItemsIndex = -1; + for (let index = rows.length - 1; index >= 0; index -= 1) { + if (rows[index]?.type === "items") { + lastItemsIndex = index; + break; + } } - if (sortMode === "MOST_WORN") { - next.sort((a, b) => Number(b.wearCountSinceWash ?? 0) - Number(a.wearCountSinceWash ?? 0)); + if (lastItemsIndex < 0) { + return rows; + } + const next = [...rows]; + const lastItemsRow = next[lastItemsIndex]; + if (lastItemsRow.type !== "items") return next; + if (lastItemsRow.items.length < 2) { + next[lastItemsIndex] = { ...lastItemsRow, trailingAddTile: true }; return next; } - next.sort((a, b) => toMillis(b.createdAt) - toMillis(a.createdAt)); + next.push({ + type: "items", + key: "closet:add-item-row", + items: [], + animateOffset, + trailingAddTile: true, + }); return next; } -function buildSections(items: ClosetItem[]) { - return CATEGORY_ORDER.map((category) => { - const inCategory = items.filter((item) => toCanonicalCategory(item.category) === category); - const buckets = new Map(); - inCategory.forEach((item) => { - const key = subcategoryBucket(item, category); - const current = buckets.get(key) ?? []; - current.push(item); - buckets.set(key, current); - }); - const subcategories = Array.from(buckets.entries()) - .map(([label, groupedItems]) => ({ label, items: groupedItems })) - .sort((a, b) => { - if (a.label === "Other") return 1; - if (b.label === "Other") return -1; - return a.label.localeCompare(b.label); - }); - return { - key: category, - title: CATEGORY_LABELS[category], - itemCount: inCategory.length, - subcategories, - }; - }).filter((section) => section.itemCount > 0); -} +const ClosetSearchResultsHeader = React.memo(function ClosetSearchResultsHeader({ + query, + totalCount, + shownCount, + onViewAll, +}: { + query: string; + totalCount: number; + shownCount: number; + onViewAll: () => void; +}) { + const { colors } = useAppTheme(); + const hasMore = totalCount > shownCount; + + return ( + + + Showing {shownCount} of {totalCount} result{totalCount === 1 ? "" : "s"} for {query.trim()}. + + {hasMore ? ( + + + View all related items + + + ) : null} + + ); +}); + +const ClosetSearchEmptyState = React.memo(function ClosetSearchEmptyState() { + const { colors } = useAppTheme(); + const layout = useResponsiveLayout(); + + return ( + + + No exact match + + + Try a broader search. + + + ); +}); + +const ClosetLoadingSkeleton = React.memo(function ClosetLoadingSkeleton() { + const layout = useResponsiveLayout(); + + return ( + + + + + + + + + + + + ); +}); + +const ClosetEmptyState = React.memo(function ClosetEmptyState({ + filtered, + onAddItem, + onClearFilters, +}: { + filtered: boolean; + onAddItem: () => void; + onClearFilters: () => void; +}) { + const { colors } = useAppTheme(); + const layout = useResponsiveLayout(); + return ( + + + + + + + {filtered ? "No pieces match" : "Start your closet"} + + + {filtered + ? "Clear the filters to return to your full wardrobe." + : "Add a few clean photos so AURA can build from what you own."} + + + + + + {filtered ? "Clear filters" : "Add item"} + + + + + ); +}); + +const ClosetSectionHeader = React.memo(function ClosetSectionHeader({ + section, + expanded, + onToggle, +}: { + section: ClosetSection; + expanded: boolean; + onToggle: (sectionKey: string) => void; +}) { + const { colors } = useAppTheme(); + return ( + + + onToggle(section.key)} + haptic="selection" + hapticTrigger="press" + pressedScale={0.99} + pressedOpacity={0.9} + style={{ + flexDirection: "row", + alignItems: "center", + justifyContent: "space-between", + gap: 12, + paddingVertical: 2, + }} + > + + + {section.title} + + + + {section.itemCount} + + + + + + + + + ); +}); + +const ClosetSubcategoryHeader = React.memo(function ClosetSubcategoryHeader({ + label, + count, +}: { + label: string; + count: number; +}) { + const { colors } = useAppTheme(); + const layout = useResponsiveLayout(); + return ( + + + + {label} + + + {count} + + + + + ); +}); + +type ClosetGridRowProps = { + items: ClosetItem[]; + cardWidth: number; + gridGap: number; + animateOffset: number; + trailingAddTile?: boolean; + selectedItemIds: Set; + onAddItem: () => void; + onPressItem: (item: ClosetItem) => void; + onLongPressItem: (item: ClosetItem) => void; +}; + +const ClosetAddItemTile = React.memo(function ClosetAddItemTile({ + width, + onPress, +}: { + width: number; + onPress: () => void; +}) { + const { colors } = useAppTheme(); + const boardSize = width; + const textBlockHeight = 60; + + return ( + + + + + + + + + Add item + + + Quick capture + + + + ); +}); + +const ClosetGridRow = React.memo( + function ClosetGridRow({ + items, + cardWidth, + gridGap, + animateOffset, + trailingAddTile = false, + selectedItemIds, + onAddItem, + onPressItem, + onLongPressItem, + }: ClosetGridRowProps) { + return ( + + {items.map((item, index) => ( + + ))} + {trailingAddTile ? ( + + ) : null} + + ); + }, + (prev, next) => + prev.items === next.items && + prev.cardWidth === next.cardWidth && + prev.gridGap === next.gridGap && + prev.animateOffset === next.animateOffset && + prev.trailingAddTile === next.trailingAddTile && + prev.onAddItem === next.onAddItem && + prev.onPressItem === next.onPressItem && + prev.onLongPressItem === next.onLongPressItem && + prev.items.every((item) => prev.selectedItemIds.has(item.id) === next.selectedItemIds.has(item.id)), +); export default function ClosetScreen() { const { user } = useAuth(); @@ -188,16 +719,42 @@ export default function ClosetScreen() { const layout = useResponsiveLayout(); const uid = user?.uid ?? null; const [items, setItems] = useState([]); + const [profilePreferences, setProfilePreferences] = useState(null); const [loading, setLoading] = useState(true); const [search, setSearch] = useState(""); + const [showAllSearchResults, setShowAllSearchResults] = useState(false); const [sortMode, setSortMode] = useState("RECENTLY_ADDED"); const [statusFilter, setStatusFilter] = useState<"ALL" | ClothingStatus>("ALL"); const [categoryFilter, setCategoryFilter] = useState<"ALL" | CategoryKey>("ALL"); const [brandFilter, setBrandFilter] = useState("ALL"); - const [colorFilter, setColorFilter] = useState("ALL"); + const [colorFilters, setColorFilters] = useState([]); + const [styleFilters, setStyleFilters] = useState([]); + const [wearFilters, setWearFilters] = useState([]); const [filtersOpen, setFiltersOpen] = useState(false); + const [styleInsightsExpanded, setStyleInsightsExpanded] = useState(false); + const [selectedItemIds, setSelectedItemIds] = useState>(() => new Set()); + const [quickAddOpen, setQuickAddOpen] = useState(false); + const [linkModalOpen, setLinkModalOpen] = useState(false); + const [productLink, setProductLink] = useState(""); + const [productLinkTouched, setProductLinkTouched] = useState(false); + const [productLinkLoading, setProductLinkLoading] = useState(false); + const [productLinkSaving, setProductLinkSaving] = useState(false); + const [productLinkError, setProductLinkError] = useState(""); + const [productLinkRecoverableError, setProductLinkRecoverableError] = + useState(null); + const [productLinkPreview, setProductLinkPreview] = useState(null); + const [productLinkDraft, setProductLinkDraft] = useState(null); + const [productLinkEditing, setProductLinkEditing] = useState(false); + const [quickAdding, setQuickAdding] = useState(false); + const [bulkActionLoading, setBulkActionLoading] = useState(false); + const [refreshing, setRefreshing] = useState(false); + const [moreActionsOpen, setMoreActionsOpen] = useState(false); + const [locallyRemovedItemIds, setLocallyRemovedItemIds] = useState>(() => new Set()); + const staleFailoverIdsRef = React.useRef(new Set()); + const closetCacheRefreshingRef = React.useRef(false); const [expandedSections, setExpandedSections] = useState>({ top: true, + one_piece: true, outerwear: true, bottom: true, shoes: true, @@ -212,212 +769,2046 @@ export default function ClosetScreen() { useEffect(() => { if (!uid) { + setItems([]); setLoading(false); - router.replace("/(auth)/login"); + router.replace("/(auth)/welcome"); return; } + setItems([]); setLoading(true); - const unsub = listenToItems(uid, (next) => { - setItems(next); - setLoading(false); - }, { - onError: (message) => { + const unsub = listenToItems( + uid, + (next, meta) => { + if (meta?.source === "cache") { + closetCacheRefreshingRef.current = !!meta.stale; + } else { + closetCacheRefreshingRef.current = false; + } + setItems(next); setLoading(false); - Alert.alert("Closet", message || "Unable to load wardrobe."); }, - }); + { + includeDrafts: true, + onError: (message) => { + setLoading(false); + Alert.alert("Closet", message || "Unable to load wardrobe."); + }, + } + ); return () => unsub(); }, [uid]); + useEffect(() => { + let cancelled = false; + if (!uid) { + setProfilePreferences(null); + return () => { + cancelled = true; + }; + } + setProfilePreferences(null); + void getCachedProfilePreferences(uid).then((cached) => { + if (!cancelled && cached?.data) setProfilePreferences(cached.data); + }); + void loadUserProfilePreferences(uid) + .then((profile) => { + if (!cancelled) setProfilePreferences(profile); + }) + .catch(() => { + if (!cancelled) setProfilePreferences(null); + }); + return () => { + cancelled = true; + }; + }, [uid]); + + const handleRefresh = React.useCallback(async () => { + if (!uid || refreshing) return; + setRefreshing(true); + const startedAt = Date.now(); + try { + const profile = await loadUserProfilePreferences(uid); + setProfilePreferences(profile); + } catch { + setProfilePreferences(null); + } finally { + const remaining = Math.max(0, 450 - (Date.now() - startedAt)); + setTimeout(() => setRefreshing(false), remaining); + } + }, [refreshing, uid]); + + const styleProfile = useMemo( + () => getStyleProfileConfig(profilePreferences), + [profilePreferences] + ); + const categoryOrder = useMemo( + () => styleProfile.categoryOrder as CategoryKey[], + [styleProfile.categoryOrder] + ); + + const visibleItems = useMemo( + () => items.filter((item) => isVisibleWardrobeItem(item)), + [items] + ); + const minimumClosetProgress = useMemo( + () => getMinimumClosetProgress(visibleItems), + [visibleItems] + ); + const normalizedSearch = useMemo(() => normalizeText(search), [search]); + const isSearchMode = normalizedSearch.length > 0; + const showMinimumClosetCard = !loading && !minimumClosetProgress.isUnlocked && !isSearchMode; + + useEffect(() => { + setShowAllSearchResults(false); + }, [normalizedSearch]); + + const openAddMissingItem = React.useCallback(() => { + const suggestedCategory = getSuggestedAddItemCategory(visibleItems); + router.push({ + pathname: "/(tabs)/add", + params: { + ...(suggestedCategory ? { suggestedCategory } : {}), + addSession: String(Date.now()), + sourceRoute: "/(tabs)/closet", + sourceTab: "closet", + }, + }); + }, [visibleItems]); + const processingItems = useMemo( + () => + items + .filter((item) => !locallyRemovedItemIds.has(item.id) && isProcessingWardrobeItem(item)) + .sort((a, b) => toMillis(b.createdAt) - toMillis(a.createdAt)), + [items, locallyRemovedItemIds] + ); + const normalizedProductLink = useMemo(() => validHttpUrl(productLink), [productLink]); + const productLinkInlineError = useMemo(() => { + const trimmed = productLink.trim(); + if (!trimmed || !productLinkTouched) return ""; + return normalizedProductLink ? "" : "Enter a valid http or https product link."; + }, [normalizedProductLink, productLink, productLinkTouched]); + const productLinkChipLabel = useMemo( + () => (normalizedProductLink ? formatUrlForDisplay(normalizedProductLink) : ""), + [normalizedProductLink], + ); + const productLinkBusy = productLinkLoading || productLinkSaving; + const productLinkImageUrl = useMemo( + () => productLinkPreviewImageUrl(productLinkPreview), + [productLinkPreview], + ); + const productLinkPrice = useMemo( + () => productLinkPriceLabel(productLinkPreview), + [productLinkPreview], + ); + const productLinkSource = useMemo( + () => productLinkSourceLabel(productLinkPreview), + [productLinkPreview], + ); + const productLinkMaterial = useMemo( + () => + cleanProductLinkText(productLinkPreview?.candidate.material) || + cleanProductLinkText(productLinkPreview?.metadata.material), + [productLinkPreview], + ); + const productLinkFit = useMemo( + () => cleanProductLinkText(productLinkPreview?.candidate.fit), + [productLinkPreview], + ); + const productLinkCanSave = + !!uid && + !!productLinkPreview && + !!productLinkDraft && + !!cleanProductLinkText(productLinkDraft.name) && + !!productLinkImageUrl; + + useEffect(() => { + if (!uid) return; + const now = Date.now(); + processingItems.forEach((item) => { + const lifecycle = getItemLifecycleStatus(item); + if (lifecycle !== "processing") return; + const lastRunAt = + toMillis((item as any).ingestion?.lastRunAt) || + toMillis((item as any).updatedAt) || + toMillis(item.createdAt); + if (!lastRunAt || now - lastRunAt < PROCESSING_STALE_TIMEOUT_MS) return; + if (staleFailoverIdsRef.current.has(item.id)) return; + staleFailoverIdsRef.current.add(item.id); + debugClosetLog("[ITEM_PROCESSING_STALE]", { + uid, + itemId: item.id, + lastRunAt, + ageMs: now - lastRunAt, + }); + void updateDoc(doc(db, "users", uid, "items", item.id), { + draftState: "failed", + itemLifecycleStatus: "failed", + ingestionStatus: "failed", + "ingestion.status": "failed", + "ingestion.error": { + message: "Processing timed out. Please retry or edit this item.", + code: "client_stale_processing_timeout", + }, + updatedAt: Date.now(), + }) + .then(() => { + debugClosetLog("[ITEM_PROCESSING_FAILOVER]", { + uid, + itemId: item.id, + to: "failed", + }); + }) + .catch((error) => { + staleFailoverIdsRef.current.delete(item.id); + debugClosetLog("[ITEM_PROCESSING_FAILOVER]", { + uid, + itemId: item.id, + error, + }); + }); + }); + }, [processingItems, uid]); + const brandOptions = useMemo(() => { const values = Array.from( new Set( - items.map((item) => sanitizeDisplayText(item.brand)).filter(Boolean) + visibleItems.map((item) => sanitizeDisplayText(item.brand)).filter(Boolean) ) ).sort((a, b) => a.localeCompare(b)); return ["ALL", ...values]; - }, [items]); + }, [visibleItems]); - const colorOptions = useMemo(() => { - const values = Array.from( - new Set( - items - .flatMap((item) => [item.primaryColor, ...(item.colors ?? [])]) - .map((value) => sanitizeDisplayText(value)) - .filter(Boolean) - ) - ).sort((a, b) => a.localeCompare(b)); - return ["ALL", ...values]; - }, [items]); + const colorOptions = useMemo(() => { + const options = new Map(); + visibleItems.forEach((item) => { + itemColorValues(item).forEach((value) => { + const key = normalizeFilterToken(value); + if (!key || key === "multicolor" || key === "multi color") return; + if (!options.has(key)) { + options.set(key, titleCaseFilterLabel(key)); + } + }); + }); + return Array.from(options.entries()) + .map(([key, label]) => ({ key, label })) + .sort((a, b) => a.label.localeCompare(b.label)); + }, [visibleItems]); + + const styleFilterOptions = useMemo( + () => + STYLE_FILTER_OPTIONS.filter((option) => + visibleItems.some((item) => itemMatchesStyleFilter(item, option.key)) + ), + [visibleItems], + ); - const filteredItems = useMemo(() => { - const query = normalizeText(search); - return sortItems( - items.filter((item) => { - if (statusFilter !== "ALL" && item.status !== statusFilter) return false; + const wearFilterOptions = useMemo( + () => + WEAR_FILTER_OPTIONS.filter((option) => + visibleItems.some((item) => itemMatchesWearFilter(item, option.key)) + ), + [visibleItems], + ); + + useEffect(() => { + const availableColors = new Set([MULTICOLOR_FILTER_KEY, ...colorOptions.map((option) => option.key)]); + setColorFilters((prev) => prev.filter((value) => availableColors.has(value))); + }, [colorOptions]); + + useEffect(() => { + const availableStyles = new Set(styleFilterOptions.map((option) => option.key)); + setStyleFilters((prev) => prev.filter((value) => availableStyles.has(value))); + }, [styleFilterOptions]); + + useEffect(() => { + const availableWear = new Set(wearFilterOptions.map((option) => option.key)); + setWearFilters((prev) => prev.filter((value) => availableWear.has(value))); + }, [wearFilterOptions]); + + const toggleColorFilter = React.useCallback((value: string) => { + setColorFilters((prev) => toggleFilterValue(prev, value)); + }, []); + + const toggleStyleFilter = React.useCallback((value: string) => { + setStyleFilters((prev) => toggleFilterValue(prev, value)); + }, []); + + const toggleWearFilter = React.useCallback((value: string) => { + setWearFilters((prev) => toggleFilterValue(prev, value)); + }, []); + + const filteredByControls = useMemo( + () => + visibleItems.filter((item) => { + if (statusFilter !== "ALL") { + const laundryStatus = normalizeLaundryStatus(item); + if (statusFilter === "AVAILABLE" && laundryStatus !== "clean") return false; + if (statusFilter === "WORN" && laundryStatus !== "needs_wash") return false; + if (statusFilter === "IN_LAUNDRY" && laundryStatus !== "in_laundry") return false; + } if (categoryFilter !== "ALL" && toCanonicalCategory(item.category) !== categoryFilter) return false; if (brandFilter !== "ALL" && sanitizeDisplayText(item.brand) !== brandFilter) return false; - if ( - colorFilter !== "ALL" && - ![sanitizeDisplayText(item.primaryColor), ...(item.colors ?? []).map((value) => sanitizeDisplayText(value))].includes(colorFilter) - ) { + if (colorFilters.length) { + const colorsForItem = itemColorKeys(item); + const matchesColor = colorFilters.some((filter) => + filter === MULTICOLOR_FILTER_KEY + ? itemHasMultipleColors(item) + : colorsForItem.includes(filter) + ); + if (!matchesColor) return false; + } + if (styleFilters.length && !styleFilters.some((filter) => itemMatchesStyleFilter(item, filter))) { + return false; + } + if (wearFilters.length && !wearFilters.some((filter) => itemMatchesWearFilter(item, filter))) { return false; } - return searchMatches(item, query); + return true; }), - sortMode + [brandFilter, categoryFilter, colorFilters, statusFilter, styleFilters, visibleItems, wearFilters], + ); + const sortedFilteredItems = useMemo( + () => sortItems(filteredByControls, sortMode), + [filteredByControls, sortMode], + ); + const searchResults = useMemo( + () => (isSearchMode ? rankSearchItems(sortedFilteredItems, normalizedSearch) : []), + [isSearchMode, normalizedSearch, sortedFilteredItems], + ); + const displayedSearchItems = useMemo( + () => (showAllSearchResults ? searchResults : searchResults.slice(0, SEARCH_PREVIEW_LIMIT)), + [searchResults, showAllSearchResults], + ); + + const sections = useMemo( + () => buildSections(sortedFilteredItems, categoryOrder, styleProfile.emphasizedSubcategories), + [categoryOrder, sortedFilteredItems, styleProfile.emphasizedSubcategories] + ); + const searchRows = useMemo( + () => (loading || displayedSearchItems.length === 0 ? [] : buildGridRows(displayedSearchItems, "search")), + [displayedSearchItems, loading], + ); + const closetRows = useMemo( + () => { + if (isSearchMode) return searchRows; + if (loading || sections.length === 0) return []; + if (categoryFilter === "ALL") { + return appendAddTileToRows(buildGridRows(sortedFilteredItems, "all"), sortedFilteredItems.length); + } + return appendAddTileToRows(buildClosetListRows(sections, expandedSections), sortedFilteredItems.length); + }, + [categoryFilter, expandedSections, isSearchMode, loading, searchRows, sections, sortedFilteredItems], + ); + const activeListItems = isSearchMode ? displayedSearchItems : sortedFilteredItems; + const isSelectionMode = selectedItemIds.size > 0; + const selectedItems = useMemo( + () => visibleItems.filter((item) => selectedItemIds.has(item.id)), + [selectedItemIds, visibleItems] + ); + const visibleItemIds = useMemo(() => new Set(visibleItems.map((item) => item.id)), [visibleItems]); + const selectedVisibleCount = useMemo( + () => Array.from(selectedItemIds).filter((itemId) => visibleItemIds.has(itemId)).length, + [selectedItemIds, visibleItemIds] + ); + const allFilteredItemsSelected = + activeListItems.length > 0 && activeListItems.every((item) => selectedItemIds.has(item.id)); + const hasActiveOrganizeState = + sortMode !== "RECENTLY_ADDED" || + statusFilter !== "ALL" || + categoryFilter !== "ALL" || + brandFilter !== "ALL" || + colorFilters.length > 0 || + styleFilters.length > 0 || + wearFilters.length > 0; + const closetGridGap = 16; + const closetGridCardWidth = + (layout.width - layout.horizontalPadding * 2 - closetGridGap) / 2; + const closetFabRight = Math.max(18, layout.horizontalPadding); + const closetFabBottom = + layout.composerOffset + Math.max(0, CLOSET_FAB_DOCK_GAP - FLOATING_CONTROL_GAP); + + useEffect(() => { + setSelectedItemIds((prev) => { + const next = new Set(Array.from(prev).filter((itemId) => visibleItemIds.has(itemId))); + if (next.size === prev.size) return prev; + return next; + }); + }, [visibleItemIds]); + + const clearSelection = React.useCallback(() => { + setSelectedItemIds(new Set()); + setMoreActionsOpen(false); + }, []); + + const toggleItemSelection = React.useCallback((itemId: string) => { + setSelectedItemIds((prev) => { + const next = new Set(prev); + if (next.has(itemId)) { + next.delete(itemId); + } else { + next.add(itemId); + } + return next; + }); + }, []); + + const handleItemPress = React.useCallback( + (item: ClosetItem) => { + if (selectedItemIds.size > 0) { + toggleItemSelection(item.id); + return; + } + router.push({ + pathname: "/(tabs)/item/[id]", + params: { id: item.id, sourceTab: "closet", sourceRoute: "/(tabs)/closet" }, + }); + }, + [selectedItemIds.size, toggleItemSelection] + ); + + const handleItemLongPress = React.useCallback( + (item: ClosetItem) => { + LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut); + setSelectedItemIds((prev) => { + if (prev.has(item.id)) return prev; + const next = new Set(prev); + next.add(item.id); + return next; + }); + }, + [] + ); + + const openQuickAdd = React.useCallback(() => { + setQuickAddOpen(true); + }, []); + + const openManualAddFromCloset = React.useCallback(() => { + setQuickAddOpen(false); + router.push({ + pathname: "/(tabs)/add", + params: { addSession: String(Date.now()), sourceRoute: "/(tabs)/closet", sourceTab: "closet" }, + }); + }, []); + + const openFilters = React.useCallback(() => { + setFiltersOpen(true); + }, []); + + const handleChangeSort = React.useCallback((value: string) => { + setSortMode(value as SortMode); + }, []); + + const handleToggleSection = React.useCallback((sectionKey: string) => { + LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut); + setExpandedSections((prev) => ({ + ...prev, + [sectionKey]: !(prev[sectionKey] !== false), + })); + }, []); + + const handleSelectAllVisible = React.useCallback(() => { + LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut); + if (allFilteredItemsSelected) { + clearSelection(); + return; + } + setSelectedItemIds(new Set(activeListItems.map((item) => item.id))); + }, [activeListItems, allFilteredItemsSelected, clearSelection]); + + const runBulkAction = React.useCallback( + async (label: string, action: () => Promise) => { + if (!uid || bulkActionLoading || selectedItems.length === 0) return; + setBulkActionLoading(true); + try { + await action(); + } catch (error: any) { + Alert.alert(label, error?.message ?? `Unable to ${label.toLowerCase()}.`); + } finally { + setBulkActionLoading(false); + } + }, + [bulkActionLoading, selectedItems.length, uid] + ); + + const applyBulkStatus = React.useCallback( + async (status: ClothingStatus) => { + if (!uid || selectedItems.length === 0) return; + const batch = writeBatch(db); + const now = Date.now(); + selectedItems.forEach((item) => { + const ref = doc(db, "users", uid, "items", item.id); + if (status === "AVAILABLE") { + batch.update(ref, { + status, + laundryStatus: "clean", + wearCountSinceWash: 0, + lastWashedDate: now, + lastWashedAt: now, + laundryUpdatedAt: now, + updatedAt: now, + }); + return; + } + batch.update(ref, { + status, + laundryStatus: status === "IN_LAUNDRY" ? "in_laundry" : "needs_wash", + laundryUpdatedAt: now, + updatedAt: now, + }); + }); + await batch.commit(); + clearSelection(); + }, + [clearSelection, selectedItems, uid] + ); + + const handleBulkDelete = React.useCallback(() => { + if (!uid || selectedItems.length === 0 || bulkActionLoading) return; + const count = selectedItems.length; + Alert.alert( + count === 1 ? "Delete selected item?" : `Delete ${count} selected items?`, + "This permanently removes the selected closet items.", + [ + { text: "Cancel", style: "cancel" }, + { + text: "Delete", + style: "destructive", + onPress: () => { + void runBulkAction("Delete", async () => { + await Promise.all( + selectedItems.map((item) => deleteWardrobeItem(uid, item.id, item)) + ); + clearSelection(); + }); + }, + }, + ] + ); + }, [bulkActionLoading, clearSelection, runBulkAction, selectedItems, uid]); + + const handleBulkFavorite = React.useCallback(() => { + void runBulkAction("Favorite", async () => { + if (!uid || selectedItems.length === 0) return; + const batch = writeBatch(db); + const now = Date.now(); + selectedItems.forEach((item) => { + batch.update(doc(db, "users", uid, "items", item.id), { + isFavorite: true, + updatedAt: now, + }); + }); + await batch.commit(); + await Promise.all( + selectedItems.map((item) => + logItemStyleEvent(uid, "item_favorited", { + ...item, + isFavorite: true, + }) + ) + ); + clearSelection(); + }); + }, [clearSelection, runBulkAction, selectedItems, uid]); + + const handleBulkStyleSelected = React.useCallback(() => { + if (selectedItems.length === 0) return; + const names = selectedItems + .slice(0, 8) + .map((item) => sanitizeDisplayText(item.name) || sanitizeDisplayText(item.subCategory) || item.id) + .filter(Boolean); + const itemIds = selectedItems.map((item) => item.id); + clearSelection(); + router.push({ + pathname: "/(tabs)/ai", + params: { + prompt: + `Style a look using only these selected closet items from my wardrobe.\n` + + `Selected item ids: ${itemIds.join(", ")}.\n` + + `Selected pieces: ${names.join(", ")}.\n` + + `Build from these first, keep it wearable, and tell me how to style them together.`, + promptKey: `closet-selected-${Date.now()}`, + }, + }); + }, [clearSelection, selectedItems]); + + const handleBulkShare = React.useCallback(() => { + void runBulkAction("Share", async () => { + const title = selectedItems.length === 1 ? "AURA item" : "AURA selection"; + const message = + selectedItems.length === 1 + ? `${sanitizeDisplayText(selectedItems[0]?.name) || "Wardrobe item"}` + : `AURA selection (${selectedItems.length} items): ${selectedItems + .map((item) => sanitizeDisplayText(item.name) || sanitizeDisplayText(item.subCategory) || item.id) + .join(", ")}`; + await Share.share({ title, message }); + }); + }, [runBulkAction, selectedItems]); + + const handleBulkMarkWorn = React.useCallback(() => { + void runBulkAction("Mark worn", async () => { + if (!uid || selectedItems.length === 0) return; + const failures: string[] = []; + for (const item of selectedItems) { + try { + await safeMarkWorn(uid, item.id); + } catch (error: any) { + failures.push( + sanitizeDisplayText(item.name) || + error?.message || + item.id + ); + } + } + clearSelection(); + await Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light).catch(() => undefined); + if (failures.length) { + Alert.alert( + "Some items were skipped", + failures.length === selectedItems.length + ? "None of the selected items could be marked worn." + : `${failures.length} selected item(s) could not be marked worn.` + ); + } + }); + }, [clearSelection, runBulkAction, selectedItems, uid]); + + function localAttachmentId() { + return `quick-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + } + + const createDraftsFromAssets = React.useCallback( + async (assets: ImagePicker.ImagePickerAsset[], source: "camera" | "library") => { + if (!uid) return; + const images: ChatImageAttachment[] = assets + .filter((asset) => !!asset.uri) + .map((asset) => ({ + id: localAttachmentId(), + type: "image", + uri: asset.uri, + localUri: asset.uri, + role: assets.length > 1 ? "separate_items" : "same_item", + groupId: `quick-${Date.now()}`, + width: asset.width ?? null, + height: asset.height ?? null, + })); + if (!images.length) return; + setQuickAdding(true); + try { + await Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); + const uploaded = await uploadAuraAttachments(uid, images); + const uploadedImages = uploaded.filter( + (attachment): attachment is ChatImageAttachment => attachment.type === "image" + ); + await createAuraItemDraftsFromImages({ + uid, + images: uploadedImages, + mode: uploadedImages.length > 1 ? "separate_items" : "same_item", + prompt: source === "camera" ? "Quick add camera photo" : "Quick add photo library", + }); + await Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success); + setQuickAddOpen(false); + Toast.itemAdded(); + } catch (error: any) { + await Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error); + Toast.error("Quick add failed", error?.message ?? "Unable to add that item."); + } finally { + setQuickAdding(false); + } + }, + [uid] + ); + + const handleChoosePhotos = React.useCallback(async () => { + const permission = await ImagePicker.requestMediaLibraryPermissionsAsync(); + if (!permission.granted) { + Alert.alert("Photos", "Please allow photo access to add items."); + return; + } + const result = await ImagePicker.launchImageLibraryAsync({ + mediaTypes: ["images"], + allowsMultipleSelection: true, + allowsEditing: false, + quality: 0.9, + exif: false, + }); + if (!result.canceled) { + await createDraftsFromAssets(result.assets ?? [], "library"); + } + }, [createDraftsFromAssets]); + + const handleTakePhoto = React.useCallback(async () => { + const permission = await ImagePicker.requestCameraPermissionsAsync(); + if (!permission.granted) { + Alert.alert("Camera", "Please allow camera access to add an item."); + return; + } + const result = await ImagePicker.launchCameraAsync({ + mediaTypes: ["images"], + allowsEditing: false, + quality: 0.9, + exif: false, + }); + if (!result.canceled) { + await createDraftsFromAssets(result.assets ?? [], "camera"); + } + }, [createDraftsFromAssets]); + + const resetProductLinkSheet = React.useCallback(() => { + setProductLink(""); + setProductLinkTouched(false); + setProductLinkError(""); + setProductLinkRecoverableError(null); + setProductLinkPreview(null); + setProductLinkDraft(null); + setProductLinkEditing(false); + }, []); + + const closeProductLinkSheet = React.useCallback(() => { + if (productLinkBusy) return; + setLinkModalOpen(false); + resetProductLinkSheet(); + }, [productLinkBusy, resetProductLinkSheet]); + + const startProductLinkReview = React.useCallback(async (rawUrl: string, source: "clipboard" | "manual") => { + const url = validHttpUrl(rawUrl); + debugClosetLog("[PASTE_LINK_UI]", "search submitted", { + uid, + source, + hasUrl: !!url, + rawLength: rawUrl.length, + }); + if (!url || !uid) { + debugClosetLog("[PASTE_LINK_UI]", "invalid url", { + uid, + source, + rawUrl, + }); + setProductLinkTouched(true); + return; + } + setProductLinkLoading(true); + setProductLinkError(""); + setProductLinkRecoverableError(null); + setProductLinkPreview(null); + setProductLinkDraft(null); + setProductLinkEditing(false); + try { + await Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light).catch(() => undefined); + const preview = await previewProductLinkForCloset(url); + setProductLinkPreview(preview); + setProductLinkDraft(draftFromProductLinkPreview(preview)); + setProductLinkTouched(false); + setQuickAddOpen(false); + await Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success).catch(() => undefined); + } catch (error: any) { + await Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error).catch(() => undefined); + if (error instanceof ClosetProductLinkError && error.blockedStore) { + setProductLinkRecoverableError({ + code: "blocked_store", + title: "This store blocked automatic reading.", + message: "You can try again, paste another link, or add the item from a screenshot.", + }); + } else { + setProductLinkError( + error?.message ?? "I could not read that product link. Try another product page or add it manually." + ); + } + } finally { + setProductLinkLoading(false); + } + }, [uid]); + + const handlePasteProductLinkAction = React.useCallback(() => { + debugClosetLog("[PASTE_LINK_UI]", "opened", { uid }); + resetProductLinkSheet(); + setQuickAddOpen(false); + setLinkModalOpen(true); + }, [resetProductLinkSheet, uid]); + + const handlePasteFromClipboard = React.useCallback(async () => { + try { + const clipboard = await Clipboard.getStringAsync(); + debugClosetLog("[PASTE_LINK_UI]", "pasted from clipboard", { + uid, + hasClipboardText: !!clipboard?.trim(), + hasValidUrl: !!validHttpUrl(clipboard), + }); + setProductLink(clipboard ?? ""); + setProductLinkTouched(true); + setProductLinkError(""); + setProductLinkRecoverableError(null); + setProductLinkPreview(null); + setProductLinkDraft(null); + setProductLinkEditing(false); + } catch (error) { + debugClosetLog("[PASTE_LINK_UI]", "pasted from clipboard", { + uid, + hasClipboardText: false, + hasValidUrl: false, + errorMessage: error instanceof Error ? error.message : String(error), + }); + } + }, [uid]); + + const handleAddProductLinkFromScreenshot = React.useCallback(async () => { + if (productLinkBusy) return; + setLinkModalOpen(false); + await handleChoosePhotos(); + }, [handleChoosePhotos, productLinkBusy]); + + const handleOpenProductLinkManually = React.useCallback(async () => { + const url = normalizedProductLink ?? validHttpUrl(productLink); + if (!url) return; + try { + await Linking.openURL(url); + } catch { + Toast.error("Could not open link", "Open the product page in your browser and add from screenshot/photo."); + } + }, [normalizedProductLink, productLink]); + + const handleImportProductLink = React.useCallback(async () => { + if (productLinkBusy) return; + if (!normalizedProductLink) { + debugClosetLog("[PASTE_LINK_UI]", "invalid url", { + uid, + rawUrl: productLink, + }); + setProductLinkTouched(true); + return; + } + await startProductLinkReview(productLink, "manual"); + }, [normalizedProductLink, productLink, productLinkBusy, startProductLinkReview, uid]); + + const handleSaveProductLinkToCloset = React.useCallback(async () => { + if (!uid || !productLinkPreview || !productLinkDraft || productLinkSaving) return; + setProductLinkSaving(true); + setProductLinkError(""); + setProductLinkRecoverableError(null); + try { + const candidate = candidateFromProductLinkDraft({ + preview: productLinkPreview, + draft: productLinkDraft, + }); + await createAuraItemDraftsFromCandidates({ + uid, + candidates: [candidate], + prompt: "Closet product link import", + mode: "pending", + }); + await Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success).catch(() => undefined); + setLinkModalOpen(false); + resetProductLinkSheet(); + Toast.itemAdded(); + } catch (error: any) { + await Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error).catch(() => undefined); + setProductLinkError( + error?.message ?? "That item could not be added yet. You can edit the details or try another link." + ); + } finally { + setProductLinkSaving(false); + } + }, [productLinkDraft, productLinkPreview, productLinkSaving, resetProductLinkSheet, uid]); + + const handleRetryProcessingItem = React.useCallback( + async (item: ClosetItem) => { + if (!uid) return; + await updateDoc(doc(db, "users", uid, "items", item.id), { + ingestionStatus: "pending", + itemLifecycleStatus: "processing", + "ingestion.status": "pending", + "ingestion.lastRunAt": Date.now(), + updatedAt: Date.now(), + }); + }, + [uid] + ); + + const handleReviewProcessingItem = React.useCallback( + (item: ClosetItem) => { + if (!item?.id) { + debugClosetLog("[CLOSET_REVIEW_ERROR]", "missing item id", { item }); + Alert.alert("Review unavailable", "This item is no longer available."); + return; + } + debugClosetLog("[CLOSET_REVIEW]", "opening draft review", { + uid, + itemId: item.id, + draftState: (item as any).draftState ?? null, + itemLifecycleStatus: (item as any).itemLifecycleStatus ?? null, + ingestionStatus: (item as any).ingestionStatus ?? (item as any).ingestion?.status ?? null, + }); + router.push({ + pathname: "/(tabs)/add", + params: { editId: item.id, sourceItemId: item.id, sourceRoute: "/(tabs)/closet", sourceTab: "closet" }, + }); + }, + [uid] + ); + + const handleRemoveProcessingItem = React.useCallback( + async (item: ClosetItem) => { + if (!uid) return; + const itemRef = doc(db, "users", uid, "items", item.id); + setLocallyRemovedItemIds((prev) => { + const next = new Set(prev); + next.add(item.id); + return next; + }); + debugClosetLog("[ITEM_REMOVE]", "removing unfinished item", { + uid, + itemId: item.id, + path: `users/${uid}/items/${item.id}`, + mode: "hard_delete", + draftState: (item as any).draftState ?? null, + itemLifecycleStatus: (item as any).itemLifecycleStatus ?? null, + }); + try { + await updateDoc(itemRef, { + draftState: "cancelled", + itemLifecycleStatus: "deleted", + removedAt: Date.now(), + updatedAt: Date.now(), + }); + await deleteWardrobeItem(uid, item.id, item); + const afterDelete = await getDoc(itemRef); + debugClosetLog("[ITEM_REMOVE_SUCCESS]", { + uid, + itemId: item.id, + existsAfterDelete: afterDelete.exists(), + }); + } catch (error) { + setLocallyRemovedItemIds((prev) => { + const next = new Set(prev); + next.delete(item.id); + return next; + }); + debugClosetLog("[ITEM_REMOVE_ERROR]", { + uid, + itemId: item.id, + error, + }); + Alert.alert("Remove failed", "That item could not be removed. Please try again."); + } + }, + [uid] + ); + + const renderClosetRow = React.useCallback( + ({ item, index }: { item: ClosetListRow; index: number }) => { + if (item.type === "section") { + return ( + + + + ); + } + if (item.type === "subcategory") { + return ( + + + + ); + } + return ( + + ); + }, + [ + closetGridCardWidth, + closetGridGap, + handleItemLongPress, + handleItemPress, + handleToggleSection, + openQuickAdd, + selectedItemIds, + ], + ); + + const closetListHeader = useMemo(() => { + if (isSearchMode) { + if (!searchResults.length) return null; + return ( + setShowAllSearchResults(true)} + /> + ); + } + + return null; + }, [ + displayedSearchItems.length, + isSearchMode, + search, + searchResults.length, + ]); + + const closetListFooter = useMemo(() => { + if (isSearchMode) return null; + if (!processingItems.length && !showMinimumClosetCard) return null; + return ( + + void handleRetryProcessingItem(item)} + onRemove={(item) => void handleRemoveProcessingItem(item)} + /> + + {showMinimumClosetCard ? ( + setStyleInsightsExpanded((expanded) => !expanded)} + onAddMissingItem={openAddMissingItem} + /> + ) : null} + ); - }, [brandFilter, categoryFilter, colorFilter, items, search, sortMode, statusFilter]); + }, [ + colors, + handleRemoveProcessingItem, + handleRetryProcessingItem, + handleReviewProcessingItem, + isSearchMode, + openAddMissingItem, + processingItems, + showMinimumClosetCard, + styleInsightsExpanded, + visibleItems, + ]); + + const clearFilters = React.useCallback(() => { + setSearch(""); + setStatusFilter("ALL"); + setCategoryFilter("ALL"); + setBrandFilter("ALL"); + setColorFilters([]); + setStyleFilters([]); + setWearFilters([]); + }, []); + + const closetListEmpty = useMemo(() => { + if (loading) { + return ; + } - const sections = useMemo(() => buildSections(filteredItems), [filteredItems]); - const visibleCount = filteredItems.length; + if (isSearchMode) { + return ; + } + + return ( + 0} + onAddItem={openQuickAdd} + onClearFilters={clearFilters} + /> + ); + }, [clearFilters, isSearchMode, loading, openQuickAdd, visibleItems.length]); return ( - + - - - - option.key === sortMode)?.label ?? "Recently added"} - statusLabel={STATUS_OPTIONS.find((option) => option.key === statusFilter)?.label ?? "All"} - onOpenFilters={() => setFiltersOpen(true)} - /> - - - {loading ? ( - - - - ) : sections.length === 0 ? ( + {isSelectionMode ? ( - - {items.length === 0 ? "Your closet is still empty" : "No pieces match these filters"} - - - {items.length === 0 - ? "Add a few pieces and Closet becomes your clean, category-first browser." - : "Try a broader search or clear one of the active filters."} - + + + + {selectedVisibleCount} selected + + + Tap more pieces to add them, or tap again to deselect. + + + ({ + minHeight: 44, + paddingHorizontal: 12, + borderRadius: 999, + alignItems: "center", + justifyContent: "center", + backgroundColor: colors.surfaceInteractive, + opacity: bulkActionLoading ? 0.5 : pressed ? 0.78 : 1, + })} + > + + {allFilteredItemsSelected ? "Clear" : "Select all"} + + + ({ + minHeight: 44, + paddingHorizontal: 12, + borderRadius: 999, + alignItems: "center", + justifyContent: "center", + backgroundColor: colors.overlay, + opacity: bulkActionLoading ? 0.5 : pressed ? 0.78 : 1, + })} + > + + Cancel + + + + + + + void runBulkAction("Move to laundry", async () => applyBulkStatus("IN_LAUNDRY"))} + /> + + setMoreActionsOpen(true)} + /> + - ) : ( - sections.map((section) => ( - - { - LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut); - setExpandedSections((prev) => ({ - ...prev, - [section.key]: !(prev[section.key] !== false), - })); - } - } - onPressItem={(item) => - router.push({ - pathname: "/(tabs)/item/[id]", - params: { id: item.id }, - }) - } - /> - )) - )} - + ) : null} + + + + + + + + + + } + removeClippedSubviews={Platform.OS !== "web"} + initialNumToRender={10} + maxToRenderPerBatch={8} + updateCellsBatchingPeriod={40} + windowSize={7} + extraData={selectedItemIds} + contentContainerStyle={{ + paddingHorizontal: layout.horizontalPadding, + paddingTop: 16, + paddingBottom: layout.bottomDockPadding + 56, + }} + /> + + + + setFiltersOpen(false)} - sortMode={sortMode} statusFilter={statusFilter} categoryFilter={categoryFilter} brandFilter={brandFilter} - colorFilter={colorFilter} + colorFilters={colorFilters} + sortMode={sortMode} sortOptions={SORT_OPTIONS} statusOptions={STATUS_OPTIONS} - categoryOptions={[{ key: "ALL", label: "All categories" }, ...CATEGORY_ORDER.map((key) => ({ key, label: CATEGORY_LABELS[key] }))]} + categoryOptions={[ + { key: "ALL", label: "All categories" }, + { key: "top", label: "Tops" }, + { key: "bottom", label: "Bottoms" }, + { key: "outerwear", label: "Outerwear" }, + { key: "shoes", label: "Footwear" }, + { key: "accessory", label: "Accessories" }, + { key: "one_piece", label: "One-pieces" }, + ]} brandOptions={brandOptions} colorOptions={colorOptions} - onChangeSort={setSortMode} - onChangeStatus={setStatusFilter} - onChangeCategory={setCategoryFilter} + multicolorFilterKey={MULTICOLOR_FILTER_KEY} + styleOptions={styleFilterOptions} + selectedStyleFilters={styleFilters} + wearOptions={wearFilterOptions} + selectedWearFilters={wearFilters} + onChangeStatus={(value) => setStatusFilter(value as "ALL" | ClothingStatus)} + onChangeCategory={(value) => setCategoryFilter(value as "ALL" | CategoryKey)} onChangeBrand={setBrandFilter} - onChangeColor={setColorFilter} - onClear={() => { - setSortMode("RECENTLY_ADDED"); - setStatusFilter("ALL"); - setCategoryFilter("ALL"); - setBrandFilter("ALL"); - setColorFilter("ALL"); - }} + onChangeSort={handleChangeSort} + onToggleColor={toggleColorFilter} + onClearColors={() => setColorFilters([])} + onToggleStyle={toggleStyleFilter} + onClearStyle={() => setStyleFilters([])} + onToggleWear={toggleWearFilter} + onClearWear={() => setWearFilters([])} + onClear={clearFilters} /> - router.push("/(tabs)/add")} - style={({ pressed }) => ({ - position: "absolute", - right: layout.horizontalPadding, - bottom: layout.composerOffset - 2, - width: 58, - height: 58, - borderRadius: 999, - alignItems: "center", - justifyContent: "center", - backgroundColor: "rgba(20,24,32,0.84)", - borderWidth: 1, - borderColor: "rgba(255,255,255,0.1)", - shadowColor: "#000", - shadowOpacity: 0.28, - shadowRadius: 18, - shadowOffset: { width: 0, height: 10 }, - elevation: 18, - opacity: pressed ? 0.88 : 1, - })} + setMoreActionsOpen(false)} > - setMoreActionsOpen(false)} style={{ - position: "absolute", - inset: 0, - borderRadius: 999, - backgroundColor: "rgba(255,255,255,0.04)", + ...auraSheetBackdropStyle(colors), + justifyContent: "flex-end", + paddingHorizontal: layout.horizontalPadding, + paddingBottom: layout.composerOffset + 18, }} - /> - - + > + event.stopPropagation()} + style={{ + ...auraCardStyle(colors, "sheet"), + padding: 16, + gap: 10, + }} + > + + More actions + + + {selectedItems.length} selected item{selectedItems.length === 1 ? "" : "s"} + + + + void runBulkAction("Mark available", async () => applyBulkStatus("AVAILABLE"))} + /> + + + + + + setQuickAddOpen(false)} + > + + setQuickAddOpen(false)} + /> + + + + + Add item + + + Fast capture for your wardrobe. + + + { + setQuickAddOpen(false); + void handleTakePhoto(); + }} + /> + { + setQuickAddOpen(false); + void handleChoosePhotos(); + }} + /> + { + setQuickAddOpen(false); + handlePasteProductLinkAction(); + }} + /> + + + + + + + + + event.stopPropagation()} + style={{ + maxHeight: layout.height * 0.84, + ...auraCardStyle(colors, "sheet"), + padding: 14, + overflow: "hidden", + }} + > + + + + + Paste product link + + + Paste a product URL and review the item before adding it to your closet. + + + ({ + width: 44, + height: 44, + borderRadius: 999, + alignItems: "center", + justifyContent: "center", + backgroundColor: colors.surfaceMuted, + borderWidth: 1, + borderColor: colors.border, + opacity: productLinkBusy ? 0.45 : pressed ? 0.74 : 1, + })} + > + + + + + + { + setProductLink(value); + setProductLinkTouched(true); + setProductLinkError(""); + setProductLinkRecoverableError(null); + setProductLinkPreview(null); + setProductLinkDraft(null); + setProductLinkEditing(false); + }} + autoCapitalize="none" + autoCorrect={false} + keyboardAppearance="dark" + keyboardType="url" + placeholder="https://..." + placeholderTextColor={colors.textMuted} + editable={!productLinkBusy} + onSubmitEditing={() => void handleImportProductLink()} + style={{ + minHeight: 50, + borderRadius: 18, + paddingHorizontal: 14, + color: colors.text, + backgroundColor: colors.inputBackground, + borderWidth: 1, + borderColor: colors.border, + fontSize: 14, + fontWeight: "600", + }} + /> + {productLinkInlineError ? ( + + {productLinkInlineError} + + ) : null} + {productLinkError ? ( + + {productLinkError} + + ) : null} + {productLinkChipLabel ? ( + + + + {productLinkChipLabel} + + + ) : null} + + + + void handlePasteFromClipboard()} + disabled={productLinkBusy} + style={({ pressed }) => ({ + flex: 0.8, + ...auraButtonStyle(colors, "secondary", productLinkBusy, "compact"), + flexDirection: "row", + gap: 8, + minHeight: 44, + opacity: productLinkBusy ? 0.55 : pressed ? 0.82 : 1, + })} + > + + + Paste + + + void handleImportProductLink()} + disabled={productLinkBusy || !normalizedProductLink} + style={({ pressed }) => ({ + flex: 1.2, + ...auraButtonStyle(colors, "primary", productLinkBusy || !normalizedProductLink, "compact"), + flexDirection: "row", + gap: 8, + minHeight: 44, + opacity: productLinkBusy || !normalizedProductLink ? 0.55 : pressed ? 0.86 : 1, + })} + > + {productLinkLoading ? : null} + + {productLinkLoading ? "Searching..." : productLinkPreview ? "Search again" : "Search"} + + + + + {productLinkRecoverableError ? ( + + + + {productLinkRecoverableError.title} + + + {productLinkRecoverableError.message} + + + + void handleImportProductLink()} + disabled={productLinkBusy || !normalizedProductLink} + style={({ pressed }) => ({ + minHeight: 42, + borderRadius: 999, + paddingHorizontal: 14, + alignItems: "center", + justifyContent: "center", + backgroundColor: colors.accent, + borderWidth: 1, + borderColor: colors.borderStrong, + opacity: productLinkBusy || !normalizedProductLink ? 0.5 : pressed ? 0.78 : 1, + })} + > + Try again + + void handleAddProductLinkFromScreenshot()} + disabled={productLinkBusy} + style={({ pressed }) => ({ + minHeight: 42, + borderRadius: 999, + paddingHorizontal: 13, + alignItems: "center", + justifyContent: "center", + backgroundColor: colors.surfaceElevated, + borderWidth: 1, + borderColor: colors.border, + opacity: productLinkBusy ? 0.5 : pressed ? 0.78 : 1, + })} + > + Add from screenshot + + void handleOpenProductLinkManually()} + disabled={!normalizedProductLink} + style={({ pressed }) => ({ + minHeight: 42, + borderRadius: 999, + paddingHorizontal: 13, + alignItems: "center", + justifyContent: "center", + backgroundColor: "transparent", + borderWidth: 1, + borderColor: "transparent", + opacity: !normalizedProductLink ? 0.5 : pressed ? 0.78 : 1, + })} + > + Open link + + ({ + minHeight: 42, + borderRadius: 999, + paddingHorizontal: 13, + alignItems: "center", + justifyContent: "center", + backgroundColor: "transparent", + borderWidth: 1, + borderColor: "transparent", + opacity: productLinkBusy ? 0.5 : pressed ? 0.78 : 1, + })} + > + Cancel + + + + ) : null} + + {productLinkLoading ? ( + + + + + Reading product details... + + + ) : null} + + {productLinkPreview && productLinkDraft ? ( + + + + {productLinkImageUrl ? ( + + ) : ( + + )} + + + + {productLinkDraft.name || "Untitled item"} + + + + + + {productLinkMaterial ? : null} + {productLinkFit ? : null} + {productLinkPrice ? : null} + {productLinkSource ? : null} + + + + + {productLinkEditing ? ( + + + setProductLinkDraft((current) => (current ? { ...current, name: value } : current)) + } + /> + + + setProductLinkDraft((current) => (current ? { ...current, brand: value } : current)) + } + style={{ flex: 1 }} + /> + + setProductLinkDraft((current) => (current ? { ...current, color: value } : current)) + } + style={{ flex: 1 }} + /> + + + setProductLinkDraft((current) => (current ? { ...current, size: value } : current)) + } + /> + + + Category + + + + {PRODUCT_LINK_CATEGORY_OPTIONS.map((option) => { + const selected = productLinkDraft.category === option.key; + return ( + + setProductLinkDraft((current) => + current ? { ...current, category: option.key } : current + ) + } + style={({ pressed }) => ({ + ...auraChipStyle(colors, selected ? "selected" : "filter"), + paddingHorizontal: 12, + opacity: pressed ? 0.76 : 1, + })} + > + + {option.label} + + + ); + })} + + + + + ) : null} + + + void handleSaveProductLinkToCloset()} + disabled={!productLinkCanSave || productLinkSaving} + style={({ pressed }) => ({ + ...auraButtonStyle(colors, "primary", !productLinkCanSave || productLinkSaving), + flexDirection: "row", + gap: 8, + minHeight: 56, + opacity: !productLinkCanSave || productLinkSaving ? 0.55 : pressed ? 0.86 : 1, + })} + > + {productLinkSaving ? : null} + + {productLinkSaving ? "Adding..." : "Add to Wardrobe"} + + + + setProductLinkEditing((editing) => !editing)} + disabled={productLinkSaving} + style={({ pressed }) => ({ + flex: 1, + ...auraButtonStyle(colors, "secondary", productLinkSaving, "compact"), + minHeight: 44, + opacity: productLinkSaving ? 0.55 : pressed ? 0.82 : 1, + })} + > + + {productLinkEditing ? "Done" : "Edit details"} + + + ({ + flex: 1, + ...auraButtonStyle(colors, "secondary", productLinkSaving, "compact"), + minHeight: 44, + opacity: productLinkSaving ? 0.55 : pressed ? 0.82 : 1, + })} + > + + Cancel + + + + + + ) : null} + + + + + + + ); +} + +function ProductLinkPreviewLine({ + label, + value, +}: { + label: string; + value: string; +}) { + const { colors } = useAppTheme(); + return ( + + + {label} + + + {value} + + + ); +} + +function ProductLinkReviewField({ + label, + value, + onChangeText, + placeholder, + style, +}: { + label: string; + value: string; + onChangeText: (value: string) => void; + placeholder?: string; + style?: React.ComponentProps["style"]; +}) { + const { colors } = useAppTheme(); + return ( + + + {label} + + ); } + +function QuickAddAction({ + icon, + label, + disabled, + onPress, +}: { + icon: keyof typeof Ionicons.glyphMap; + label: string; + disabled?: boolean; + onPress: () => void; +}) { + const { colors } = useAppTheme(); + return ( + ({ + ...auraButtonStyle(colors, "secondary", disabled), + flexDirection: "row", + gap: 12, + minHeight: 52, + paddingHorizontal: 15, + backgroundColor: colors.surfaceMuted, + borderColor: colors.border, + justifyContent: "flex-start", + opacity: disabled ? 0.5 : pressed ? 0.76 : 1, + })} + > + + + {label} + + + ); +} + +function BulkActionPill({ + icon, + label, + onPress, + disabled, + tone = "default", +}: { + icon: keyof typeof Ionicons.glyphMap; + label: string; + onPress: () => void; + disabled?: boolean; + tone?: "default" | "destructive"; +}) { + const { colors } = useAppTheme(); + + return ( + ({ + minWidth: 86, + ...auraButtonStyle(colors, tone === "destructive" ? "danger" : "tertiary", disabled), + minHeight: 54, + paddingHorizontal: 14, + alignItems: "center", + justifyContent: "center", + gap: 4, + opacity: disabled ? 0.45 : pressed ? 0.78 : 1, + })} + > + + + {label} + + + ); +} diff --git a/app/(tabs)/index.tsx b/app/(tabs)/index.tsx index b9eac39..6391ea6 100644 --- a/app/(tabs)/index.tsx +++ b/app/(tabs)/index.tsx @@ -1,23 +1,32 @@ +import { Ionicons } from "@expo/vector-icons"; +import { LinearGradient } from "expo-linear-gradient"; import { router, useFocusEffect } from "expo-router"; import React, { useEffect, useMemo, useRef, useState } from "react"; import { - ActivityIndicator, Alert, - Animated, - Pressable, + InteractionManager, + RefreshControl, ScrollView, + StyleSheet, Text, View, } from "react-native"; -import ContinueSection from "@/src/components/home/ContinueSection"; import ContinueChatCard from "@/src/components/home/ContinueChatCard"; +import AuraPressable from "@/src/components/aura/AuraPressable"; import AuraLookModule from "@/src/components/home/AuraLookModule"; import HomeHero from "@/src/components/home/HomeHero"; -import InsightCard from "@/src/components/home/InsightCard"; +import { AuraAnimatedSection } from "@/src/components/motion"; import QuickActionRail, { type QuickActionItem } from "@/src/components/home/QuickActionRail"; +import ShopOptionsSheet from "@/src/components/shop/ShopOptionsSheet"; import SmartToolsGrid, { type SmartTool } from "@/src/components/home/SmartToolsGrid"; -import TodayOutfitCard from "@/src/components/home/TodayOutfitCard"; +import { AuraSkeleton, AuraSkeletonLine } from "@/src/components/ui/AuraSkeleton"; +import { AuraTopSafeAreaScrim, auraButtonStyle, auraButtonTextStyle, auraSurfaceTiers } from "@/src/components/ui/auraStylePrimitives"; +import { HOME_DEFERRED_FEATURES } from "@/src/components/home/homeDeferredFeatures"; +import { homeTypography } from "@/src/components/home/homeTypography"; +import type { AppColors } from "@/constants/theme"; +import { CTA_HEIGHT, CTA_HORIZONTAL_PADDING, PILL_RADIUS } from "@/src/constants/auraControls"; +import { AURA_TRAINING_ROUTE } from "@/src/constants/routes"; import { useAuth } from "@/src/hooks/useAuth"; import { useAppTheme } from "@/src/hooks/useAppTheme"; import { useLocalWeather } from "@/src/hooks/useLocalWeather"; @@ -25,21 +34,46 @@ import { useNow } from "@/src/hooks/useNow"; import { useResponsiveLayout } from "@/src/hooks/useResponsiveLayout"; import { buildVisiblePreferenceHint, loadAssistantProfile } from "@/src/lib/assistantMemory"; import { loadChatMessages, loadLatestChatThread, type AIChatThread } from "@/src/lib/aiChats"; -import { auraLookToPlannedOutfit, loadLatestSavedAuraLook, saveAuraLook } from "@/src/lib/auraLooks"; -import { listenToItems } from "@/src/lib/items"; +import { askAuraStream, isAuraStreamAbortError } from "@/src/lib/aura"; +import { analyticsErrorProperties, trackLaunchEvent } from "@/src/lib/analytics"; +import { handleSharedAuraLookAction } from "@/src/lib/auraActions"; +import { loadLatestSavedAuraLook } from "@/src/lib/auraLooks"; +import { generateAuraSwipeBatch } from "@/src/lib/auraSwipe"; +import { listenToItems, normalizeLaundryStatus } from "@/src/lib/items"; +import { + buildMinimumClosetSummary, + getMinimumClosetProgress, +} from "@/src/lib/minimumCloset"; +import { + trackSuggestionEvent, +} from "@/src/lib/suggestionAnalytics"; +import { getStyleProfileConfig } from "@/src/lib/styleProfile"; +import { Toast } from "@/src/lib/toast"; +import { loadUserProfilePreferences } from "@/src/lib/userProfile"; +import { markOutfitWorn } from "@/src/lib/wearOutfit"; +import { + buildAdHocWardrobeSuggestion, + buildWardrobeSuggestions, + type WardrobeSuggestion, +} from "@/src/lib/wardrobeSuggestions"; +import { + getCachedChatList, + getCachedHomeSnapshot, + getCachedProfilePreferences, + setCachedHomeSnapshot, +} from "@/src/lib/localCache"; import type { ClothingItem } from "@/src/types/ClothingItem"; -import type { AuraLookAction, AuraResponse } from "@/src/types/aura"; -import { savePlannedRecord, subscribeOutfitByDate, type DailyOutfitRecord } from "@/src/utils/dailyOutfits"; - -function toMillis(value: unknown): number { - if (typeof value === "number" && Number.isFinite(value)) return value; - if (value instanceof Date) return value.getTime(); - if (value && typeof value === "object" && typeof (value as { toDate?: () => Date }).toDate === "function") { - const date = (value as { toDate: () => Date }).toDate(); - return Number.isFinite(date?.getTime?.()) ? date.getTime() : 0; - } - return 0; -} +import type { AuraLook, AuraLookAction, AuraResponse } from "@/src/types/aura"; +import type { UserProfilePreferences } from "@/src/types/UserProfilePreferences"; +import { subscribeOutfitByDate, type DailyOutfitRecord } from "@/src/utils/dailyOutfits"; + +const HOME_BACKGROUND_BASE = "#080709"; +const HOME_BACKGROUND_GRADIENT = ["#080709", "#080709", "#111014"] as const; +const HOME_SECTION_GAP = 24; +const HOME_SECTION_CONTENT_GAP = 16; +const HOME_CARD_GAP = 12; +const HOME_TIGHT_GAP = 4; +const HOME_BOTTOM_BREATHING_ROOM = 24; function RevealSection({ delay, @@ -48,30 +82,179 @@ function RevealSection({ delay: number; children: React.ReactNode; }) { - const opacity = useRef(new Animated.Value(0)).current; - const translateY = useRef(new Animated.Value(18)).current; + return ( + + {children} + + ); +} - useEffect(() => { - Animated.parallel([ - Animated.timing(opacity, { - toValue: 1, - duration: 320, - delay, - useNativeDriver: true, - }), - Animated.timing(translateY, { - toValue: 0, - duration: 360, - delay, - useNativeDriver: true, - }), - ]).start(); - }, [delay, opacity, translateY]); +function CompleteWardrobeCard({ + colors, + suggestions, + onPress, +}: { + colors: AppColors; + suggestions: WardrobeSuggestion[]; + onPress: () => void; +}) { + const layout = useResponsiveLayout(); + const topSuggestion = suggestions[0]; return ( - - {children} - + + + + + + + COMPLETE YOUR WARDROBE + + {topSuggestion ? `Start with ${topSuggestion.itemType}` : "A few precise pieces would unlock more range"} + + + AURA found the quiet gaps that would create more outfit combinations without clutter. + + + + + + {suggestions.slice(0, 2).map((suggestion) => ( + + + + {suggestion.itemType} + + + +{suggestion.outfitsUnlockedEstimate} + + + ))} + + + + + + View suggestions + + + + ); +} + +function HomeLoadingSkeleton() { + const { colors } = useAppTheme(); + const layout = useResponsiveLayout(); + + return ( + + + + + + + + + + + + + + + + + + + ); } @@ -90,13 +273,23 @@ export default function HomeScreen() { const [latestChatThread, setLatestChatThread] = useState(null); const [latestAuraLookResponse, setLatestAuraLookResponse] = useState(null); const [latestSavedLook, setLatestSavedLook] = useState(null); + const [profilePreferences, setProfilePreferences] = useState(null); + const [activeShopSuggestion, setActiveShopSuggestion] = useState(null); + const [renderDeferredHomeSections, setRenderDeferredHomeSections] = useState(false); + const [refreshing, setRefreshing] = useState(false); + const [regeneratingLook, setRegeneratingLook] = useState(false); + const homeCacheRefreshingRef = useRef(false); useEffect(() => { if (!uid) { - router.replace("/(auth)/login"); + setItems([]); + setLoading(false); + router.replace("/(auth)/welcome"); return; } + setItems([]); + setLoading(true); const unsub = listenToItems( uid, (next) => { @@ -116,6 +309,34 @@ export default function HomeScreen() { return () => unsub(); }, [uid]); + useEffect(() => { + let cancelled = false; + if (!uid) { + setLatestChatThread(null); + setLatestSavedLook(null); + setTodayRecord(null); + return () => { + cancelled = true; + }; + } + + setLatestChatThread(null); + setLatestSavedLook(null); + setTodayRecord(null); + + void getCachedHomeSnapshot(uid).then((cached) => { + if (cancelled || !cached?.data) return; + homeCacheRefreshingRef.current = cached.stale; + setLatestChatThread(cached.data.latestChatPreview); + setLatestSavedLook(cached.data.latestSavedLookPreview); + setTodayRecord(cached.data.todayOutfitPreview); + }); + + return () => { + cancelled = true; + }; + }, [uid]); + useEffect(() => { if (!uid) return; const unsub = subscribeOutfitByDate( @@ -133,52 +354,89 @@ export default function HomeScreen() { } }, [weather.actions, weather.permission, weather.state]); - useFocusEffect( - React.useCallback(() => { - let active = true; - void (async () => { - if (!uid) { - setAssistantHint(null); - setLatestChatThread(null); - return; + useEffect(() => { + if (loading) return; + setRenderDeferredHomeSections(false); + const task = InteractionManager.runAfterInteractions(() => { + setRenderDeferredHomeSections(true); + }); + const fallback = setTimeout(() => setRenderDeferredHomeSections(true), 900); + return () => { + task.cancel?.(); + clearTimeout(fallback); + }; + }, [loading]); + + const loadHomeCompanionData = React.useCallback( + async (isActive: () => boolean) => { + if (!uid) { + setAssistantHint(null); + setLatestChatThread(null); + setLatestAuraLookResponse(null); + return; + } + try { + const cachedProfilePreferences = await getCachedProfilePreferences(uid); + if (isActive() && cachedProfilePreferences?.data) { + setProfilePreferences(cachedProfilePreferences.data); } - try { - const [profile, latestChat, savedLook] = await Promise.all([ - loadAssistantProfile(uid), - loadLatestChatThread(uid), - loadLatestSavedAuraLook(uid), - ]); - if (!active) return; - setAssistantHint(buildVisiblePreferenceHint(profile)); - setLatestChatThread(latestChat); - setLatestSavedLook(savedLook); - if (latestChat?.chatId) { - const chatMessages = await loadChatMessages(uid, latestChat.chatId); - if (!active) return; - const latestAura = [...chatMessages] - .reverse() - .find((message) => message.type === "assistant" && message.aura?.look)?.aura ?? null; - setLatestAuraLookResponse(latestAura); - } else { - setLatestAuraLookResponse(null); - } - } catch { - if (!active) return; - setAssistantHint(null); - setLatestChatThread(null); + const cachedChats = await getCachedChatList(uid); + if (isActive() && cachedChats?.data?.[0]) { + setLatestChatThread(cachedChats.data[0]); + } + const [assistantProfile, latestChat, savedLook, userProfilePreferences] = await Promise.all([ + loadAssistantProfile(uid), + loadLatestChatThread(uid), + loadLatestSavedAuraLook(uid), + loadUserProfilePreferences(uid), + ]); + if (!isActive()) return; + setAssistantHint(buildVisiblePreferenceHint(assistantProfile)); + setLatestChatThread(latestChat); + setLatestSavedLook(savedLook); + setProfilePreferences(userProfilePreferences); + if (latestChat?.chatId) { + const chatMessages = await loadChatMessages(uid, latestChat.chatId); + if (!isActive()) return; + const latestAura = [...chatMessages] + .reverse() + .find((message) => message.type === "assistant" && message.aura?.look)?.aura ?? null; + setLatestAuraLookResponse(latestAura); + } else { setLatestAuraLookResponse(null); - setLatestSavedLook(null); } - })(); + } catch { + if (!isActive()) return; + setAssistantHint(null); + } + }, + [uid], + ); + + useFocusEffect( + React.useCallback(() => { + let active = true; + const task = InteractionManager.runAfterInteractions(() => { + void loadHomeCompanionData(() => active); + }); return () => { active = false; + task.cancel?.(); }; - }, [uid]) + }, [loadHomeCompanionData]) ); const itemsById = useMemo(() => new Map(items.map((item) => [item.id, item])), [items]); - - const displayName = (user?.displayName ?? user?.email?.split("@")[0] ?? "there").trim(); + const styleProfile = useMemo( + () => getStyleProfileConfig(profilePreferences), + [profilePreferences] + ); + const displayName = ( + profilePreferences?.firstName ?? + user?.displayName ?? + user?.email?.split("@")[0] ?? + "there" + ).trim(); const headerGreeting = `${greeting}, ${displayName}`; const weatherLabel = useMemo(() => { @@ -193,37 +451,163 @@ export default function HomeScreen() { }, [timeLabel, weather.city, weather.label, weather.permission, weather.state, weather.tempC]); const availableCount = useMemo( - () => items.filter((item) => item.status === "AVAILABLE").length, + () => items.filter((item) => normalizeLaundryStatus(item) === "clean").length, [items] ); const laundryCount = useMemo( - () => items.filter((item) => item.status === "IN_LAUNDRY").length, + () => items.filter((item) => normalizeLaundryStatus(item) === "in_laundry").length, + [items] + ); + const needsWashCount = useMemo( + () => items.filter((item) => normalizeLaundryStatus(item) === "needs_wash").length, [items] ); const unwornCount = useMemo( () => items.filter((item) => { - const wearCount = typeof item.wearCount === "number" ? item.wearCount : 0; + const wearCount = + typeof item.wearCountSinceWash === "number" ? item.wearCountSinceWash : 0; return item.status === "AVAILABLE" && wearCount === 0; }).length, [items] ); - const recentItems = useMemo( - () => - [...items] - .sort((a, b) => { - const aMs = Math.max(toMillis(a.updatedAt), toMillis(a.createdAt)); - const bMs = Math.max(toMillis(b.updatedAt), toMillis(b.createdAt)); - return bMs - aMs; - }) - .slice(0, 8), - [items] - ); + const heroStylistNote = useMemo(() => { + if (todayRecord?.plannedOutfit || todayRecord?.wornOutfit) { + return "Built from pieces ready right now."; + } + if (unwornCount > 0) { + return "Ready to pull ignored pieces back in."; + } + if (laundryCount > 0) { + return "Built around what is already wearable now."; + } + if (weather.permission === "granted" && weather.state === "ready") { + if (weather.tempC != null && weather.tempC < 12) return "Cleaner layers will carry this best."; + if (weather.tempC != null && weather.tempC > 24) return "Keep it light without flattening the look."; + } + if (styleProfile.recommendationEmphasis.includes("smart_casual")) { + return "This stays in your lane without feeling too safe."; + } + return "A strong look can start from what you already own."; + }, [ + laundryCount, + styleProfile.recommendationEmphasis, + todayRecord?.plannedOutfit, + todayRecord?.wornOutfit, + unwornCount, + weather.permission, + weather.state, + weather.tempC, + ]); + + const heroGuidancePhrases = useMemo(() => { + const phrases: string[] = []; + if (availableCount > 0) phrases.push("Start with what is ready"); + if (unwornCount > 0) phrases.push("Pull one ignored piece back in"); + if (weather.permission === "granted" && weather.state === "ready") { + if (weather.tempC != null && weather.tempC < 12) { + phrases.push("Layer it clean"); + } else if (weather.tempC != null && weather.tempC > 24) { + phrases.push("Keep it lighter"); + } else { + phrases.push("Keep the balance sharp"); + } + } + if (!phrases.length) phrases.push("Start with what is ready"); + return phrases.slice(0, 3); + }, [availableCount, unwornCount, weather.permission, weather.state, weather.tempC]); const hasMinimalWardrobe = availableCount < 4; + const minimumClosetProgress = useMemo(() => getMinimumClosetProgress(items), [items]); + const wardrobeSuggestions = useMemo( + () => + buildWardrobeSuggestions({ + items, + profilePreferences, + savedLooks: [ + { + addToComplete: latestAuraLookResponse?.recommendedAdditions ?? [], + missingPieces: latestAuraLookResponse?.missingPieces ?? [], + upgradeSuggestions: latestAuraLookResponse?.upgradeSuggestions ?? [], + }, + ...(latestAuraLookResponse?.look + ? [{ addToComplete: latestAuraLookResponse.look.addToComplete }] + : []), + ...(latestAuraLookResponse?.lookOptions ?? []).map((look) => ({ + addToComplete: look.addToComplete, + })), + ...(latestSavedLook?.look + ? [{ addToComplete: latestSavedLook.look.addToComplete }] + : []), + ], + }), + [items, latestAuraLookResponse, latestSavedLook?.look, profilePreferences], + ); const latestLook = latestAuraLookResponse?.look ?? latestSavedLook?.look ?? null; + + useEffect(() => { + if (!uid || !wardrobeSuggestions.length) return; + wardrobeSuggestions.forEach((suggestion) => { + void trackSuggestionEvent({ + userId: uid, + eventName: "suggestion_viewed", + suggestion, + sourceScreen: "home", + }); + }); + }, [uid, wardrobeSuggestions]); + + const openWardrobeSuggestions = React.useCallback(() => { + if (uid) { + wardrobeSuggestions.forEach((suggestion) => { + void trackSuggestionEvent({ + userId: uid, + eventName: "suggestion_clicked", + suggestion, + sourceScreen: "home", + }); + }); + } + router.push("/insights"); + }, [uid, wardrobeSuggestions]); + + useEffect(() => { + if (!uid || loading) return; + homeCacheRefreshingRef.current = false; + void setCachedHomeSnapshot(uid, { + closetItemCount: items.length, + cleanCount: availableCount, + laundryCount, + needsWashCount, + minimumClosetProgress, + latestSavedLookPreview: latestSavedLook, + latestChatPreview: latestChatThread, + todayOutfitPreview: todayRecord, + }); + }, [ + availableCount, + items.length, + latestChatThread, + latestSavedLook, + laundryCount, + loading, + minimumClosetProgress, + needsWashCount, + todayRecord, + uid, + ]); const proactiveLookMeta = useMemo(() => { + const leansDressy = styleProfile.recommendationEmphasis.some((value) => + ["date_night", "going_out", "dress_styling"].includes(value) + ); + const leansCasual = styleProfile.recommendationEmphasis.some((value) => + ["casual_everyday", "streetwear", "weekend"].includes(value) + ); + const leansTailored = styleProfile.recommendationEmphasis.some((value) => + ["smart_casual", "office", "formal"].includes(value) + ); + if (latestAuraLookResponse?.look) { return { eyebrow: latestAuraLookResponse.look.addToComplete.length ? "COMPLETE THE LOOK" : "BUILT FROM YOUR WARDROBE", @@ -282,6 +666,33 @@ export default function HomeScreen() { secondaryPrompt: "What should I buy first to unlock stronger outfits?", }; } + if (leansDressy) { + return { + eyebrow: "GOOD FOR TONIGHT", + fallbackTitle: "A polished night direction", + fallbackBody: "AURA can build around the dressier side of your wardrobe and still keep it closet-first.", + primaryPrompt: "Plan a date-night or going-out look using the categories I actually wear.", + secondaryPrompt: "Style a sharper evening outfit from my wardrobe.", + }; + } + if (leansTailored) { + return { + eyebrow: "SMARTER EVERYDAY", + fallbackTitle: "Pulled together and sharp", + fallbackBody: "AURA can lean into blazers, trousers, and cleaner layers for a more polished daily direction.", + primaryPrompt: "Build a smart casual or office-ready outfit using the categories I wear most.", + secondaryPrompt: "Show a sharper version that still feels easy.", + }; + } + if (leansCasual) { + return { + eyebrow: "BUILT FOR EVERYDAY", + fallbackTitle: "Easy and lived-in", + fallbackBody: "AURA can lean into your casual rotation and keep it intentional without overcomplicating it.", + primaryPrompt: "Build a casual everyday look using the categories I wear most.", + secondaryPrompt: "Give it a streetwear edge using only my closet.", + }; + } return { eyebrow: "GOOD FOR TONIGHT", fallbackTitle: "A sharper direction", @@ -293,21 +704,207 @@ export default function HomeScreen() { hasMinimalWardrobe, latestAuraLookResponse?.look, latestSavedLook, + styleProfile.recommendationEmphasis, unwornCount, weather.permission, weather.state, weather.tempC, ]); - const starterPrompts = useMemo( + const handleRegenerateHomeLook = React.useCallback(async () => { + if (!uid) { + Alert.alert("AURA", "Please sign in to regenerate outfits."); + return; + } + if (regeneratingLook) return; + + const previousLookItemIds = Array.from( + new Set( + (latestLook?.pieces ?? []) + .map((piece) => piece.itemId) + .filter((itemId): itemId is string => !!itemId), + ), + ); + const previousLookSignature = previousLookItemIds.length + ? [...previousLookItemIds].sort().join("|") + : ""; + const previousLookSummary = latestLook + ? ` Avoid repeating this current look: ${latestLook.lookTitle}. Current closet pieces: ${(latestLook.fromCloset ?? []).join(", ")}.` + : ""; + const prompt = + `Regenerate a fresh closet-first outfit from my wardrobe for today. Make it visually distinct from the current look, use pieces I can wear now, and return one structured visual look.${previousLookSummary}`; + const pickFreshLook = (looks: AuraLook[]) => + looks.find((candidate) => { + if (!previousLookItemIds.length) return true; + const candidateIds = new Set( + (candidate.pieces ?? []) + .map((piece) => piece.itemId) + .filter((itemId): itemId is string => !!itemId), + ); + if (!candidateIds.size) return true; + const overlap = previousLookItemIds.filter((itemId) => candidateIds.has(itemId)).length; + return overlap <= 1 || overlap < previousLookItemIds.length; + }) ?? looks[0] ?? null; + + setRegeneratingLook(true); + const startedAt = Date.now(); + void trackLaunchEvent({ + userId: uid, + eventName: "ai_request_started", + properties: { + source: "home_regenerate_look", + closetItemCount: items.length, + previousLookItemCount: previousLookItemIds.length, + }, + }); + try { + try { + const batch = await generateAuraSwipeBatch({ + items, + numOutfits: 3, + intentText: prompt, + excludeItemIds: previousLookItemIds, + recentItemIds: previousLookItemIds, + previousLookItemIds, + previousLookSignatures: previousLookSignature ? [previousLookSignature] : [], + maxOverlap: 1, + }); + const batchLooks = batch.lookOptions.map((option) => option.look).filter(Boolean); + const batchLook = pickFreshLook(batchLooks); + + if (batchLook) { + const batchReason = + batch.lookOptions.find((option) => option.look === batchLook)?.reason || + batchLook.shortExplanation; + setLatestAuraLookResponse({ + presentation: "card", + title: batchLook.lookTitle, + reply: "I regenerated a fresh closet-first outfit from what is ready now.", + reason: batchReason, + outfitItems: batchLook.fromCloset, + ownedPieces: batchLook.fromCloset, + recommendedAdditions: batchLook.addToComplete, + swapSuggestion: "", + missingPieces: batchLook.addToComplete, + upgradeSuggestions: [], + upgradeSuggestionItems: [], + chips: [], + look: batchLook, + lookOptions: batchLooks, + }); + void trackLaunchEvent({ + userId: uid, + eventName: "outfit_generated", + properties: { + source: "home_regenerate_look", + provider: "generateAuraSwipeBatch", + elapsedMs: Date.now() - startedAt, + lookCount: batchLooks.length, + closetItemCount: items.length, + }, + }); + return; + } + } catch { + // The structured batch path is preferred, but the chat stream can still return a visual look. + } + + const result = await askAuraStream({ + message: prompt, + selectedDate: new Date().toISOString().slice(0, 10), + weather: + weather.permission === "granted" && weather.state === "ready" + ? { + tempF: + typeof weather.tempC === "number" + ? Math.round((weather.tempC * 9) / 5 + 32) + : null, + condition: weather.label ?? null, + } + : null, + clientIntent: "home_regenerate_look", + clientContext: { + minimumCloset: buildMinimumClosetSummary(items), + outfitDiversity: { + shouldAvoidRepeats: previousLookItemIds.length > 0, + reason: previousLookItemIds.length > 0 ? "followup" : "none", + recentItemIds: previousLookItemIds, + previousLookItemIds, + excludedItemIds: previousLookItemIds, + previousLookSignatures: previousLookSignature ? [previousLookSignature] : [], + maxOverlap: 1, + }, + }, + }); + const regeneratedLook = pickFreshLook( + [result.look, ...(result.lookOptions ?? [])].filter((look): look is AuraLook => !!look), + ); + if (!regeneratedLook) { + Alert.alert("AURA", "I couldn't regenerate a visual outfit from the current closet state. Try adding a few more ready pieces."); + return; + } + setLatestAuraLookResponse({ + ...result, + look: regeneratedLook, + lookOptions: result.lookOptions?.length ? result.lookOptions : [regeneratedLook], + }); + void trackLaunchEvent({ + userId: uid, + eventName: "outfit_generated", + properties: { + source: "home_regenerate_look", + provider: "askAuraStream", + elapsedMs: Date.now() - startedAt, + lookCount: Math.max(result.look ? 1 : 0, result.lookOptions?.length ?? 0), + closetItemCount: items.length, + }, + }); + } catch (error: any) { + if (isAuraStreamAbortError(error)) return; + void trackLaunchEvent({ + userId: uid, + eventName: "ai_response_failed", + properties: { + source: "home_regenerate_look", + elapsedMs: Date.now() - startedAt, + closetItemCount: items.length, + ...analyticsErrorProperties(error), + }, + }); + Alert.alert("AURA", error?.message ?? "Unable to regenerate this outfit right now."); + } finally { + setRegeneratingLook(false); + } + }, [ + items, + latestLook, + regeneratingLook, + uid, + weather.label, + weather.permission, + weather.state, + weather.tempC, + ]); + + const refinementActions = useMemo( () => [ - { key: "today", label: "Build today’s look", prompt: "Build an outfit for today from my wardrobe." }, - { key: "weather", label: "Dress for weather", prompt: "Build me a weather-aware outfit using what I own." }, - { key: "unworn", label: "Use unworn items", prompt: "Create a look using pieces I have not worn much." }, - { key: "trip", label: "Pack a trip", prompt: "Help me plan a compact travel wardrobe from my closet." }, - { key: "elevate", label: "Dress this up", prompt: "Elevate one of my casual outfits into something sharper." }, + { + key: "dressier", + label: "Make it dressier", + prompt: "Elevate one of my casual outfits into something sharper.", + }, + { + key: "unworn", + label: "Use unworn pieces", + prompt: "Create a look using pieces I have not worn much.", + }, + { + key: "fix", + label: "Fix this outfit", + prompt: "Fix this outfit. Give me a sharper version, a more wearable version, and a closet-first version.", + }, ], - [] + [], ); const openAIWithPrompt = React.useCallback((prompt?: string) => { @@ -334,175 +931,170 @@ export default function HomeScreen() { }); }, []); - const openSoonTool = React.useCallback((title: string, prompt: string) => { - Alert.alert( - `${title} is coming soon`, - "We haven’t built the dedicated tool yet, but the stylist can still help right now.", - [ - { text: "Not now", style: "cancel" }, - { - text: "Ask Stylist", - onPress: () => openAIWithPrompt(prompt), - }, - ] - ); - }, [openAIWithPrompt]); - - async function handleAuraLookAction(action: AuraLookAction) { - const look = latestLook; - if (!look || !uid) return; - const promptBase = look.lookTitle; - if (action === "saveLook") { - try { - const saved = await saveAuraLook(uid, look, { title: promptBase }); - setLatestSavedLook(saved); - Alert.alert("Saved", "Look saved to your profile."); - } catch (error: any) { - Alert.alert("Save failed", error?.message ?? "Unable to save this look."); - } - return; + const handleRefresh = React.useCallback(async () => { + if (!uid || refreshing) return; + setRefreshing(true); + const startedAt = Date.now(); + let active = true; + try { + await Promise.allSettled([ + loadHomeCompanionData(() => active), + weather.actions.refresh(), + ]); + } finally { + active = false; + const remaining = Math.max(0, 450 - (Date.now() - startedAt)); + setTimeout(() => setRefreshing(false), remaining); } - if (action === "planForToday") { - try { - await savePlannedRecord(uid, new Date(), auraLookToPlannedOutfit(look)); - Alert.alert("Planned", "This look is now attached to today."); - } catch (error: any) { - Alert.alert("Plan failed", error?.message ?? "Unable to plan this look for today."); + }, [loadHomeCompanionData, refreshing, uid, weather.actions]); + + async function handleAuraLookAction(action: AuraLookAction, selectedLook?: AuraLook) { + const look = selectedLook ?? latestLook; + if (action === "shopMissingPieces") { + const missingPieces = look?.addToComplete?.filter(Boolean) ?? []; + const suggestion = missingPieces[0] + ? buildAdHocWardrobeSuggestion(missingPieces[0]) + : wardrobeSuggestions[0] ?? null; + if (suggestion) { + setActiveShopSuggestion(suggestion); + return; } - return; - } - if (action === "showMoreLikeThis") { - openAIWithPrompt(`Show me 3 more looks like ${promptBase}.`); - return; } - if (action === "shopMissingPieces") { - const missingPieces = look.addToComplete.filter(Boolean); - Alert.alert( - "Missing pieces", - missingPieces.length - ? missingPieces.join("\n") - : "AURA does not see any missing pieces in this look yet.", - missingPieces.length - ? [ - { text: "Close", style: "cancel" }, - { - text: "Create shopping brief", - onPress: () => openAIWithPrompt(`Turn ${promptBase} into a concise shopping brief for the missing pieces.`), - }, - ] - : [{ text: "Close", style: "cancel" }] + await handleSharedAuraLookAction({ + uid, + action, + look, + promptBase: look?.lookTitle, + saveTitle: look?.lookTitle, + onPrompt: openAIWithPrompt, + onAlert: (title, message, buttons) => Alert.alert(title, message, buttons), + onSavedLook: setLatestSavedLook, + }); + } + + async function handleWearTodayFromHome() { + const plannedOutfit = todayRecord?.plannedOutfit ?? null; + if (!uid || !plannedOutfit) return; + try { + const wornAt = Date.now(); + const result = await markOutfitWorn({ + uid, + source: "home", + title: "Today's Look", + look: plannedOutfit, + wornAt: new Date(wornAt), + }); + setTodayRecord((prev) => ({ + ...(prev ?? { dateKey: result.dateKey }), + plannedOutfit, + wornOutfit: { + itemsByCategory: plannedOutfit.itemsByCategory, + wornAt, + source: "home", + title: "Today's Look", + outfitSnapshot: result.outfitSnapshot, + }, + })); + Toast.success( + result.alreadyMarked ? "Already marked worn today" : "Marked as worn today", + result.alreadyMarked ? "AURA will not double-count it." : undefined, ); - return; - } - if (action === "useOnlyMyCloset") { - openAIWithPrompt(`Rebuild ${promptBase} using only my closet.`); - return; - } - if (action === "makeItDressier") { - openAIWithPrompt(`Make ${promptBase} dressier.`); + } catch (error: any) { + Toast.error("Couldn't mark worn. Try again.", error?.message); } } const smartTools = useMemo( () => [ + { + key: "closet", + title: "Closet", + subtitle: `${availableCount} clean pieces ready`, + icon: "wardrobe-outline", + onPress: () => router.push("/(tabs)/closet"), + }, { key: "laundry", title: "Laundry", - subtitle: laundryCount > 0 ? `${laundryCount} items need attention` : "Check care flow and refresh pieces", + subtitle: `${needsWashCount + laundryCount} pieces need care`, icon: "washing-machine", - badge: laundryCount > 0 ? String(laundryCount) : undefined, onPress: () => router.push("/(tabs)/laundry"), }, { - key: "shopping", - title: "Shopping", - subtitle: "Turn wardrobe gaps into a cleaner wish list", - icon: "shopping-outline", - badge: "Soon", - onPress: () => - openSoonTool( - "Shopping", - "Review my wardrobe and suggest a short shopping list of meaningful gaps." - ), - }, - { - key: "favorites", - title: "Favorites", - subtitle: "Surface the pieces worth building around", - icon: "heart-outline", - badge: "Soon", - onPress: () => - openSoonTool( - "Favorites", - "Show me the standout pieces in my wardrobe and what to build around them." - ), + key: "aura-training", + title: "AURA Training", + subtitle: "Swipe outfit edits so AURA learns your taste", + icon: "gesture-swipe-horizontal", + onPress: () => router.push(AURA_TRAINING_ROUTE), }, { - key: "insights", - title: "Insights", - subtitle: "See what you wear most, least, and should rotate next", - icon: "chart-line", - onPress: () => openAIWithPrompt("Give me a concise wardrobe insight summary from what I own."), + key: "studio", + title: "Studio", + subtitle: "Build, refine, save, or plan a look", + icon: "hanger", + onPress: () => router.push("/(tabs)/studio"), }, { - key: "packing", - title: "Packing", - subtitle: "Build short trip capsules without overpacking", - icon: "bag-suitcase-outline", - onPress: () => openAIWithPrompt("Help me pack for a weekend trip using only my wardrobe."), + key: "add", + title: "Add Item", + subtitle: "Bring a new piece into rotation", + icon: "plus-circle-outline", + onPress: () => + router.push({ pathname: "/(tabs)/add", params: { addSession: String(Date.now()) } }), }, { - key: "recent", - title: "Recently worn", - subtitle: "Review what has been in rotation lately", - icon: "history", + key: "calendar", + title: "Calendar", + subtitle: "Plan the day around real context", + icon: "calendar-month-outline", onPress: () => router.push("/(tabs)/calendar"), }, { - key: "unworn", - title: "Unworn items", - subtitle: unwornCount > 0 ? `${unwornCount} items deserve airtime` : "Everything is getting some rotation", - icon: "hanger", - badge: unwornCount > 0 ? String(unwornCount) : undefined, - onPress: () => openAIWithPrompt("Build an outfit around my least-worn items."), - }, - { - key: "gaps", - title: "Wardrobe gaps", - subtitle: "Find what is missing before you buy the wrong thing", - icon: "vector-square-plus", - onPress: () => openAIWithPrompt("What wardrobe gaps should I actually fill next based on what I own?"), + key: "insights", + title: "Insights", + subtitle: "Read rotation, gaps, and closet signals", + icon: "chart-box-outline", + onPress: () => router.push("/insights"), }, ], - [laundryCount, openAIWithPrompt, openSoonTool, unwornCount] + [availableCount, laundryCount, needsWashCount] ); + // Keep the deferred Home feature inventory close to the screen entry point so it is + // discoverable during future product passes without reintroducing clutter now. + void HOME_DEFERRED_FEATURES; + if (loading) { - return ( - - - Building your dashboard… - - ); + return ; } return ( - + + + + } contentContainerStyle={{ paddingTop: layout.topContentInset, paddingHorizontal: layout.horizontalPadding, - paddingBottom: layout.bottomDockPadding, - gap: layout.sectionGap, + paddingBottom: layout.bottomDockPadding + HOME_BOTTOM_BREATHING_ROOM, + gap: HOME_SECTION_GAP, }} > @@ -511,180 +1103,88 @@ export default function HomeScreen() { greeting={headerGreeting} weatherLabel={weatherLabel} personalHint={assistantHint} - onAskStylist={() => openAIWithPrompt("Build me a strong outfit from my wardrobe for today.")} - onPlanToday={() => router.push("/(tabs)/calendar")} - /> - - - - openAIWithPrompt("Plan my outfit for today using what I already own.")} - onOpenCalendar={() => router.push("/(tabs)/calendar")} - onAskStylist={() => openAIWithPrompt("Refine or improve my outfit plan for today.")} - /> - - - - {latestChatThread?.chatId && latestChatThread.lastMessagePreview ? ( - openAIChat(latestChatThread.chatId)} - /> - ) : null} - - - - openAIWithPrompt("Build me a strong outfit from my wardrobe for today.")} + onWearToday={handleWearTodayFromHome} /> - - - - Ask faster - - Start the stylist with a focused prompt instead of a blank thread. + + + + Not feeling it? + + Shift the direction without leaving the styling flow. openAIWithPrompt(action.prompt)} /> - - - - - - - 0 - ? `${unwornCount} pieces are ready for a comeback` - : "Your wardrobe is staying in motion" - } - body={ - hasMinimalWardrobe - ? "Once you add a few more available items, the stylist can build stronger rotations, gap analysis, and smarter daily suggestions." - : unwornCount > 0 - ? "Use the stylist to pull neglected items back into rotation before they disappear into the background." - : "You do not have obvious dead stock right now. A few targeted outfit prompts can keep that momentum going." - } - ctaLabel={hasMinimalWardrobe ? "Add another item" : "Use unworn pieces"} - onPress={() => - hasMinimalWardrobe - ? router.push("/(tabs)/add") - : openAIWithPrompt("Build a look around pieces I have not worn enough.") - } - /> + {renderDeferredHomeSections ? ( + <> + + + - 0 - ? `${laundryCount} items are sitting in laundry` - : "Care flow is under control" - } - body={ - availableCount === 0 - ? "Add a few pieces first, then Home will start surfacing stronger outfit planning and rotation insights." - : laundryCount > 0 - ? "Push those pieces back to available once they are clean so your daily outfit options stay full." - : `You have ${availableCount} available pieces ready to wear, so planning can stay focused on choice instead of recovery.` - } - ctaLabel={ - availableCount === 0 - ? "Add first item" - : laundryCount > 0 - ? "Open Laundry" - : "Plan around what is ready" - } - onPress={() => - availableCount === 0 - ? router.push("/(tabs)/add") - : laundryCount > 0 - ? router.push("/(tabs)/laundry") - : openAIWithPrompt("Build me an outfit from the pieces that are ready to wear right now.") - } - /> - - + {wardrobeSuggestions.length ? ( + + + + ) : null} - - {recentItems.length ? ( - - router.push({ - pathname: "/(tabs)/item/[id]", - params: { id: item.id }, - }) - } - /> - ) : ( - router.push("/(tabs)/add")} - /> - )} - + + + - - router.push("/(tabs)/add")} - style={({ pressed }) => ({ - borderRadius: layout.largeRadius, - paddingVertical: layout.cardPadding + 2, - paddingHorizontal: layout.cardPadding, - backgroundColor: "rgba(255,255,255,0.045)", - borderWidth: 1, - borderColor: "rgba(255,255,255,0.08)", - opacity: pressed ? 0.86 : 1, - gap: 4, - })} - > - Add something new - - Bring in a new piece, clean its image, and let the assistant classify it in the background. - - - + {latestChatThread?.chatId && latestChatThread.lastMessagePreview ? ( + + openAIChat(latestChatThread.chatId)} + /> + + ) : null} + + ) : null} + setActiveShopSuggestion(null)} + /> ); } diff --git a/app/(tabs)/item/[id].tsx b/app/(tabs)/item/[id].tsx index 63a1dd6..ece77f4 100644 --- a/app/(tabs)/item/[id].tsx +++ b/app/(tabs)/item/[id].tsx @@ -1,120 +1,926 @@ +import { Ionicons } from "@expo/vector-icons"; +import { LinearGradient } from "expo-linear-gradient"; import { router, useLocalSearchParams } from "expo-router"; -import { deleteDoc, deleteField, doc, onSnapshot, updateDoc } from "firebase/firestore"; +import { doc, onSnapshot } from "firebase/firestore"; import React, { useEffect, useMemo, useState } from "react"; -import { Alert, Image, Modal, Pressable, ScrollView, Text, TextInput, View } from "react-native"; +import { + Alert, + Linking, + Modal, + type NativeScrollEvent, + type NativeSyntheticEvent, + Pressable, + ScrollView, + Share, + StyleSheet, + Text, + useWindowDimensions, + View, +} from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; -import { SafeScreen } from "../../../src/components/SafeScreen"; -import { ALLOWED_COLORS } from "../../../src/shared/wardrobeTaxonomy"; -import { useAuth } from "../../../src/hooks/useAuth"; -import { useAppTheme } from "../../../src/hooks/useAppTheme"; -import { useResponsiveLayout } from "../../../src/hooks/useResponsiveLayout"; -import { db } from "../../../src/lib/firebase"; -import { getItemImageUrl } from "../../../src/lib/itemImage"; +import type { AppColors } from "@/constants/theme"; +import AppImage from "@/src/components/common/AppImage"; +import AuraPressable from "@/src/components/aura/AuraPressable"; +import { SafeScreen } from "@/src/components/SafeScreen"; +import AuraBackButton from "@/src/components/ui/AuraBackButton"; +import { AuraSkeleton, AuraSkeletonLine } from "@/src/components/ui/AuraSkeleton"; +import { + AuraButton, + AuraDivider, + AuraIconButton, + AuraSheetBackdrop, + AuraSheetSurface, + AuraText, + AuraTopSafeAreaScrim, + auraCardStyle, + auraDesignTokens, +} from "@/src/components/ui/auraStylePrimitives"; +import { useAuth } from "@/src/hooks/useAuth"; +import { useAppTheme } from "@/src/hooks/useAppTheme"; +import { useResponsiveLayout } from "@/src/hooks/useResponsiveLayout"; +import { db } from "@/src/lib/firebase"; +import { deleteWardrobeItem } from "@/src/lib/deleteItem"; +import { runHaptic } from "@/src/lib/haptics"; +import { getItemImageUrl } from "@/src/lib/itemImage"; import { getIngestionStatus, + LAUNDRY_STATUS_LABELS, + markNeedsWash, markWashed as markWashedItem, - safeMarkWorn, + normalizeLaundryStatus, sendToLaundry, -} from "../../../src/lib/items"; -import { ClothingItem } from "../../../src/types/ClothingItem"; +} from "@/src/lib/items"; +import { safeGoBack } from "@/src/lib/navigation"; +import { Toast } from "@/src/lib/toast"; +import { markItemWorn } from "@/src/lib/wearOutfit"; +import type { ClothingItem, LaundryStatus } from "@/src/types/ClothingItem"; + +type TimestampLike = { + toDate: () => Date; +}; type ItemDetails = ClothingItem & { id: string; + source?: string | null; + sourceUrl?: string | null; + retailer?: string | null; + domain?: string | null; + updatedAt?: number | string | TimestampLike | null; + itemLifecycleStatus?: string | null; + ingestionSource?: { + sourceType?: string | null; + sourceUrl?: string | null; + domain?: string | null; + } | null; + linkMetadata?: { + sourceUrl?: string | null; + domain?: string | null; + retailer?: string | null; + } | null; }; -function formatDate(value?: any | null) { - if (!value) return "—"; - if (typeof value?.toDate === "function") { - return value.toDate().toLocaleDateString(); - } - if (typeof value === "number") { - return new Date(value).toLocaleDateString(); - } - return "—"; -} +type DetailImageAsset = { + uri: string; + isPrimary: boolean; +}; + +type DetailRow = { + label: string; + value: string; + multiline?: boolean; +}; + +type DetailGroupItem = DetailRow; -function formatDateOrNotSet(value?: any | null) { - if (!value) return "Not set"; - return formatDate(value); +type DetailGroup = { + title: string; + items: DetailGroupItem[]; +}; + +const STATUS_OPTIONS: LaundryStatus[] = ["needs_wash", "in_laundry", "clean"]; +const DETAIL_RADIUS = 24; +const BUTTON_HEIGHT = 52; + +function cleanString(value: unknown) { + return String(value ?? "").replace(/\s+/g, " ").trim(); } -function ingestionStatusLabel(item: ItemDetails) { - const status = getIngestionStatus(item) ?? "pending"; - if (status === "done") return "done"; - if (status === "failed") return "failed"; - if (status === "processing") return "processing"; - return "pending"; +function isMeaningful(value: unknown) { + if (Array.isArray(value)) return value.some((entry) => cleanString(entry)); + return Boolean(cleanString(value)); } -function toTitleCase(value: string) { - if (!value) return value; +function titleCase(value: string) { return value + .replace(/[_-]+/g, " ") .split(" ") .filter(Boolean) .map((part) => `${part[0]?.toUpperCase() ?? ""}${part.slice(1)}`) .join(" "); } -function formatValue(value?: string | null) { - const normalized = String(value ?? "").trim(); - return normalized ? normalized : "—"; +function displayValue(value: unknown) { + const normalized = cleanString(value); + return normalized ? titleCase(normalized) : ""; +} + +function normalizeDisplayToken(value: unknown) { + const normalized = cleanString(value) + .replace(/_/g, " ") + .replace(/\s+/g, " ") + .trim(); + return normalized ? titleCase(normalized) : ""; +} + +function dedupeTokens(values: unknown[]) { + const seen = new Set(); + const next: string[] = []; + const pushValue = (value: unknown) => { + const normalized = normalizeDisplayToken(value); + const key = normalized.toLowerCase(); + if (!normalized || seen.has(key)) return; + seen.add(key); + next.push(normalized); + }; + + values.forEach((value) => { + if (Array.isArray(value)) { + value.forEach((entry) => dedupeTokens([entry]).forEach(pushValue)); + return; + } + cleanString(value) + .split(/\s*(?:\/|\||,|\u2022)\s*/u) + .filter(Boolean) + .forEach(pushValue); + }); + return next; +} + +function uniqueStrings(values: unknown[]) { + return dedupeTokens(values); +} + +function joinLabels(values: unknown[]) { + return dedupeTokens(values).join(" / "); +} + +function hasToDate(value: unknown): value is TimestampLike { + return ( + typeof value === "object" && + value !== null && + "toDate" in value && + typeof (value as { toDate?: unknown }).toDate === "function" + ); +} + +function dateFromValue(value?: unknown | null) { + if (!value) return null; + if (hasToDate(value)) { + const date = value.toDate(); + return Number.isNaN(date.getTime()) ? null : date; + } + if (typeof value === "number") { + const date = new Date(value); + return Number.isNaN(date.getTime()) ? null : date; + } + if (typeof value === "string") { + const date = new Date(value); + return Number.isNaN(date.getTime()) ? null : date; + } + return null; +} + +function formatDateTime(value?: unknown | null) { + const date = dateFromValue(value); + return date ? date.toLocaleString() : ""; +} + +function isValidImageUrl(value?: string | null) { + const url = cleanString(value); + if (!url) return false; + return url.startsWith("file://") || /^https?:\/\//i.test(url); +} + +function isValidHttpUrl(value?: string | null) { + const url = cleanString(value); + if (!url) return false; + try { + const parsed = new URL(url); + return parsed.protocol === "http:" || parsed.protocol === "https:"; + } catch { + return false; + } +} + +function normalizeHttpUrl(value?: string | null) { + const url = cleanString(value); + if (!isValidHttpUrl(url)) return null; + return url; +} + +function domainFromUrl(value?: string | null) { + const url = normalizeHttpUrl(value); + if (!url) return ""; + try { + return new URL(url).hostname.replace(/^www\d*\./i, ""); + } catch { + return ""; + } +} + +function dedupeImageAssets(images: DetailImageAsset[]) { + const seen = new Set(); + return images.filter((image) => { + const key = image.uri.trim(); + if (!key || seen.has(key)) return false; + seen.add(key); + return true; + }); +} + +function getItemDetailImages(item: ItemDetails | null): DetailImageAsset[] { + if (!item) return []; + + const next: DetailImageAsset[] = []; + const sources = [...(item.images ?? []), ...(item.photos?.images ?? [])]; + + for (const image of sources) { + const isPrimary = Boolean(image?.isPrimary); + const candidate = isPrimary + ? getItemImageUrl(item, { variant: "hero" }) + : cleanString(image?.cleanedUrl ?? image?.originalUrl) || null; + + if (candidate && isValidImageUrl(candidate)) { + next.push({ uri: candidate, isPrimary }); + } + } + + const fallback = getItemImageUrl(item, { variant: "hero" }); + if (fallback && isValidImageUrl(fallback)) { + next.push({ uri: fallback, isPrimary: next.length === 0 }); + } + + const deduped = dedupeImageAssets(next); + return deduped.sort((a, b) => Number(b.isPrimary) - Number(a.isPrimary)); +} + +function ingestionStatusLabel(item: ItemDetails | null | undefined) { + const status = getIngestionStatus(item) ?? "pending"; + return titleCase(status); +} + +function itemTitle(item: ItemDetails) { + return ( + cleanString(item.name) || + uniqueStrings([item.primaryColor, item.colorLabel, item.brand, item.subCategory, item.type, item.category]) + .map(titleCase) + .join(" ") || + "Closet Item" + ); +} + +function itemSubtitle(item: ItemDetails) { + return cleanString(item.brand); +} + +function productUrlForItem(item: ItemDetails | null | undefined) { + if (!item) return null; + return ( + normalizeHttpUrl(item.sourceUrl) ?? + normalizeHttpUrl(item.ingestionSource?.sourceUrl) ?? + normalizeHttpUrl(item.linkMetadata?.sourceUrl) + ); +} + +function sourceLabelForItem(item: ItemDetails) { + return ( + cleanString(item.retailer) || + cleanString(item.linkMetadata?.retailer) || + cleanString(item.domain) || + cleanString(item.ingestionSource?.domain) || + domainFromUrl(productUrlForItem(item)) || + cleanString(item.source) + ); +} + +function classificationLabel(item: ItemDetails) { + return joinLabels([item.category, item.subCategory || item.type]); +} + +function colorLabel(item: ItemDetails) { + return joinLabels([item.primaryColor, item.colorLabel, item.displayColor, item.displayColors, item.colors]); +} + +function detectedColorLabel(item: ItemDetails) { + return joinLabels([item.aiColorLabel, item.aiColors, item.pixelColors]); +} + +function primaryColorForDetail(item: ItemDetails) { + return dedupeTokens([item.primaryColor, item.colorLabel, item.displayColor])[0] ?? ""; +} + +function visibleColorsForDetail(item: ItemDetails) { + const primary = primaryColorForDetail(item).toLowerCase(); + const visibleColors = dedupeTokens([item.displayColors, item.colors, item.aiColors, item.pixelColors]); + if (visibleColors.length <= 1 && visibleColors[0]?.toLowerCase() === primary) return ""; + return visibleColors + .slice(0, 6) + .join(" / "); +} + +function isUsefulDetailValue(value: string) { + const normalized = cleanString(value).toLowerCase(); + return Boolean(normalized) && !["unknown", "not set", "n/a", "na", "none", "-"].includes(normalized); } -function QuickFact(props: { label: string; value: string; tone?: "default" | "success" | "danger" }) { - const { label, value, tone = "default" } = props; +function filterDetailGroupItems(items: DetailGroupItem[]) { + return items.filter((item) => isMeaningful(item.value) && isUsefulDetailValue(item.value)); +} + +function buildDetailGroups(item: ItemDetails) { + const groups: DetailGroup[] = [ + { + title: "Overview", + items: filterDetailGroupItems([ + { label: "Category", value: displayValue(item.category) }, + { label: "Fit", value: displayValue(item.fit) }, + { label: "Brand", value: cleanString(item.brand) }, + ]), + }, + { + title: "Material & Design", + items: filterDetailGroupItems([ + { label: "Material", value: displayValue(item.material) || joinLabels([item.materials]), multiline: true }, + { label: "Pattern", value: displayValue(item.pattern) }, + { label: "Style", value: displayValue(item.style) }, + ]), + }, + { + title: "Color", + items: filterDetailGroupItems([ + { label: "Primary", value: primaryColorForDetail(item) || colorLabel(item) }, + { label: "Secondary", value: visibleColorsForDetail(item), multiline: true }, + ]), + }, + { + title: "Occasion", + items: filterDetailGroupItems([ + { label: "Season", value: joinLabels([item.seasonTags]), multiline: true }, + { label: "Use", value: joinLabels([item.occasionTags]), multiline: true }, + { label: "Tags", value: joinLabels([item.detailTags, item.aestheticTags]), multiline: true }, + ]), + }, + ]; + + return groups.filter((group) => group.items.length > 0); +} + +// Advanced metadata is intentionally kept available while the Advanced UI is hidden. +// eslint-disable-next-line @typescript-eslint/no-unused-vars +function buildAdvancedRows(item: ItemDetails) { + const rows: DetailRow[] = [ + { label: "Ingestion status", value: ingestionStatusLabel(item) }, + { label: "Lifecycle", value: displayValue(item.itemLifecycleStatus || item.draftState) }, + { + label: "Source", + value: joinLabels([ + item.source, + item.ingestionSource?.sourceType, + item.sourceUrl ? domainFromUrl(item.sourceUrl) : null, + ]), + }, + { label: "Classification", value: classificationLabel(item) }, + { label: "Detected colors", value: detectedColorLabel(item) || colorLabel(item) }, + { label: "Created", value: formatDateTime(item.createdAt) }, + { label: "Updated", value: formatDateTime(item.updatedAt) }, + ]; + return rows.filter((row) => isMeaningful(row.value)); +} + +function auraInsightForItem(item: ItemDetails) { + const token = uniqueStrings([item.category, item.subCategory, item.type, item.style]) + .join(" ") + .toLowerCase(); + if (/\b(shoe|shoes|sneaker|sneakers|boot|boots|loafer|loafers|heel|heels|footwear)\b/.test(token)) { + return "Use this as the anchor piece and keep the rest of the outfit balanced."; + } + if (/\b(outerwear|jacket|coat|blazer|hoodie|cardigan|overshirt|trench|parka)\b/.test(token)) { + return "Layer over basics and keep inner pieces visually lighter."; + } + if (/\b(top|shirt|tee|tshirt|t-shirt|sweater|blouse|tank|polo|kurta)\b/.test(token)) { + return "Works well with neutral bottoms, clean sneakers, or layered outerwear."; + } + if (/\b(bottom|pants|trousers|jeans|shorts|skirt|cargo|chinos)\b/.test(token)) { + return "Pair with a simple top and let the silhouette set the outfit direction."; + } + if (/\b(accessory|bag|belt|watch|hat|cap|sunglasses|scarf|jewelry|jewellery)\b/.test(token)) { + return "Use this as a finishing accent and keep nearby colors intentional."; + } + return "Build around the strongest visual cue, then keep color and proportion balanced."; +} + +function buildUsageRows(item: ItemDetails, status: LaundryStatus) { + return filterDetailGroupItems([ + { label: "Styling read", value: auraInsightForItem(item), multiline: true }, + { label: "Laundry", value: LAUNDRY_STATUS_LABELS[status] }, + { label: "Wear count", value: `${Number(item.wearCountSinceWash ?? 0)} since wash` }, + { label: "Last worn", value: formatDateTime(item.lastWornAt ?? item.lastWornDate) }, + { label: "Last washed", value: formatDateTime(item.lastWashedAt ?? item.lastWashedDate) }, + ]); +} + +function shouldStackDetailRow(row: DetailRow) { + if (row.multiline) return true; + return cleanString(row.value).length > 42; +} + +function auraPromptForItem(item: ItemDetails) { + const details = uniqueStrings([ + itemTitle(item), + item.brand, + item.category, + item.subCategory, + item.primaryColor || item.colorLabel, + item.material, + item.fit, + ]).join(", "); + return [ + `Style this closet item for me: ${details}.`, + "Use it as the anchor and build a wearable outfit around it using my closet where possible.", + ].join("\n"); +} + +function shareMessageForItem(item: ItemDetails, productUrl?: string | null) { + const name = itemTitle(item); + const brand = cleanString(item.brand); + const title = brand ? `${name} by ${brand}` : name; + return [title, productUrl].filter(Boolean).join("\n"); +} + +function createStyles(colors: AppColors) { + return StyleSheet.create({ + card: { + ...auraCardStyle(colors, "card"), + padding: 16, + gap: 16, + borderRadius: DETAIL_RADIUS, + }, + heroCard: { + borderWidth: 1, + borderColor: colors.borderSoft, + borderRadius: 28, + padding: 5, + overflow: "hidden", + backgroundColor: colors.surface, + }, + heroBlock: { + gap: 14, + }, + heroOverlay: { + ...StyleSheet.absoluteFillObject, + justifyContent: "flex-end", + paddingHorizontal: 18, + paddingBottom: 16, + borderRadius: 22, + overflow: "hidden", + }, + heroOverlayCopy: { + gap: 4, + }, + heroSummary: { + gap: 9, + paddingHorizontal: 2, + paddingTop: 2, + }, + heroSummaryRow: { + flexDirection: "row", + alignItems: "flex-start", + justifyContent: "space-between", + gap: 16, + }, + sectionBlock: { + gap: 10, + }, + sectionCard: { + borderRadius: DETAIL_RADIUS, + borderWidth: 1, + borderColor: colors.border, + backgroundColor: colors.surface, + padding: 16, + gap: 14, + }, + sectionTitleSmall: { + color: colors.text, + fontSize: 15, + lineHeight: 20, + fontWeight: "600", + }, + row: { + flexDirection: "row", + justifyContent: "space-between", + gap: 14, + paddingVertical: 11, + borderBottomWidth: StyleSheet.hairlineWidth, + borderBottomColor: colors.border, + }, + rowLast: { + borderBottomWidth: 0, + paddingBottom: 0, + }, + actionStack: { + gap: 10, + }, + secondaryActionRow: { + flexDirection: "row", + alignItems: "stretch", + gap: 10, + }, + tertiaryActionRow: { + flexDirection: "row", + gap: 10, + }, + actionButton: { + minWidth: 0, + minHeight: BUTTON_HEIGHT, + borderRadius: 18, + flexDirection: "row", + alignItems: "center", + justifyContent: "center", + gap: 8, + paddingHorizontal: 16, + paddingVertical: 0, + }, + primaryActionButton: { + width: "100%", + }, + secondaryActionButton: { + width: "100%", + minHeight: 48, + }, + tertiaryActionButton: { + flex: 1, + minHeight: 42, + paddingHorizontal: 13, + borderRadius: 16, + }, + imagePlaceholder: { + minHeight: 330, + borderRadius: 22, + backgroundColor: colors.surfaceMuted, + alignItems: "center", + justifyContent: "center", + borderWidth: 1, + borderColor: colors.borderSoft, + overflow: "hidden", + }, + selectedStatusSegment: { + backgroundColor: colors.primary, + borderColor: "transparent", + }, + unselectedStatusSegment: { + backgroundColor: "transparent", + borderColor: "transparent", + }, + statusControl: { + flexDirection: "row", + padding: 3, + borderRadius: 15, + borderWidth: 1, + borderColor: "rgba(251,228,216,0.12)", + backgroundColor: "rgba(9,0,11,0.24)", + }, + statusSegment: { + flex: 1, + minHeight: 36, + borderRadius: 12, + borderWidth: 0, + alignItems: "center", + justifyContent: "center", + paddingHorizontal: 6, + }, + insightCard: { + padding: 16, + gap: 12, + borderRadius: DETAIL_RADIUS, + borderWidth: 1, + borderColor: colors.border, + backgroundColor: colors.surface, + }, + insightHeader: { + flexDirection: "row", + alignItems: "flex-start", + gap: 12, + }, + insightIcon: { + width: 34, + height: 34, + borderRadius: 17, + alignItems: "center", + justifyContent: "center", + backgroundColor: colors.accentSoft, + borderWidth: 1, + borderColor: colors.borderSoft, + }, + insightButtonRow: { + flexDirection: "row", + gap: 10, + }, + insightButton: { + flex: 1, + minHeight: 40, + borderRadius: 16, + }, + detailGroups: { + gap: 20, + }, + detailGroupCard: { + borderRadius: 0, + padding: 0, + gap: 0, + backgroundColor: "transparent", + }, + detailGroupHeader: { + paddingBottom: 4, + }, + detailGroupItems: { + borderTopWidth: StyleSheet.hairlineWidth, + borderTopColor: colors.borderSoft, + }, + detailValueRow: { + flexDirection: "row", + alignItems: "flex-start", + justifyContent: "space-between", + gap: 14, + paddingVertical: 9, + borderBottomWidth: StyleSheet.hairlineWidth, + borderBottomColor: colors.borderSoft, + }, + detailValueRowStacked: { + flexDirection: "column", + alignItems: "stretch", + gap: 5, + }, + detailValueRowLast: { + borderBottomWidth: 0, + }, + detailLabel: { + width: 104, + color: colors.textMuted, + fontSize: 12.5, + lineHeight: 18, + fontWeight: "500", + }, + detailLabelStacked: { + width: "100%", + }, + detailValueText: { + flex: 1, + color: colors.textPrimary, + fontSize: 14.5, + lineHeight: 20, + fontWeight: "500", + textAlign: "right", + }, + detailValueTextStacked: { + flex: 0, + width: "100%", + textAlign: "left", + }, + productSourceButton: { + minHeight: 48, + borderRadius: 16, + flexDirection: "row", + alignItems: "center", + justifyContent: "space-between", + gap: 8, + paddingHorizontal: 14, + borderWidth: 1, + borderColor: colors.borderSoft, + backgroundColor: colors.surfaceMuted, + }, + modalRoot: { + flex: 1, + backgroundColor: colors.background, + }, + modalTitle: { + color: colors.text, + fontSize: 18, + fontWeight: "600", + textAlign: "center", + }, + modalCloseButton: { + position: "absolute", + right: 12, + zIndex: 10, + paddingVertical: 10, + paddingHorizontal: 12, + borderRadius: 999, + backgroundColor: colors.surfaceGlass, + borderWidth: 1, + borderColor: colors.border, + }, + modalImageStage: { + width: "100%", + minHeight: 360, + alignItems: "center", + justifyContent: "center", + backgroundColor: colors.outfitBoardBackground, + borderRadius: 20, + overflow: "hidden", + paddingHorizontal: 24, + paddingVertical: 24, + borderWidth: 1, + borderColor: colors.borderWarm, + }, + carouselFrame: { + width: "100%", + overflow: "hidden", + alignItems: "center", + justifyContent: "center", + borderWidth: 1, + }, + carouselPagination: { + flexDirection: "row", + alignItems: "center", + justifyContent: "space-between", + paddingHorizontal: 2, + }, + carouselCounter: { + minHeight: 28, + borderRadius: 999, + paddingHorizontal: 10, + alignItems: "center", + justifyContent: "center", + backgroundColor: colors.surfaceMuted, + borderWidth: 1, + borderColor: colors.borderSoft, + }, + actionMenuBackdrop: { + flex: 1, + alignItems: "center", + justifyContent: "flex-end", + paddingHorizontal: 16, + }, + actionMenuCard: { + width: "100%", + maxWidth: 420, + padding: 12, + overflow: "hidden", + borderRadius: auraDesignTokens.radii.xl, + borderColor: colors.borderStrong, + gap: 8, + }, + actionMenuSurface: { + gap: 8, + }, + actionMenuHandle: { + alignSelf: "center", + width: 42, + height: 3, + borderRadius: 999, + backgroundColor: colors.borderStrong, + marginBottom: 4, + }, + actionMenuButton: { + minHeight: 52, + paddingHorizontal: 16, + paddingVertical: 0, + flexDirection: "row", + alignItems: "center", + justifyContent: "space-between", + gap: 10, + borderRadius: auraDesignTokens.radii.md, + borderWidth: 1, + backgroundColor: colors.surfaceMuted, + borderColor: colors.borderSoft, + }, + }); +} + +function DetailRowsBlock({ title, rows }: { title: string; rows: DetailRow[] }) { const { colors } = useAppTheme(); - const valueColor = - tone === "success" ? colors.accent : tone === "danger" ? "#ff6b6b" : colors.text; + const styles = useMemo(() => createStyles(colors), [colors]); + if (!rows.length) return null; return ( - - - {label} - - {value} + + + + {title} + + + + {rows.map((item, index) => { + const stacked = shouldStackDetailRow(item); + return ( + + + {item.label} + + + {item.value} + + + ); + })} + ); } -function SectionCard(props: { - title: string; - subtitle?: string | null; - children: React.ReactNode; -}) { - const { title, subtitle = null, children } = props; - const { colors } = useAppTheme(); +function DetailGroupBlock({ group }: { group: DetailGroup }) { + return ; +} + +function ItemDetailLoadingSkeleton() { const layout = useResponsiveLayout(); + return ( - - - {title} - {subtitle ? ( - {subtitle} + + + + + + + + + + + + ); +} + +function ProductHeroCard({ + item, + images, + activeImageIndex, + onIndexChange, + onPressImage, +}: { + item: ItemDetails; + images: DetailImageAsset[]; + activeImageIndex: number; + onIndexChange: (index: number) => void; + onPressImage: () => void; +}) { + const { colors } = useAppTheme(); + const styles = useMemo(() => createStyles(colors), [colors]); + const eyebrow = itemSubtitle(item) || sourceLabelForItem(item); + const overlay = ( + + + + {eyebrow ? ( + + {eyebrow} + ) : null} + + {itemTitle(item)} + - {children} + + ); + + return ( + + {images.length > 0 ? ( + + ) : ( + + + + Photo unavailable + + {overlay} + + )} ); } @@ -123,95 +929,75 @@ export default function ItemDetailsScreen() { const { user } = useAuth(); const { colors } = useAppTheme(); const layout = useResponsiveLayout(); + const styles = useMemo(() => createStyles(colors), [colors]); + const insets = useSafeAreaInsets(); const uid = user?.uid ?? null; - const { id } = useLocalSearchParams<{ id: string }>(); + const { id, sourceTab, sourceRoute } = useLocalSearchParams<{ + id: string; + sourceTab?: string; + sourceRoute?: string; + }>(); const itemId = useMemo(() => (Array.isArray(id) ? id[0] : id), [id]); + const resolvedSourceTab = useMemo(() => { + const value = Array.isArray(sourceTab) ? sourceTab[0] : sourceTab; + if ( + value === "closet" || + value === "index" || + value === "ai" || + value === "calendar" || + value === "profile" || + value === "laundry" || + value === "aura" + ) { + return value; + } + return null; + }, [sourceTab]); + const resolvedSourceRoute = useMemo(() => { + const value = Array.isArray(sourceRoute) ? sourceRoute[0] : sourceRoute; + if ( + value === "/" || + value === "/(tabs)/closet" || + value === "/(tabs)/ai" || + value === "/(tabs)/calendar" || + value === "/(tabs)/profile" || + value === "/(tabs)/laundry" + ) { + return value; + } + return null; + }, [sourceRoute]); + const itemFallbackRoute = useMemo(() => { + if (resolvedSourceRoute) return resolvedSourceRoute; + if (resolvedSourceTab === "index") return "/" as const; + if (resolvedSourceTab === "ai" || resolvedSourceTab === "aura") return "/(tabs)/ai" as const; + if (resolvedSourceTab === "calendar") return "/(tabs)/calendar" as const; + if (resolvedSourceTab === "profile") return "/(tabs)/profile" as const; + if (resolvedSourceTab === "laundry") return "/(tabs)/laundry" as const; + return "/(tabs)/closet" as const; + }, [resolvedSourceRoute, resolvedSourceTab]); const [item, setItem] = useState(null); const [loading, setLoading] = useState(true); const [actionLoading, setActionLoading] = useState(false); - const [colorSaving, setColorSaving] = useState(false); - const [colorSavedAt, setColorSavedAt] = useState(null); const [detailImageOpen, setDetailImageOpen] = useState(false); - const [patternDraft, setPatternDraft] = useState(""); - const [materialDraft, setMaterialDraft] = useState(""); - const [footerHeight, setFooterHeight] = useState(0); - const itemImageUri = getItemImageUrl(item, { variant: "hero" }); - const footerOffset = layout.composerOffset; - const quickFacts = item - ? [ - { label: "Category", value: formatValue(item.category) }, - { label: "Sub-category", value: formatValue(item.subCategory) }, - { label: "Size", value: formatValue(item.size) }, - { - label: "Status", - value: formatValue(item.status || "available"), - tone: item.status === "IN_LAUNDRY" ? ("danger" as const) : ("default" as const), - }, - { label: "Last worn", value: formatDate(item.lastWornDate) }, - { - label: "Last washed", - value: formatDateOrNotSet(item.lastWashedAt ?? item.lastWashedDate), - }, - ] - : []; - const aiFacts = item - ? [ - { - label: "Ingestion", - value: - ingestionStatusLabel(item) === "done" - ? "Complete" - : ingestionStatusLabel(item) === "failed" - ? "Failed" - : ingestionStatusLabel(item) === "processing" - ? "Processing" - : "Pending", - tone: - ingestionStatusLabel(item) === "done" - ? ("success" as const) - : ingestionStatusLabel(item) === "failed" - ? ("danger" as const) - : ("default" as const), - }, - { label: "Source", value: formatValue(item.colorSource || "ai") }, - { - label: "Classification", - value: `${formatValue(item.category)} / ${formatValue(item.subCategory)}`, - }, - { - label: "Detected colors", - value: formatValue(item.colorLabel || item.colors?.join(" / ")), - }, - ] - : []; + const [activeImageIndex, setActiveImageIndex] = useState(0); + const [actionMenuVisible, setActionMenuVisible] = useState(false); + const detailImages = useMemo(() => getItemDetailImages(item), [item]); + const productUrl = useMemo(() => productUrlForItem(item), [item]); - useEffect(() => { - if (!itemImageUri) return; - const baseUri = itemImageUri.split("?")[0]?.toLowerCase() ?? ""; - const kind = baseUri.endsWith(".png") - ? "png" - : baseUri.endsWith(".jpg") || baseUri.endsWith(".jpeg") - ? "jpg" - : "unknown"; - console.log("[ItemScreen] displaying image URI:", itemImageUri); - console.log("[ItemScreen] displayed image suffix:", kind); - }, [itemImageUri]); + function navigateBackToSource() { + if (resolvedSourceTab) { + router.replace(itemFallbackRoute); + return; + } + safeGoBack("/(tabs)/closet"); + } useEffect(() => { - if (!item) return; - setPatternDraft(item.pattern ?? ""); - setMaterialDraft(item.material ?? ""); - console.log("[ItemScreen] image fields:", { - itemId: item.id, - photos: item.photos ?? null, - photoUrl: item.photoUrl ?? null, - photoUri: item.photoUri ?? null, - cleanedUrl: (item as any).cleanedUrl ?? null, - cleanedPhotoUrl: (item as any).cleanedPhotoUrl ?? null, - selectedImageUri: itemImageUri, - }); - }, [item, itemImageUri]); + if (activeImageIndex <= Math.max(detailImages.length - 1, 0)) return; + setActiveImageIndex(0); + }, [activeImageIndex, detailImages.length]); useEffect(() => { if (!uid || !itemId) { @@ -227,44 +1013,77 @@ export default function ItemDetailsScreen() { if (!snap.exists()) { setItem(null); } else { - setItem({ id: snap.id, ...(snap.data() as any) }); + setItem({ id: snap.id, ...(snap.data() as Omit) }); } setLoading(false); }, (err) => { - console.log(err); + if (__DEV__) { + console.log(err); + } Alert.alert("Error", err.message); setLoading(false); - } + }, ); return () => unsub(); }, [itemId, uid]); - async function onMarkWorn() { + async function onSendToLaundry() { if (!uid || !itemId) return router.replace("/(auth)/login"); - if (item?.status === "IN_LAUNDRY") return; try { setActionLoading(true); - await safeMarkWorn(uid, itemId); - } catch (e: any) { - console.log(e); - Alert.alert("Error", e?.message ?? "Failed to mark item as worn"); + await sendToLaundry(uid, itemId); + void runHaptic("light"); + Toast.laundryUpdated("Piece moved to laundry."); + } catch (e: unknown) { + const message = e instanceof Error ? e.message : "Failed to send item to laundry"; + if (__DEV__) { + console.log(e); + } + Toast.error("Laundry update failed", message); } finally { setActionLoading(false); } } - async function onSendToLaundry() { + async function onMarkNeedsWash() { if (!uid || !itemId) return router.replace("/(auth)/login"); + try { + setActionLoading(true); + await markNeedsWash(uid, itemId); + void runHaptic("light"); + Toast.laundryUpdated("Piece marked needs wash."); + } catch (e: unknown) { + const message = e instanceof Error ? e.message : "Failed to mark item as needs wash"; + if (__DEV__) { + console.log(e); + } + Toast.error("Laundry update failed", message); + } finally { + setActionLoading(false); + } + } + async function onMarkWornToday() { + if (!uid || !itemId) return router.replace("/(auth)/login"); + if (actionLoading) return; try { setActionLoading(true); - await sendToLaundry(uid, itemId); - } catch (e: any) { - console.log(e); - Alert.alert("Error", e?.message ?? "Failed to send item to laundry"); + const result = await markItemWorn({ + uid, + source: "item_detail", + itemId, + }); + void runHaptic("light"); + Toast.success( + result.alreadyMarked ? "Already marked worn today" : "Marked as worn today", + result.alreadyMarked ? "This item was not double-counted." : undefined, + ); + } catch (e: unknown) { + const message = e instanceof Error ? e.message : "Unable to mark this item worn."; + Toast.error("Couldn't mark worn. Try again.", message); } finally { setActionLoading(false); } @@ -281,9 +1100,14 @@ export default function ItemDetailsScreen() { try { setActionLoading(true); await markWashedItem(uid, itemId); - } catch (e: any) { - console.log(e); - Alert.alert("Error", e?.message ?? "Failed to mark item as washed"); + void runHaptic("light"); + Toast.laundryUpdated("Piece is clean and ready."); + } catch (e: unknown) { + const message = e instanceof Error ? e.message : "Failed to mark item as washed"; + if (__DEV__) { + console.log(e); + } + Toast.error("Laundry update failed", message); } finally { setActionLoading(false); } @@ -292,564 +1116,537 @@ export default function ItemDetailsScreen() { ]); } + function onLaundryStatusPress(status: LaundryStatus) { + if (!item || normalizeLaundryStatus(item) === status) return; + if (status === "clean") { + onConfirmWashed(); + } else if (status === "in_laundry") { + void onSendToLaundry(); + } else { + void onMarkNeedsWash(); + } + } + async function onDelete() { if (!uid || !itemId) return router.replace("/(auth)/login"); - Alert.alert( - "Delete item?", - "This will remove the item from your wardrobe.", - [ - { text: "Cancel", style: "cancel" }, - { - text: "Delete", - style: "destructive", - onPress: async () => { - try { - await deleteDoc(doc(db, "users", uid, "items", itemId)); - router.back(); - } catch (e: any) { + Alert.alert("Delete item?", "This will remove the item from your wardrobe.", [ + { text: "Cancel", style: "cancel" }, + { + text: "Delete", + style: "destructive", + onPress: async () => { + try { + const cleanup = await deleteWardrobeItem(uid, itemId, item); + if (cleanup.failed > 0) { + Toast.success("Item deleted", "Some image cleanup could not finish."); + } + navigateBackToSource(); + } catch (e: unknown) { + const message = e instanceof Error ? e.message : "Failed to delete"; + if (__DEV__) { console.log(e); - Alert.alert("Error", e?.message ?? "Failed to delete"); } - }, + Alert.alert("Error", message); + } }, - ] - ); + }, + ]); } function onEdit() { + if (!itemId) return; router.push({ pathname: "/(tabs)/add", - params: { editId: itemId }, + params: { + editId: itemId, + sourceItemId: itemId, + sourceRoute: "/(tabs)/item/[id]", + }, }); } function onOpenOverflowMenu() { - Alert.alert("Item actions", undefined, [ - { text: "Edit", onPress: onEdit }, - { text: "Delete", style: "destructive", onPress: onDelete }, - { text: "Cancel", style: "cancel" }, - ]); + void runHaptic("selection"); + setActionMenuVisible(true); } - async function onSelectColor(color: string) { - if (!uid || !itemId) return router.replace("/(auth)/login"); - try { - setColorSaving(true); - await updateDoc(doc(db, "users", uid, "items", itemId), { - colors: [color], - colorLabel: toTitleCase(color), - primaryColor: toTitleCase(color), - colorSource: "user", - colorUpdatedAt: Date.now(), - colorNeedsReview: false, - colorConfidence: 1, - }); - setColorSavedAt(Date.now()); - } catch (e: any) { - console.log(e); - Alert.alert("Error", e?.message ?? "Failed to save color"); - } finally { - setColorSaving(false); - } + function onStyleWithAura(currentItem: ItemDetails) { + router.push({ + pathname: "/(tabs)/ai", + params: { + prompt: auraPromptForItem(currentItem), + requiredItemIds: currentItem.id, + promptKey: `item-${currentItem.id}-${Date.now()}`, + }, + }); } - async function onResetToAI() { - if (!uid || !itemId) return router.replace("/(auth)/login"); - const currentIngestionStatus = ingestionStatusLabel(item); - if (currentIngestionStatus === "processing" || currentIngestionStatus === "done") { - Alert.alert("AI already complete", "Ingestion is already active or completed for this item."); + function onAddToOutfit() { + if (!itemId) return; + router.push({ + pathname: "/(tabs)/studio", + params: { + itemId, + sourceRoute: "/(tabs)/item/[id]", + }, + }); + } + + async function onOpenProductUrl(url: string) { + if (!isValidHttpUrl(url)) { + Toast.error("Link unavailable", "This product link is not valid."); return; } try { - setColorSaving(true); - await updateDoc(doc(db, "users", uid, "items", itemId), { - colorSource: "ai", - colorUpdatedAt: Date.now(), - "ingestion.status": "pending", - "ingestion.lastProcessedPhotoHash": deleteField(), - }); - setColorSavedAt(Date.now()); - } catch (e: any) { - console.log(e); - Alert.alert("Error", e?.message ?? "Failed to reset AI color"); - } finally { - setColorSaving(false); + await Linking.openURL(url); + } catch (e: unknown) { + const message = e instanceof Error ? e.message : "Could not open that product link."; + Toast.error("Could not open link", message); } } - async function saveField(field: "pattern" | "material", value: string | null) { - if (!uid || !itemId) return router.replace("/(auth)/login"); + async function onShareItem(currentItem: ItemDetails) { + const message = shareMessageForItem(currentItem, productUrl); try { - await updateDoc(doc(db, "users", uid, "items", itemId), { - [field]: value, + void runHaptic("light"); + await Share.share({ + title: itemTitle(currentItem), + message, + ...(productUrl ? { url: productUrl } : {}), }); - } catch (e: any) { - console.log(e); - Alert.alert("Error", e?.message ?? `Failed to update ${field}`); + } catch (e: unknown) { + const errorMessage = e instanceof Error ? e.message : "Could not share this item."; + Toast.error("Share failed", errorMessage); } } + const renderContent = () => { + if (loading) { + return ; + } + + if (!item) { + return ( + + Item not found + + This closet item may have been removed. + + + ); + } + + const status = normalizeLaundryStatus(item); + const detailGroups = buildDetailGroups(item); + const usageRows = buildUsageRows(item, status); + const productSourceLabel = productUrl ? sourceLabelForItem(item) || domainFromUrl(productUrl) : ""; + + return ( + <> + + setDetailImageOpen(true)} + /> + + + + onStyleWithAura(item)} + accessibilityLabel="Style this item with AURA" + style={{ minHeight: BUTTON_HEIGHT }} + /> + + + + void onShareItem(item)} + accessibilityLabel="Share this item" + style={{ flex: 1 }} + /> + + + + {usageRows.length ? ( + + + + ) : null} + + {detailGroups.length ? ( + + {detailGroups.map((group) => ( + + ))} + + ) : null} + + {productUrl ? ( + + + Product link + + void onOpenProductUrl(productUrl)} + haptic="light" + hapticTrigger="press" + pressedScale={0.98} + accessibilityRole="button" + accessibilityLabel="View original product" + style={styles.productSourceButton} + > + + + Original source + + + {productSourceLabel || "View original product"} + + + + + + ) : null} + + ); + }; + return ( - + + setDetailImageOpen(false)} /> + setActionMenuVisible(false)} + onAddToOutfit={() => { + setActionMenuVisible(false); + setTimeout(onAddToOutfit, 120); + }} + onMarkWorn={() => { + setActionMenuVisible(false); + setTimeout(() => { + void onMarkWornToday(); + }, 120); + }} + onLaundryStatusPress={(status) => { + setActionMenuVisible(false); + setTimeout(() => { + onLaundryStatusPress(status); + }, 120); + }} + onDelete={() => { + setActionMenuVisible(false); + setTimeout(() => { + void onDelete(); + }, 160); + }} + /> + + + + 0 ? footerHeight + 24 : layout.bottomDockPadding + 96, - gap: 16, + paddingTop: 8, + paddingBottom: layout.bottomDockPadding + 72, + gap: 24, }} > - - router.back()} - style={[pill, { backgroundColor: colors.surface, borderColor: colors.border }]} - > - Back - - Item - - - + {renderContent()} + + + ); +} + +function ActionMenuModal({ + visible, + currentLaundryStatus, + actionLoading, + onClose, + onAddToOutfit, + onMarkWorn, + onLaundryStatusPress, + onDelete, +}: { + visible: boolean; + currentLaundryStatus: LaundryStatus; + actionLoading: boolean; + onClose: () => void; + onAddToOutfit: () => void; + onMarkWorn: () => void; + onLaundryStatusPress: (status: LaundryStatus) => void; + onDelete: () => void; +}) { + const { colors } = useAppTheme(); + const styles = useMemo(() => createStyles(colors), [colors]); + const { width } = useWindowDimensions(); + const insets = useSafeAreaInsets(); + const menuWidth = Math.min(width - 32, 420); + + function closeWithHaptic() { + void runHaptic("selection"); + onClose(); + } + + function renderMenuRow({ + rowKey, + icon, + label, + onPress, + destructive = false, + disabled = false, + selected = false, + quiet = false, + accessory = "chevron-forward", + accessibilityLabel, + }: { + rowKey?: React.Key; + icon: keyof typeof Ionicons.glyphMap; + label: string; + onPress: () => void; + destructive?: boolean; + disabled?: boolean; + selected?: boolean; + quiet?: boolean; + accessory?: keyof typeof Ionicons.glyphMap | null; + accessibilityLabel?: string; + }) { + const tone = destructive ? "destructive" : selected ? "accent" : "primary"; + const iconColor = destructive + ? colors.destructive + : selected + ? colors.accent + : quiet + ? colors.textMuted + : colors.textSecondary; + + return ( + { + void runHaptic(destructive ? "warning" : "light"); + onPress(); + }} + disabled={disabled} + haptic="selection" + hapticTrigger="press" + pressedScale={0.98} + accessibilityRole="button" + accessibilityLabel={accessibilityLabel ?? label} + accessibilityState={{ selected, disabled }} + style={[ + styles.actionMenuButton, + quiet && !selected && !destructive + ? { + backgroundColor: "transparent", + borderColor: colors.borderSoft, + } + : null, + selected + ? { + backgroundColor: colors.accentSoft, + borderColor: colors.borderStrong, + } + : null, + destructive + ? { + backgroundColor: colors.dangerSurface, + borderColor: colors.dangerBorder, + } + : null, + ]} + > + + + + {label} + + {accessory ? ( + + ) : null} + + ); + } - {loading ? ( - Loading… - ) : !item ? ( - Item not found. - ) : ( - <> - - {itemImageUri ? ( - setDetailImageOpen(true)} - style={{ width: "100%", height: 216, borderRadius: 18 }} - > - - - - - ) : ( - - No photo - + return ( + + + + + + + + + + Styling actions + + {renderMenuRow({ + rowKey: "add-to-outfit", + icon: "shirt-outline", + label: "Add to outfit", + onPress: onAddToOutfit, + quiet: true, + })} + {renderMenuRow({ + rowKey: "mark-worn-today", + icon: "checkmark-circle-outline", + label: "Mark worn today", + onPress: onMarkWorn, + disabled: actionLoading, + })} + + + + + + + Laundry + + {STATUS_OPTIONS.map((status) => + renderMenuRow({ + rowKey: `laundry-${status}`, + icon: + status === "clean" + ? "sparkles-outline" + : status === "in_laundry" + ? "water-outline" + : "alert-circle-outline", + label: LAUNDRY_STATUS_LABELS[status], + onPress: () => onLaundryStatusPress(status), + selected: currentLaundryStatus === status, + disabled: actionLoading || currentLaundryStatus === status, + accessory: currentLaundryStatus === status ? "checkmark" : null, + accessibilityLabel: `Set laundry status to ${LAUNDRY_STATUS_LABELS[status]}`, + }), )} + - - - {item.name || `${item.primaryColor ?? ""} ${item.category}`} - - {item.brand ? ( - {item.brand} - ) : null} - {item.colorLabel || item.colors?.length ? ( - - {item.colorLabel || item.colors?.join(" / ") || "—"} - - ) : null} - - - - - - {quickFacts.map((fact) => ( - - ))} - - {typeof item.priceAmount === "number" || typeof item.price === "number" ? ( - - ) : null} - {item.purchaseDate ? ( - - ) : null} - - {item.notes ? ( - - - Notes - - {item.notes} - - ) : null} - + - - - {aiFacts.map((fact) => ( - - ))} - - {ingestionStatusLabel(item) === "failed" ? ( - - Couldn't analyze, you can edit manually. - - ) : null} - {item.colorNeedsReview && item.colorSource !== "user" ? ( - - - Color check: AI said {item.aiColorLabel || "—"}, pixels suggest{" "} - {toTitleCase(item.pixelColors?.[0] || "—")}. Confirm a color below if needed. - - - ) : null} - + {renderMenuRow({ + rowKey: "delete-item", + icon: "trash-outline", + label: "Delete item", + onPress: onDelete, + destructive: true, + accessory: null, + })} - - - - Pattern - - { - setPatternDraft(""); - void saveField("pattern", null); - }} - style={pill} - > - Auto (AI) - - void saveField("pattern", patternDraft.trim() || null)} - placeholder={item.pattern || "Auto (AI)"} - style={[textInput, { flex: 1 }]} - /> - - - - Material - - { - setMaterialDraft(""); - void saveField("material", null); - }} - style={pill} - > - Auto (AI) - - void saveField("material", materialDraft.trim() || null)} - placeholder={item.material || "Auto (AI)"} - style={[textInput, { flex: 1 }]} - /> - - - - Color correction - - Detected: {item.colorLabel || item.colors?.join(" / ") || "—"}. Tap to correct: - - - {ALLOWED_COLORS.map((color) => { - const isSelected = (item.colors?.[0] || "").toLowerCase() === color; - return ( - onSelectColor(color)} - disabled={colorSaving} - style={{ - paddingVertical: 7, - paddingHorizontal: 11, - borderRadius: 999, - borderWidth: 1, - borderColor: isSelected ? colors.text : "rgba(255,255,255,0.12)", - backgroundColor: isSelected ? colors.text : "rgba(255,255,255,0.03)", - opacity: colorSaving ? 0.65 : 1, - }} - > - - {toTitleCase(color)} - - - ); - })} - - - {item.colorSource === "user" ? ( - - Reset to AI - - ) : null} - {colorSavedAt ? ( - Saved - ) : null} - - - - - - )} - - {!loading && item ? ( - { - const nextHeight = Math.round(event.nativeEvent.layout.height); - if (nextHeight !== footerHeight) { - setFooterHeight(nextHeight); - } - }} - > - {item.status === "IN_LAUNDRY" ? ( - - Mark as Washed - - ) : ( - <> - - Mark as Worn - - - - - Send to Laundry - - - Mark as Washed - - - - )} - - ) : null} - + + Cancel + + + + + + ); } -const card = { - borderWidth: 1, - borderColor: "#eee", - borderRadius: 16, - padding: 12, - backgroundColor: "#fff", -} as const; - -const btn = { - flex: 1, - paddingVertical: 14, - borderRadius: 14, - alignItems: "center", - justifyContent: "center", -} as const; - -const btnText = { - fontWeight: "900", - fontSize: 16, -} as const; - -const pill = { - paddingVertical: 8, - paddingHorizontal: 12, - borderRadius: 999, - borderWidth: 1, - borderColor: "#ddd", -} as const; - -const pillText = { - fontWeight: "900", -} as const; - -const textInput = { - borderWidth: 1, - borderColor: "#ddd", - borderRadius: 12, - paddingHorizontal: 12, - paddingVertical: 10, - fontSize: 16, -} as const; - -function ItemImageModal(props: { +function ItemImageModal({ + visible, + images, + initialIndex, + onClose, +}: { visible: boolean; - uri: string | null; + images: DetailImageAsset[]; + initialIndex: number; onClose: () => void; }) { - const { visible, uri, onClose } = props; + const { colors } = useAppTheme(); + const styles = useMemo(() => createStyles(colors), [colors]); const insets = useSafeAreaInsets(); + const [activeIndex, setActiveIndex] = useState(initialIndex); + const { width } = useWindowDimensions(); + + useEffect(() => { + if (!visible) return; + setActiveIndex(initialIndex); + }, [initialIndex, visible]); + + const imageWidth = Math.max(width - 32, 1); + + function onMomentumScrollEnd(event: NativeSyntheticEvent) { + const nextIndex = Math.round(event.nativeEvent.contentOffset.x / imageWidth); + setActiveIndex(Math.max(0, Math.min(images.length - 1, nextIndex))); + } return ( - - - Photo - + + Photo - Close + Close - {uri ? ( - 0 ? ( + - + {images.map((image) => ( + + + + + + ))} + + ) : null} + {images.length > 1 ? ( + + {images.map((image, index) => ( + + ))} + + {activeIndex + 1} / {images.length} + ) : null} @@ -884,3 +1709,106 @@ function ItemImageModal(props: { ); } + +function DetailImageCarousel({ + images, + activeIndex, + onIndexChange, + onPressImage, + overlay, +}: { + images: DetailImageAsset[]; + activeIndex: number; + onIndexChange: (index: number) => void; + onPressImage: () => void; + overlay?: React.ReactNode; +}) { + const layout = useResponsiveLayout(); + const { colors } = useAppTheme(); + const styles = useMemo(() => createStyles(colors), [colors]); + const { width, height } = useWindowDimensions(); + const cardWidth = Math.max(width - layout.horizontalPadding * 2 - 12, 1); + const cardHeight = Math.min(480, Math.max(320, Math.round(height * 0.5))); + const cardGap = 14; + const pageWidth = cardWidth + cardGap; + + function onMomentumScrollEnd(event: NativeSyntheticEvent) { + const nextIndex = Math.round(event.nativeEvent.contentOffset.x / pageWidth); + onIndexChange(Math.max(0, Math.min(images.length - 1, nextIndex))); + } + + return ( + + + {images.map((image, index) => ( + + + + {overlay} + + + ))} + + {images.length > 1 ? ( + + + {images.map((image, index) => ( + + ))} + + + + {activeIndex + 1} / {images.length} + + + + ) : null} + + ); +} diff --git a/app/(tabs)/laundry.tsx b/app/(tabs)/laundry.tsx index 046fa64..29049f0 100644 --- a/app/(tabs)/laundry.tsx +++ b/app/(tabs)/laundry.tsx @@ -1,518 +1,596 @@ -import { - collection, - doc, - onSnapshot, - orderBy, - query, - updateDoc, -} from "firebase/firestore"; -import React, { useEffect, useMemo, useState } from "react"; -import { - ActivityIndicator, - Alert, - FlatList, - Pressable, - Text, - View, -} from "react-native"; +import { Ionicons } from "@expo/vector-icons"; import { router } from "expo-router"; +import React, { useCallback, useEffect, useMemo, useState } from "react"; +import { ActivityIndicator, Alert, FlatList, Pressable, Text, View } from "react-native"; + +import AuraPressable from "@/src/components/aura/AuraPressable"; +import { AuraAnimatedListItem, AuraAnimatedSection } from "@/src/components/motion"; +import AuraSubpageHeader from "@/src/components/ui/AuraSubpageHeader"; +import { auraButtonStyle, auraButtonTextStyle, auraSurfaceTiers, auraTypography } from "@/src/components/ui/auraStylePrimitives"; +import AppImage from "@/src/components/common/AppImage"; import { useAuth } from "@/src/hooks/useAuth"; import { useAppTheme } from "@/src/hooks/useAppTheme"; import { useResponsiveLayout } from "@/src/hooks/useResponsiveLayout"; -import { db } from "@/src/lib/firebase"; -import { isVisibleWardrobeItem } from "@/src/lib/items"; - -type Status = "AVAILABLE" | "WORN" | "IN_LAUNDRY"; - -type ClothingItem = { - id: string; - category: string; - brand: string; - primaryColor: string; - status: Status; - wearCountSinceWash: number; - createdAt: number; - lastWashedDate?: number; +import { runHaptic } from "@/src/lib/haptics"; +import { getBestThumbnailImageSource } from "@/src/lib/itemImage"; +import { + listenToItems, + normalizeLaundryStatus, + updateLaundryStatuses, + updateLaundryStatus, +} from "@/src/lib/items"; +import { sanitizeDisplayText } from "@/src/lib/text"; +import { Toast } from "@/src/lib/toast"; +import type { ClosetItem, LaundryStatus } from "@/src/lib/items"; +import { parseDateValue } from "@/src/utils/date"; + +type LaundryTab = LaundryStatus; +type LaundryUndo = { + key: string; + label: string; + itemIds: string[]; + previousStatus: LaundryStatus; }; -const TABS: { key: "NEEDS_WASH" | "IN_LAUNDRY" | "CLEAN"; label: string }[] = [ - { key: "NEEDS_WASH", label: "Needs wash" }, - { key: "IN_LAUNDRY", label: "In laundry" }, - { key: "CLEAN", label: "Clean" }, +const TABS: { key: LaundryTab; label: string; helper: string; icon: keyof typeof Ionicons.glyphMap }[] = [ + { key: "needs_wash", label: "Needs wash", helper: "Ready for care", icon: "alert-circle-outline" }, + { key: "in_laundry", label: "In laundry", helper: "Out of rotation", icon: "shirt-outline" }, + { key: "clean", label: "Clean", helper: "Ready to wear", icon: "checkmark-circle-outline" }, ]; +function itemTitle(item: ClosetItem) { + return ( + sanitizeDisplayText(item.name) || + [sanitizeDisplayText(item.primaryColor), sanitizeDisplayText(item.subCategory || item.category)].filter(Boolean).join(" ") || + "Wardrobe item" + ); +} + +function statusSentence(status: LaundryStatus) { + if (status === "clean") return "clean and ready"; + if (status === "in_laundry") return "in laundry"; + return "needs wash"; +} + +function formatRelativeDate(value: unknown) { + const date = parseDateValue(value); + if (!date) return ""; + const today = new Date(); + const startToday = new Date(today.getFullYear(), today.getMonth(), today.getDate()).getTime(); + const startDate = new Date(date.getFullYear(), date.getMonth(), date.getDate()).getTime(); + const dayDelta = Math.round((startToday - startDate) / 86400000); + if (dayDelta === 0) return "today"; + if (dayDelta === 1) return "yesterday"; + if (dayDelta > 1 && dayDelta < 7) return `${dayDelta}d ago`; + return new Intl.DateTimeFormat(undefined, { month: "short", day: "numeric" }).format(date); +} + +function laundryMeta(item: ClosetItem, activeStatus: LaundryStatus) { + const wears = Number(item.wearCountSinceWash ?? 0); + const lastWorn = formatRelativeDate(item.lastWornAt ?? item.lastWornDate); + const lastWashed = formatRelativeDate(item.lastWashedAt ?? item.lastWashedDate); + const lastMoved = formatRelativeDate(item.laundryUpdatedAt); + + if (activeStatus === "clean") { + return lastWashed ? `Washed ${lastWashed}` : "Clean and ready"; + } + if (activeStatus === "in_laundry") { + return lastMoved ? `Moved to laundry ${lastMoved}` : "Out of outfit rotation"; + } + const wearLabel = `${wears} wear${wears === 1 ? "" : "s"} since wash`; + return lastWorn ? `${wearLabel} · worn ${lastWorn}` : wearLabel; +} + export default function LaundryScreen() { const { user } = useAuth(); const { colors } = useAppTheme(); const layout = useResponsiveLayout(); const uid = user?.uid ?? null; - const floatingTabSpace = layout.bottomDockPadding; - const [allItems, setAllItems] = useState([]); - const [tab, setTab] = useState<"NEEDS_WASH" | "IN_LAUNDRY" | "CLEAN">( - "IN_LAUNDRY" - ); + const [allItems, setAllItems] = useState([]); + const [tab, setTab] = useState("in_laundry"); const [loading, setLoading] = useState(true); + const [savingStatus, setSavingStatus] = useState(null); + const [undo, setUndo] = useState(null); useEffect(() => { if (!uid) { + setAllItems([]); setLoading(false); router.replace("/(auth)/login"); return; } - const itemsRef = collection(db, "users", uid, "items"); - const q = query(itemsRef, orderBy("createdAt", "desc")); - - const unsub = onSnapshot( - q, - (snap) => { - const all = snap.docs.map((d) => ({ - id: d.id, - ...(d.data() as any), - })).filter((item) => isVisibleWardrobeItem(item)) as ClothingItem[]; - setAllItems(all); + setAllItems([]); + setLoading(true); + return listenToItems( + uid, + (next) => { + setAllItems(next); setLoading(false); }, - () => setLoading(false) + { + status: "ALL", + sort: "NEWEST", + onError: () => setLoading(false), + } ); - - return () => unsub(); }, [uid]); - // Simple “needs wash” logic: worn items OR wearCountSinceWash >= 3 - // (tweak threshold later) - const needsWash = useMemo( - () => - allItems.filter( - (x) => x.status === "WORN" || (x.wearCountSinceWash ?? 0) >= 3 - ), - [allItems] - ); - - const inLaundry = useMemo( - () => allItems.filter((x) => x.status === "IN_LAUNDRY"), - [allItems] - ); - - const clean = useMemo( - () => allItems.filter((x) => x.status === "AVAILABLE"), - [allItems] - ); - - const listItems = useMemo(() => { - if (tab === "NEEDS_WASH") return needsWash; - if (tab === "IN_LAUNDRY") return inLaundry; - return clean; - }, [tab, needsWash, inLaundry, clean]); - - const counts = useMemo( - () => ({ - needsWash: needsWash.length, - inLaundry: inLaundry.length, - clean: clean.length, - }), - [needsWash.length, inLaundry.length, clean.length] - ); - - async function markAllWashed() { + const buckets = useMemo(() => { + const next: Record = { + needs_wash: [], + in_laundry: [], + clean: [], + }; + allItems.forEach((item) => { + next[normalizeLaundryStatus(item)].push(item); + }); + return next; + }, [allItems]); + + const listItems = buckets[tab]; + const canMoveNeedsWash = buckets.needs_wash.length > 0; + const canMarkLaundryClean = buckets.in_laundry.length > 0; + + const setItemLaundryStatus = useCallback(async (itemId: string, status: LaundryStatus) => { + if (!uid) return router.replace("/(auth)/login"); + const item = allItems.find((entry) => entry.id === itemId); + const previousStatus = normalizeLaundryStatus(item); + if (previousStatus === status) return; try { - if (!uid) return router.replace("/(auth)/login"); - - if (inLaundry.length === 0) { - Alert.alert("Nothing to wash", "Laundry is empty."); - return; - } - - await Promise.all( - inLaundry.map((it) => - updateDoc(doc(db, "users", uid, "items", it.id), { - status: "AVAILABLE", - wearCountSinceWash: 0, - lastWashedDate: Date.now(), - }) - ) + setSavingStatus(`${itemId}:${status}`); + await updateLaundryStatus(uid, itemId, status); + setUndo({ + key: `${itemId}:${previousStatus}:${Date.now()}`, + label: `${item ? itemTitle(item) : "Piece"} is now ${statusSentence(status)}.`, + itemIds: [itemId], + previousStatus, + }); + void runHaptic("light"); + Toast.laundryUpdated( + status === "clean" + ? "Piece is clean and ready." + : status === "in_laundry" + ? "Piece moved to laundry." + : "Piece marked needs wash.", ); - - Alert.alert("Done", "Marked all laundry items as washed."); - } catch (e: any) { - console.log(e); - Alert.alert("Error", e?.message ?? "Failed to mark items as washed"); + } catch (error: any) { + Toast.error("Laundry update failed", error?.message ?? "Could not update that item."); + } finally { + setSavingStatus(null); } - } + }, [allItems, uid]); - async function moveTabItemsToLaundry() { - // Optional: when on Needs wash tab, quickly move them to IN_LAUNDRY + const bulkUpdate = useCallback(async (from: LaundryTab, to: LaundryStatus) => { + if (!uid) return router.replace("/(auth)/login"); + const source = buckets[from]; + if (!source.length) return; try { - if (!uid) return router.replace("/(auth)/login"); - - if (tab !== "NEEDS_WASH") return; - - if (needsWash.length === 0) { - Alert.alert("All good", "No items currently need washing."); - return; - } - - await Promise.all( - needsWash.map((it) => - updateDoc(doc(db, "users", uid, "items", it.id), { - status: "IN_LAUNDRY", - }) - ) + setSavingStatus(`bulk:${from}:${to}`); + await updateLaundryStatuses(uid, source.map((item) => item.id), to); + if (to === "in_laundry") setTab("in_laundry"); + if (to === "clean") setTab("clean"); + setUndo({ + key: `bulk:${from}:${to}:${Date.now()}`, + label: `${source.length} piece${source.length === 1 ? "" : "s"} moved to ${statusSentence(to)}.`, + itemIds: source.map((item) => item.id), + previousStatus: from, + }); + void runHaptic("light"); + Toast.laundryUpdated( + to === "clean" ? "Batch marked clean." : "Batch moved to laundry.", ); - - Alert.alert("Added", "Moved items to In Laundry."); - setTab("IN_LAUNDRY"); - } catch (e: any) { - console.log(e); - Alert.alert("Error", e?.message ?? "Failed to move items"); + } catch (error: any) { + Toast.error("Laundry update failed", error?.message ?? "Could not update laundry."); + } finally { + setSavingStatus(null); } - } - - const Header = ( - - {/* Title */} - - Laundry - - Track what needs washing, what’s in progress, and what’s clean. - - - - {/* Summary cards */} - - - - - + }, [buckets, uid]); + + const confirmBulkUpdate = useCallback((from: LaundryTab, to: LaundryStatus) => { + const source = buckets[from]; + if (!source.length) return; + const preview = source.slice(0, 4).map((item) => `• ${itemTitle(item)}`).join("\n"); + const more = source.length > 4 ? `\n• +${source.length - 4} more` : ""; + Alert.alert( + `Update ${source.length} piece${source.length === 1 ? "" : "s"}?`, + `This will move everything from "${TABS.find((entry) => entry.key === from)?.label}" to "${TABS.find((entry) => entry.key === to)?.label}".\n\n${preview}${more}`, + [ + { text: "Cancel", style: "cancel" }, + { text: "Update", onPress: () => void bulkUpdate(from, to) }, + ], + ); + }, [buckets, bulkUpdate]); - {/* Quick actions */} - - - { + if (!uid || !undo || savingStatus) return; + try { + setSavingStatus(`undo:${undo.key}`); + await updateLaundryStatuses(uid, undo.itemIds, undo.previousStatus); + setTab(undo.previousStatus); + setUndo(null); + void runHaptic("light"); + Toast.laundryUpdated("Change undone."); + } catch (error: any) { + Toast.error("Undo failed", error?.message ?? "Could not restore those items."); + } finally { + setSavingStatus(null); + } + }, [savingStatus, uid, undo]); + + const renderLaundryItem = useCallback( + ({ item, index }: { item: ClosetItem; index: number }) => ( + + + + ), + [savingStatus, setItemLaundryStatus], + ); + + const Header = useMemo(() => ( + + + {TABS.map((status) => ( + setTab(status.key)} + /> + ))} - {/* Tabs */} - - {TABS.map((t) => { - const active = tab === t.key; - return ( - setTab(t.key)} - style={{ - flex: 1, - paddingVertical: 10, - borderRadius: 12, - alignItems: "center", - backgroundColor: active ? colors.accent : "transparent", - }} - > - - {t.label} - - - ); - })} + + + confirmBulkUpdate("needs_wash", "in_laundry")} + /> + confirmBulkUpdate("in_laundry", "clean")} + /> + + + {!canMoveNeedsWash && !canMarkLaundryClean + ? "No valid batch actions yet. Move pieces from Closet or tell AURA what you washed." + : "Batch actions only apply when that status has items."} + - {/* Section header */} - - - {tab === "NEEDS_WASH" - ? "Needs Wash" - : tab === "IN_LAUNDRY" - ? "In Laundry" - : "Clean"} + {undo ? ( + + + + {undo.label} + + + Undo + + + ) : null} + + + + {TABS.find((entry) => entry.key === tab)?.label} + + + {listItems.length} item{listItems.length === 1 ? "" : "s"} - {listItems.length} items - ); + ), [ + buckets, + canMarkLaundryClean, + canMoveNeedsWash, + colors, + listItems.length, + savingStatus, + tab, + confirmBulkUpdate, + undo, + undoLastLaundryChange, + ]); if (loading) { return ( - - - - Loading laundry… - + + + + + Loading laundry... + ); } return ( - + + item.id} - contentContainerStyle={{ paddingBottom: floatingTabSpace }} - ListHeaderComponent={Header} - ItemSeparatorComponent={() => } - ListEmptyComponent={ - { - if (tab === "IN_LAUNDRY") markAllWashed(); - else if (tab === "NEEDS_WASH") moveTabItemsToLaundry(); - else setTab("NEEDS_WASH"); - }} - /> + contentContainerStyle={{ + paddingHorizontal: layout.horizontalPadding, + paddingTop: 12, + paddingBottom: layout.bottomDockPadding + 28, + gap: 10, + }} + ListHeaderComponent={ + + {Header} + } - renderItem={({ item }) => ( - - - - {item.primaryColor} {item.category} - - - - - - Brand: {item.brand || "—"} - - - - - - - - )} + ListEmptyComponent={} + renderItem={renderLaundryItem} + removeClippedSubviews + initialNumToRender={10} + maxToRenderPerBatch={8} + updateCellsBatchingPeriod={40} + windowSize={7} + extraData={savingStatus} /> ); } -/* ---------- small UI components ---------- */ - -function SummaryCard({ - title, - value, - subtitle, -}: { - title: string; - value: number; - subtitle: string; +function StatusCard(props: { + active: boolean; + label: string; + helper: string; + icon: keyof typeof Ionicons.glyphMap; + count: number; + onPress: () => void; }) { const { colors } = useAppTheme(); return ( - - - {title} - - - {value} - - - {subtitle} - - + + + + {props.count} + + + {props.label} + + + {props.helper} + + + ); } -function PrimaryButton({ - title, - onPress, - disabled, -}: { - title: string; - onPress: () => void; +function ActionButton(props: { + label: string; + icon: keyof typeof Ionicons.glyphMap; disabled?: boolean; -}) { - const { colors } = useAppTheme(); - return ( - - {title} - - ); -} - -function GhostButton({ - title, - onPress, - disabled, -}: { - title: string; + primary?: boolean; onPress: () => void; - disabled?: boolean; }) { const { colors } = useAppTheme(); return ( - - {title} - + + + {props.label} + + ); } -function Pill({ text }: { text: string }) { +function PremiumEmptyState({ tab }: { tab: LaundryTab }) { const { colors } = useAppTheme(); + const copy = + tab === "needs_wash" + ? { + title: "Nothing needs a wash", + body: "When you mark an outfit worn, pieces will land here before laundry day.", + } + : tab === "in_laundry" + ? { + title: "No items in laundry", + body: "Move worn pieces here when they are actually out of rotation.", + } + : { + title: "No clean pieces yet", + body: "Mark laundry as washed or add closet items to build ready outfits.", + }; return ( - {text} + + {copy.title} + + {copy.body} + + + + router.push("/(tabs)/ai")} /> + router.push("/(tabs)/closet")} /> + ); } -function MiniStat({ label, value }: { label: string; value: string }) { +const LaundryRow = React.memo(function LaundryRow({ + item, + activeStatus, + disabled, + onStatus, +}: { + item: ClosetItem; + activeStatus: LaundryStatus; + disabled?: boolean; + onStatus: (itemId: string, status: LaundryStatus) => void; +}) { const { colors } = useAppTheme(); + const imageSource = getBestThumbnailImageSource(item); + const statusLabel = TABS.find((entry) => entry.key === activeStatus)?.label ?? "Clean"; + const meta = laundryMeta(item, activeStatus); return ( - - {label} - - {value} + + + {imageSource ? ( + + ) : ( + + )} + + router.push({ pathname: "/(tabs)/item/[id]", params: { id: item.id, sourceTab: "laundry", sourceRoute: "/(tabs)/laundry" } })} + style={{ flex: 1, gap: 3 }} + > + + {itemTitle(item)} + + + {[sanitizeDisplayText(item.brand), sanitizeDisplayText(item.primaryColor)].filter(Boolean).join(" · ") || "No brand"} + + {statusLabel} + + {meta} + + + + + onStatus(item.id, "needs_wash")} /> + onStatus(item.id, "in_laundry")} + /> + onStatus(item.id, "clean")} /> + ); -} +}); -function EmptyState({ - tab, - onPrimary, -}: { - tab: "NEEDS_WASH" | "IN_LAUNDRY" | "CLEAN"; - onPrimary: () => void; -}) { +function RowAction({ label, disabled, primary, onPress }: { label: string; disabled?: boolean; primary?: boolean; onPress: () => void }) { const { colors } = useAppTheme(); - const title = - tab === "IN_LAUNDRY" - ? "No items in laundry" - : tab === "NEEDS_WASH" - ? "Nothing needs washing" - : "No clean items here"; - - const subtitle = - tab === "IN_LAUNDRY" - ? "When you move items to laundry, they’ll show up here." - : tab === "NEEDS_WASH" - ? "Wear more items or adjust your wash threshold." - : "Once washed, items appear as clean."; - - const action = - tab === "IN_LAUNDRY" - ? "Refresh" - : tab === "NEEDS_WASH" - ? "Move to laundry" - : "Go to Needs wash"; - return ( - - {title} - {subtitle} - - - - - + + {label} + + ); } diff --git a/app/(tabs)/profile.tsx b/app/(tabs)/profile.tsx index 0211102..40d57f4 100644 --- a/app/(tabs)/profile.tsx +++ b/app/(tabs)/profile.tsx @@ -1,92 +1,607 @@ +import { LinearGradient } from "expo-linear-gradient"; import { router } from "expo-router"; -import React from "react"; -import { ActivityIndicator, ScrollView, Text, View } from "react-native"; +import React, { useEffect, useMemo, useState } from "react"; +import { Image, Pressable, RefreshControl, ScrollView, Text, View } from "react-native"; import { SafeScreen } from "@/src/components/SafeScreen"; +import AuraPressable from "@/src/components/aura/AuraPressable"; +import { LookDetailModal } from "@/src/components/profile/LookDetailModal"; +import { MyLookSkeleton, MyLookThumbnail } from "@/src/components/profile/MyLookThumbnail"; +import { AuraSkeleton, AuraSkeletonLine } from "@/src/components/ui/AuraSkeleton"; +import { + auraButtonStyle, + auraButtonTextStyle, + auraCardStyle, + AuraTopSafeAreaScrim, + auraTypography, +} from "@/src/components/ui/auraStylePrimitives"; import { useAppTheme } from "@/src/hooks/useAppTheme"; import { useResponsiveLayout } from "@/src/hooks/useResponsiveLayout"; +import { listenToItems, toCanonicalCategory, type ClosetItem } from "@/src/lib/items"; +import { subscribeFavouriteLooks, subscribeProfileFeedbackLooks, type ProfileLookRecord } from "@/src/lib/profileLooks"; +import { loadUserAccountProfile, loadUserProfilePreferences } from "@/src/lib/userProfile"; import { ProfileHubRow, useAccountProfileState, formatBodyFitSummary, formatClosetSummary, formatDefaultSizesSummary, - formatNotificationsSummary, formatStyleSummary, formatUnitsSummary, useProfilePreferencesState, } from "@/src/profile/screens"; +import type { UserProfilePreferences } from "@/src/types/UserProfilePreferences"; + +type StatCardProps = { + label: string; + value: string; + onPress: () => void; +}; + +const SETTINGS_GROUPS = [ + { + label: "ACCOUNT", + rows: [ + { title: "Account", route: "/profile/account", summary: "account" }, + ], + }, + { + label: "STYLE PROFILE", + rows: [ + { title: "Body & Fit", route: "/profile/body-fit", summary: "bodyFit" }, + { title: "Default Sizes", route: "/profile/default-sizes", summary: "defaultSizes" }, + { title: "Style Preferences", route: "/profile/style-preferences", summary: "style" }, + { title: "Closet Preferences", route: "/profile/closet-preferences", summary: "closet" }, + ], + }, + { + label: "SYSTEM", + rows: [ + { title: "Units & Region", route: "/profile/units-region", summary: "units" }, + ], + }, +] as const; + +function humanize(value?: string | null) { + if (!value) return ""; + return value + .replace(/[_-]+/g, " ") + .replace(/\s+/g, " ") + .trim() + .replace(/\b\w/g, (char) => char.toUpperCase()); +} + +function compactHumanize(value?: string | null) { + const text = humanize(value); + return text ? text.charAt(0).toUpperCase() + text.slice(1).toLowerCase() : ""; +} + +function getDisplayName(name?: string | null, email?: string | null, fallback?: string | null) { + return name || fallback || email?.split("@")[0] || "AURA member"; +} + +function getInitials(name: string) { + const parts = name.split(/\s+/).filter(Boolean); + const first = parts[0]?.[0] ?? "A"; + const second = parts.length > 1 ? parts[parts.length - 1]?.[0] : parts[0]?.[1]; + return `${first}${second ?? ""}`.toUpperCase(); +} + +function summarizeStyleIdentity(profile: UserProfilePreferences) { + const style = + profile.stylePreferences.preferredStyles?.[0] || + profile.styleAesthetics?.[0] || + profile.occasionPriority?.[0] || + "Personal style"; + const fit = profile.preferredFit || profile.fitPreferences.tops || "regular"; + return `${compactHumanize(style)} · ${compactHumanize(fit)} fit`; +} + +function summarizeAuraRead(profile: UserProfilePreferences) { + const descriptors = [ + ...(profile.stylePreferences.preferredStyles ?? []), + profile.preferredFit, + ...(profile.stylePreferences.favoriteColors ?? []), + ] + .filter(Boolean) + .slice(0, 3) + .map((entry) => compactHumanize(entry).toLowerCase()); + + if (descriptors.length >= 2) { + return `AURA reads your style as ${descriptors.join(", ")}.`; + } + return "AURA reads your style as clean, relaxed, and street-smart."; +} + +function valueCounts(values: string[]) { + return values.reduce((counts, value) => { + const key = value.trim(); + if (!key) return counts; + counts.set(key, (counts.get(key) ?? 0) + 1); + return counts; + }, new Map()); +} + +function mostCommon(values: string[]) { + const counts = valueCounts(values); + let winner = ""; + let winnerCount = 0; + counts.forEach((count, value) => { + if (count > winnerCount) { + winner = value; + winnerCount = count; + } + }); + return winner; +} + +function getItemColor(item: ClosetItem) { + return ( + item.primaryColor || + item.displayColor || + item.colorLabel || + item.aiColorLabel || + item.colors?.[0] || + item.displayColors?.[0] || + item.aiColors?.[0] || + "" + ); +} + +function buildProfileFacts(profile: UserProfilePreferences, items: ClosetItem[]) { + const styles = profile.stylePreferences.preferredStyles?.length + ? profile.stylePreferences.preferredStyles + : profile.styleAesthetics; + const styleSummary = styles?.length + ? styles.slice(0, 3).map(humanize).join(" · ") + : "Add a few style preferences to sharpen AURA's read."; + const fit = profile.preferredFit || profile.fitPreferences.tops || profile.fitPreferences.outerwear || ""; + const commonColor = mostCommon(items.map(getItemColor).filter(Boolean)); + const commonCategory = mostCommon(items.map((item) => toCanonicalCategory(item.category)).filter(Boolean)); + const favoriteColor = profile.stylePreferences.favoriteColors?.[0] || profile.favoriteColors?.[0] || ""; + const colorOrCategory = commonColor || favoriteColor || commonCategory; + const relaxed = fit === "relaxed" || fit === "oversized" || profile.fitPreferences.outerwear === "roomy"; + const casual = styles?.some((entry) => /casual|street|minimal|sport|everyday/i.test(entry)); + const closetNote = relaxed || casual + ? "You lean relaxed and casual. Add one lighter layer or sharper shoe to expand your outfit range." + : "Your closet has a steady base. Add one contrast layer or signature accessory to widen the range."; + + return { + styleSummary, + fitSummary: fit ? `${humanize(fit)} fit` : "Fit preference not set", + colorOrCategory: colorOrCategory ? humanize(colorOrCategory) : "Not enough closet signal yet", + closetNote, + }; +} + +function isUnworn(item: ClosetItem) { + return Number(item.wearCountSinceWash ?? 0) === 0 && !item.lastWornDate; +} + +function StatCard({ label, value, onPress }: StatCardProps) { + const { colors } = useAppTheme(); + return ( + + {value} + {label} + + ); +} + +function SectionLabel({ children }: { children: React.ReactNode }) { + const { colors } = useAppTheme(); + return ( + + {children} + + ); +} + +function ProfileLoadingSkeleton() { + const layout = useResponsiveLayout(); + return ( + + + + {Array.from({ length: 4 }).map((_, index) => ( + + ))} + + + + + + {Array.from({ length: 3 }).map((_, index) => ( + + ))} + + + ); +} export default function ProfileScreen() { const { colors } = useAppTheme(); const layout = useResponsiveLayout(); - const { user, loading, profile } = useProfilePreferencesState(); - const { accountProfile } = useAccountProfileState(); + const { user, loading, profile, setProfile } = useProfilePreferencesState(); + const { accountProfile, setAccountProfile } = useAccountProfileState(); + const [favouriteLooks, setFavouriteLooks] = useState([]); + const [likedLooks, setLikedLooks] = useState([]); + const [looksLoading, setLooksLoading] = useState(true); + const [likedLooksLoading, setLikedLooksLoading] = useState(true); + const [items, setItems] = useState([]); + const [itemsLoading, setItemsLoading] = useState(true); + const [selectedLook, setSelectedLook] = useState(null); + const [refreshing, setRefreshing] = useState(false); + + const displayName = getDisplayName(accountProfile.name, user?.email, user?.displayName); const accountSummary = accountProfile.name ? `${accountProfile.name} • ${user?.email ?? "No email found."}` : user?.email ?? "No email found."; + const profileFacts = useMemo(() => buildProfileFacts(profile, items), [items, profile]); + + const stats = useMemo(() => { + const savedLookCount = likedLooks.length + favouriteLooks.length; + return [ + { label: "Closet items", value: itemsLoading ? "—" : String(items.length), onPress: () => router.push("/(tabs)/closet") }, + { label: "Saved looks", value: looksLoading || likedLooksLoading ? "—" : String(savedLookCount), onPress: () => router.push("/profile/my-looks") }, + { label: "Unworn", value: itemsLoading ? "—" : String(items.filter(isUnworn).length), onPress: () => router.push("/(tabs)/closet") }, + { + label: "Favourites", + value: looksLoading ? "—" : String(favouriteLooks.length), + onPress: () => router.push({ pathname: "/profile/my-looks", params: { tab: "favourites" } }), + }, + ]; + }, [favouriteLooks.length, items, itemsLoading, likedLooks.length, likedLooksLoading, looksLoading]); + + const summaries = useMemo( + () => ({ + account: accountSummary, + bodyFit: formatBodyFitSummary(profile), + defaultSizes: formatDefaultSizesSummary(profile), + style: formatStyleSummary(profile), + closet: formatClosetSummary(profile), + units: formatUnitsSummary(profile), + }), + [accountSummary, profile], + ); + + useEffect(() => { + if (!user?.uid) { + setFavouriteLooks([]); + setLooksLoading(false); + return; + } + setLooksLoading(true); + return subscribeFavouriteLooks( + user.uid, + (records) => { + setFavouriteLooks(records); + setLooksLoading(false); + }, + () => { + setFavouriteLooks([]); + setLooksLoading(false); + }, + ); + }, [user?.uid]); + + useEffect(() => { + if (!user?.uid) { + setLikedLooks([]); + setLikedLooksLoading(false); + return; + } + setLikedLooksLoading(true); + return subscribeProfileFeedbackLooks( + user.uid, + "outfit_liked", + (records) => { + setLikedLooks(records); + setLikedLooksLoading(false); + }, + () => { + setLikedLooks([]); + setLikedLooksLoading(false); + }, + ); + }, [user?.uid]); + + useEffect(() => { + if (!user?.uid) { + setItems([]); + setItemsLoading(false); + return; + } + setItems([]); + setItemsLoading(true); + return listenToItems(user.uid, (nextItems) => { + setItems(nextItems); + setItemsLoading(false); + }, { + onError: () => { + setItems([]); + setItemsLoading(false); + }, + }); + }, [user?.uid]); + + const handleRefresh = React.useCallback(async () => { + if (!user?.uid || refreshing) return; + setRefreshing(true); + const startedAt = Date.now(); + try { + const [nextProfile, nextAccountProfile] = await Promise.all([ + loadUserProfilePreferences(user.uid), + loadUserAccountProfile(user.uid), + ]); + setProfile(nextProfile); + setAccountProfile(nextAccountProfile); + } catch { + // Live subscriptions keep the visible profile from going stale if a manual refresh fails. + } finally { + const remaining = Math.max(0, 450 - (Date.now() - startedAt)); + setTimeout(() => setRefreshing(false), remaining); + } + }, [refreshing, setAccountProfile, setProfile, user?.uid]); + + const openMyLooks = () => router.push("/profile/my-looks"); + const openFavourites = () => router.push({ pathname: "/profile/my-looks", params: { tab: "favourites" } }); + const previewLooks = favouriteLooks.slice(0, 5); return ( + + } contentContainerStyle={{ paddingHorizontal: layout.horizontalPadding, paddingTop: 0, - gap: layout.sectionGap - 6, - paddingBottom: layout.bottomDockPadding, + gap: 18, + paddingBottom: layout.bottomDockPadding + 48, }} > - - Profile - - Settings, sizing, and preferences. + + Profile + + Your style identity, closet signal, and preferences. {loading ? ( - - - + ) : ( <> - router.push("/profile/account")} - /> - router.push("/profile/body-fit")} - /> - router.push("/profile/default-sizes")} - /> - router.push("/profile/units-region")} - /> - router.push("/profile/style-preferences")} - /> - router.push("/profile/closet-preferences")} - /> - router.push("/profile/notifications")} - /> + + + + {user?.photoURL ? ( + + ) : ( + + {getInitials(displayName)} + + )} + + + + + + {displayName} + + + {user?.email ?? "No email found."} + + + router.push("/profile/account")} + style={{ + ...auraButtonStyle(colors, "tertiary", false, "compact"), + minHeight: 44, + paddingHorizontal: 12, + }} + > + Edit + + + + {summarizeStyleIdentity(profile)} + + + + + + {summarizeAuraRead(profile)} + + + + + + {stats.map((stat) => ( + + ))} + + + + AURA PROFILE + + + Style preferences + + {profileFacts.styleSummary} + + + + + Fit + {profileFacts.fitSummary} + + + Top signal + {profileFacts.colorOrCategory} + + + + {profileFacts.closetNote} + + router.push("/profile/style-preferences")} + style={{ + alignSelf: "flex-start", + ...auraButtonStyle(colors, "secondary", false, "compact"), + minHeight: 44, + paddingHorizontal: 14, + }} + > + + Edit style profile + + + + + + + + MY LOOKS + + See all → + + + + + {looksLoading ? ( + Array.from({ length: 4 }).map((_, index) => ( + + )) + ) : previewLooks.length ? ( + previewLooks.map((record) => ( + setSelectedLook(record)} + /> + )) + ) : ( + + + Save looks from AURA to build your style archive. + + + )} + + + + + {SETTINGS_GROUPS.map((group) => ( + + {group.label} + + {group.rows.map((row) => ( + router.push(row.route)} + /> + ))} + + + ))} + + + + AURA + )} + setSelectedLook(null)} + /> ); } diff --git a/app/(tabs)/studio.tsx b/app/(tabs)/studio.tsx new file mode 100644 index 0000000..b88173d --- /dev/null +++ b/app/(tabs)/studio.tsx @@ -0,0 +1,919 @@ +import { Ionicons } from "@expo/vector-icons"; +import * as Haptics from "expo-haptics"; +import { router, useLocalSearchParams } from "expo-router"; +import React, { useMemo, useState } from "react"; +import { + ActivityIndicator, + Alert, + FlatList, + Pressable, + ScrollView, + Text, + View, +} from "react-native"; + +import { AuraLookCard } from "@/src/components/aura/AuraLookCard"; +import { ClosetItemCard } from "@/src/components/closet/ClosetItemCard"; +import { AuraAnimatedListItem, AuraAnimatedSection } from "@/src/components/motion"; +import AuraSubpageHeader from "@/src/components/ui/AuraSubpageHeader"; +import { auraButtonStyle, auraButtonTextStyle, auraSurfaceTiers, auraTypography } from "@/src/components/ui/auraStylePrimitives"; +import { useAuth } from "@/src/hooks/useAuth"; +import { useAppTheme } from "@/src/hooks/useAppTheme"; +import { useResponsiveLayout } from "@/src/hooks/useResponsiveLayout"; +import { saveAuraLook } from "@/src/lib/auraLooks"; +import { getItemImageUrl } from "@/src/lib/itemImage"; +import { listenToItems, normalizeLaundryStatus, toCanonicalCategory } from "@/src/lib/items"; +import { Toast } from "@/src/lib/toast"; +import { planOutfitForToday } from "@/src/lib/wearOutfit"; +import type { ClothingItem } from "@/src/types/ClothingItem"; +import type { AuraLook, AuraLookPiece } from "@/src/types/aura"; +import { toDayKey } from "@/src/utils/date"; + +type StudioCategory = "top" | "bottom" | "footwear" | "outerwear" | "accessory" | "one_piece"; +type StudioFilter = "all" | StudioCategory; +type StudioIssue = { + key: string; + label: string; + severity: "info" | "warning"; +}; + +const CATEGORY_LIMITS: Record = { + outerwear: 2, + top: 2, + bottom: 1, + footwear: 1, + one_piece: 1, + accessory: 6, +}; + +const MAX_TOTAL_ITEMS = 8; + +const FILTERS: { + key: StudioFilter; + label: string; +}[] = [ + { key: "all", label: "All" }, + { key: "top", label: "Tops" }, + { key: "bottom", label: "Bottoms" }, + { key: "outerwear", label: "Outerwear" }, + { key: "footwear", label: "Shoes" }, + { key: "accessory", label: "Accessories" }, + { key: "one_piece", label: "One Piece" }, +]; + +function itemTitle(item?: ClothingItem | null) { + return ( + item?.name || + item?.subCategory || + item?.type || + item?.category || + "Wardrobe item" + ).trim(); +} + +function itemDescriptor(item: ClothingItem) { + return [ + itemTitle(item), + item.brand, + item.category, + item.subCategory, + item.primaryColor || item.colorLabel || item.displayColor, + ] + .map((part) => String(part ?? "").trim()) + .filter(Boolean) + .join(" · "); +} + +function categoryForItem(item: ClothingItem): StudioCategory { + const category = toCanonicalCategory(item.category); + if (category === "shoes") return "footwear"; + if (category === "outerwear") return "outerwear"; + if (category === "bottom") return "bottom"; + if (category === "accessory") return "accessory"; + if (category === "one_piece") return "one_piece"; + return "top"; +} + +function auraRoleForCategory(category: StudioCategory): AuraLookPiece["role"] { + if (category === "footwear") return "shoes"; + if (category === "accessory") return "accessory"; + if (category === "one_piece") return "top"; + return category; +} + +function categoryLabel(category: StudioCategory) { + if (category === "one_piece") return "one piece"; + if (category === "footwear") return "footwear"; + return category; +} + +function imageUrlForItem(item: ClothingItem) { + return getItemImageUrl(item, { variant: "thumb" }); +} + +function isCleanItem(item: ClothingItem) { + return normalizeLaundryStatus(item) === "clean"; +} + +function buildManualLook(selection: ClothingItem[]): AuraLook { + const pieces = selection.map((item) => { + const category = categoryForItem(item); + return { + role: auraRoleForCategory(category), + itemName: itemTitle(item), + source: "closet" as const, + itemId: item.id, + imageUrl: imageUrlForItem(item), + }; + }); + const names = pieces.map((piece) => piece.itemName).filter(Boolean); + + return { + lookTitle: names.length ? "Studio Build" : "Manual Outfit", + vibe: "closet-first studio build", + shortExplanation: names.length + ? "A manually built look from pieces you chose." + : "Choose pieces below to preview an outfit.", + stylingNote: names.length + ? `Manual Studio selection: ${names.join(", ")}.` + : "Start with any role and build from your closet.", + personalizationLabel: "Studio", + personalizationNote: "Built manually from closet items.", + pieces, + fromCloset: names, + addToComplete: [], + alternates: [], + actions: ["saveLook", "planForToday"], + }; +} + +function getStudioIssues(selection: ClothingItem[]): StudioIssue[] { + if (!selection.length) { + return [{ key: "empty", label: "Choose at least one closet item to start.", severity: "info" }]; + } + + const counts = selection.reduce>( + (acc, item) => { + acc[categoryForItem(item)] += 1; + return acc; + }, + { + top: 0, + bottom: 0, + footwear: 0, + outerwear: 0, + accessory: 0, + one_piece: 0, + }, + ); + const issues: StudioIssue[] = []; + const hasOnePiece = counts.one_piece > 0; + if (!hasOnePiece && counts.top === 0) { + issues.push({ key: "top", label: "Add a top or one-piece.", severity: "warning" }); + } + if (!hasOnePiece && counts.bottom === 0) { + issues.push({ key: "bottom", label: "Add a bottom or one-piece.", severity: "warning" }); + } + if (counts.footwear === 0) { + issues.push({ key: "footwear", label: "Add shoes before planning.", severity: "warning" }); + } + const unavailableCount = selection.filter((item) => !isCleanItem(item)).length; + if (unavailableCount > 0) { + issues.push({ + key: "laundry", + label: `${unavailableCount} selected piece${unavailableCount === 1 ? " is" : "s are"} not clean.`, + severity: "warning", + }); + } + if (!issues.length) { + issues.push({ key: "ready", label: "Complete outfit: ready to save, refine, or plan.", severity: "info" }); + } + return issues; +} + +function buildAuraPrompt(selection: ClothingItem[], issues: StudioIssue[]) { + const lines = selection.map((item) => { + const label = categoryLabel(categoryForItem(item)); + return `${label}: ${itemDescriptor(item)}; availability=${normalizeLaundryStatus(item)} (closet item id: ${item.id})`; + }); + const issueLines = issues + .filter((issue) => issue.key !== "ready") + .map((issue) => `- ${issue.label}`); + + return [ + "Improve this manually built outfit from my closet.", + "Return a concrete refinement with: what to keep, what to swap or add, and one styling note.", + "Keep the selected pieces as the starting point unless a piece is unavailable or the outfit is incomplete.", + issueLines.length ? "Current Studio checklist:" : "", + ...issueLines, + "", + "Selected pieces:", + ...lines, + ].join("\n"); +} + +const StudioPickerItem = React.memo(function StudioPickerItem({ + item, + selected, + cardWidth, + gridGap, + index, + onToggleItem, +}: { + item: ClothingItem; + selected: boolean; + cardWidth: number; + gridGap: number; + index: number; + onToggleItem: (item: ClothingItem) => void; +}) { + const { colors } = useAppTheme(); + return ( + + + {selected ? ( + + + + + + ) : null} + + ); +}); + +export default function StudioScreen() { + const params = useLocalSearchParams<{ + itemId?: string | string[]; + }>(); + const { user } = useAuth(); + const { colors } = useAppTheme(); + const layout = useResponsiveLayout(); + const uid = user?.uid ?? null; + const [items, setItems] = useState([]); + const [loading, setLoading] = useState(true); + const [activeFilter, setActiveFilter] = useState("all"); + const [cleanOnly, setCleanOnly] = useState(true); + const [selection, setSelection] = useState([]); + const [saving, setSaving] = useState(false); + const consumedRouteItemRef = React.useRef(null); + const routeItemId = useMemo(() => { + const raw = Array.isArray(params.itemId) ? params.itemId[0] : params.itemId; + return typeof raw === "string" && raw.trim() ? raw.trim() : ""; + }, [params.itemId]); + + React.useEffect(() => { + if (!uid) { + setItems([]); + setLoading(false); + router.replace("/(auth)/welcome"); + return; + } + setItems([]); + setLoading(true); + const unsub = listenToItems( + uid, + (next) => { + setItems(next as ClothingItem[]); + setLoading(false); + }, + { + status: "ALL", + sort: "NEWEST", + onError: (message) => { + setLoading(false); + Alert.alert("Firestore error", message); + }, + }, + ); + return () => unsub(); + }, [uid]); + + const itemsById = useMemo(() => new Map(items.map((item) => [item.id, item])), [items]); + const look = useMemo(() => buildManualLook(selection), [selection]); + const hasSelection = selection.length > 0; + const selectedIds = useMemo(() => new Set(selection.map((item) => item.id)), [selection]); + const hasOnePieceSelected = useMemo( + () => selection.some((item) => categoryForItem(item) === "one_piece"), + [selection], + ); + const selectedCounts = useMemo( + () => + selection.reduce>( + (acc, item) => { + const category = categoryForItem(item); + acc[category] += 1; + return acc; + }, + { + top: 0, + bottom: 0, + footwear: 0, + outerwear: 0, + accessory: 0, + one_piece: 0, + }, + ), + [selection], + ); + const studioIssues = useMemo(() => getStudioIssues(selection), [selection]); + const blockingIssues = useMemo( + () => studioIssues.filter((issue) => issue.severity === "warning"), + [studioIssues], + ); + const filteredItems = useMemo( + () => + items.filter((item) => { + const category = categoryForItem(item); + if (hasOnePieceSelected && (category === "top" || category === "bottom")) { + return false; + } + if (cleanOnly && !isCleanItem(item)) return false; + return activeFilter === "all" || category === activeFilter; + }), + [activeFilter, cleanOnly, hasOnePieceSelected, items], + ); + const gridGap = 12; + const availableWidth = layout.width - layout.horizontalPadding * 2; + const columns = layout.screenSize === "large" ? 4 : 2; + const cardWidth = Math.floor((availableWidth - gridGap * (columns - 1)) / columns); + const todayKey = useMemo(() => toDayKey(new Date()), []); + + React.useEffect(() => { + if (!routeItemId || loading || consumedRouteItemRef.current === routeItemId) return; + const routeItem = items.find((entry) => entry.id === routeItemId); + if (!routeItem) return; + consumedRouteItemRef.current = routeItemId; + setActiveFilter("all"); + setSelection((prev) => + prev.some((entry) => entry.id === routeItem.id) ? prev : [routeItem, ...prev], + ); + }, [items, loading, routeItemId]); + + const toggleItem = React.useCallback((item: ClothingItem) => { + void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); + const category = categoryForItem(item); + const isSelected = selectedIds.has(item.id); + + if (isSelected) { + setSelection((prev) => prev.filter((entry) => entry.id !== item.id)); + return; + } + + if (selection.length >= MAX_TOTAL_ITEMS) { + Toast.error("Maximum 8 items per outfit"); + return; + } + + if ((category === "top" || category === "bottom") && hasOnePieceSelected) { + Toast.error("One piece selected — tops and bottoms are hidden"); + return; + } + + if ( + category === "one_piece" && + (selectedCounts.top > 0 || selectedCounts.bottom > 0) + ) { + Toast.error("Remove top/bottom items first to add a one piece"); + return; + } + + if (selectedCounts[category] >= CATEGORY_LIMITS[category]) { + Toast.error(`Maximum ${CATEGORY_LIMITS[category]} ${categoryLabel(category)} item${CATEGORY_LIMITS[category] === 1 ? "" : "s"}`); + return; + } + + setSelection((prev) => [...prev, item]); + }, [hasOnePieceSelected, selectedCounts, selectedIds, selection.length]); + + const confirmIncompleteOutfit = React.useCallback( + (actionLabel: string) => + new Promise((resolve) => { + if (!blockingIssues.length) { + resolve(true); + return; + } + Alert.alert( + "Review this outfit?", + `${blockingIssues.map((issue) => `• ${issue.label}`).join("\n")}\n\nYou can still ${actionLabel.toLowerCase()}, but AURA may need to refine it first.`, + [ + { text: "Keep editing", style: "cancel", onPress: () => resolve(false) }, + { text: actionLabel, onPress: () => resolve(true) }, + ], + ); + }), + [blockingIssues], + ); + + const handleSaveLook = React.useCallback(async () => { + if (!uid || !hasSelection || saving) return; + const shouldContinue = await confirmIncompleteOutfit("Save anyway"); + if (!shouldContinue) return; + setSaving(true); + try { + await saveAuraLook(uid, look, { title: "Studio Build" }); + Toast.saved(); + Alert.alert("Look saved", "Studio Build is in My Looks.", [ + { text: "Keep building", style: "cancel" }, + { text: "View My Looks", onPress: () => router.push("/profile/my-looks") }, + ]); + } catch (error: any) { + if (__DEV__) { + console.error("SAVE LOOK UI ERROR:", error); + } + Toast.error("Save failed", "Couldn’t save this look. Please try again."); + } finally { + setSaving(false); + } + }, [confirmIncompleteOutfit, hasSelection, look, saving, uid]); + + const handlePlanLook = React.useCallback(async () => { + if (!uid || !hasSelection || saving) return; + const shouldContinue = await confirmIncompleteOutfit("Plan anyway"); + if (!shouldContinue) return; + setSaving(true); + try { + await planOutfitForToday({ + uid, + source: "studio", + title: "Studio Build", + look, + date: new Date(`${todayKey}T12:00:00`), + }); + Toast.success("Planned", "This outfit is now attached to today."); + Alert.alert("Planned for today", "This Studio build is on your calendar.", [ + { text: "Keep building", style: "cancel" }, + { text: "Open Calendar", onPress: () => router.push("/(tabs)/calendar") }, + ]); + } catch (error: any) { + Toast.error("Plan failed", error?.message ?? "Unable to plan this outfit."); + } finally { + setSaving(false); + } + }, [confirmIncompleteOutfit, hasSelection, look, saving, todayKey, uid]); + + const handleAskAura = React.useCallback(() => { + if (!hasSelection) return; + router.push({ + pathname: "/(tabs)/ai", + params: { + prompt: buildAuraPrompt(selection, studioIssues), + promptKey: `studio-${Date.now()}`, + }, + }); + }, [hasSelection, selection, studioIssues]); + + const clearSelection = React.useCallback(() => { + if (!selection.length) return; + void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); + setSelection([]); + }, [selection.length]); + + const removeSelectedItem = React.useCallback((itemId: string) => { + void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); + setSelection((prev) => prev.filter((entry) => entry.id !== itemId)); + }, []); + + const swapSelectedItem = React.useCallback((item: ClothingItem) => { + const category = categoryForItem(item); + void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); + setSelection((prev) => prev.filter((entry) => entry.id !== item.id)); + setActiveFilter(category); + Toast.success("Choose a swap", `Showing ${categoryLabel(category)} options.`); + }, []); + + const renderStudioItem = React.useCallback( + ({ item, index }: { item: ClothingItem; index: number }) => ( + + ), + [cardWidth, gridGap, selectedIds, toggleItem], + ); + + if (loading) { + return ( + + + + + Opening Studio… + + + ); + } + + return ( + + + item.id} + renderItem={renderStudioItem} + numColumns={columns} + columnWrapperStyle={columns > 1 ? { gap: gridGap } : undefined} + ListHeaderComponent={ + + + + + {selection.length} item{selection.length === 1 ? "" : "s"} selected + + + {hasSelection ? ( + ({ opacity: pressed ? 0.6 : 1 })} + > + + Clear all + + + ) : null} + + + {hasSelection ? ( + + {selection.map((item, index) => { + const status = normalizeLaundryStatus(item); + const unavailable = status !== "clean"; + return ( + + + + + + + + {itemTitle(item)} + + + {categoryLabel(categoryForItem(item))} · {status === "clean" ? "ready" : status.replace("_", " ")} + + + + + swapSelectedItem(item)} + style={({ pressed }) => ({ + flex: 1, + ...auraButtonStyle(colors, "tertiary", false), + minHeight: 34, + borderRadius: 999, + paddingHorizontal: 10, + opacity: pressed ? 0.78 : 1, + })} + > + Swap + + removeSelectedItem(item.id)} + hitSlop={8} + style={({ pressed }) => ({ + width: 34, + height: 34, + borderRadius: 17, + alignItems: "center", + justifyContent: "center", + backgroundColor: colors.chipBackground, + opacity: pressed ? 0.7 : 1, + })} + > + + + + + ); + })} + + ) : null} + + + + + ({ + flex: 1, + ...auraButtonStyle(colors, "primary", !hasSelection || saving), + borderRadius: layout.mediumRadius, + paddingVertical: 13, + paddingHorizontal: 12, + alignItems: "center", + opacity: !hasSelection || saving ? 0.48 : pressed ? 0.84 : 1, + })} + > + + Ask AURA to improve this + + + ({ + flex: 1, + ...auraButtonStyle(colors, "secondary", !hasSelection || saving), + borderRadius: layout.mediumRadius, + paddingVertical: 13, + paddingHorizontal: 12, + alignItems: "center", + opacity: !hasSelection || saving ? 0.48 : pressed ? 0.82 : 1, + })} + > + + Plan this outfit + + + + + + + + + Studio checklist + + + + {studioIssues.map((issue) => ( + + + + {issue.label} + + + ))} + + + + + + + {FILTERS.map((filter) => { + const active = activeFilter === filter.key; + return ( + setActiveFilter(filter.key)} + style={({ pressed }) => ({ + borderRadius: layout.pillRadius, + paddingHorizontal: 13, + paddingVertical: 9, + backgroundColor: active ? colors.purpleSurface : colors.chipBackground, + borderWidth: 1, + borderColor: active ? colors.purpleBorder : colors.border, + opacity: pressed ? 0.82 : 1, + })} + > + + {filter.label} + + + ); + })} + setCleanOnly((value) => !value)} + style={({ pressed }) => ({ + borderRadius: layout.pillRadius, + paddingHorizontal: 13, + paddingVertical: 9, + backgroundColor: cleanOnly ? colors.purpleSurface : colors.chipBackground, + borderWidth: 1, + borderColor: cleanOnly ? colors.purpleBorder : colors.border, + opacity: pressed ? 0.82 : 1, + })} + > + + Clean only + + + + + + + + + Choose wardrobe items + + + {filteredItems.length + ? `${filteredItems.length} closet items${cleanOnly ? " ready" : ""}` + : items.length + ? "No matching ready items" + : "No closet items yet"} + + + {hasSelection ? ( + ({ + ...auraButtonStyle(colors, "tertiary", saving), + borderRadius: layout.pillRadius, + paddingHorizontal: 12, + paddingVertical: 9, + opacity: saving ? 0.5 : pressed ? 0.82 : 1, + })} + > + Save as look + + ) : null} + + + + } + ListEmptyComponent={ + + Nothing here yet + + {items.length + ? cleanOnly + ? "Turn off Clean only or mark pieces washed in Laundry." + : "Add or recategorize closet items to make them available for this role." + : "Add your first closet items, then return to Studio to build a look."} + + {items.length ? ( + { + setCleanOnly(false); + setActiveFilter("all"); + }} + style={({ pressed }) => ({ + ...auraButtonStyle(colors, "secondary", false), + alignSelf: "flex-start", + borderRadius: layout.pillRadius, + paddingHorizontal: 13, + paddingVertical: 9, + opacity: pressed ? 0.82 : 1, + })} + > + + Show all items + + + ) : ( + router.push({ pathname: "/(tabs)/add", params: { addSession: String(Date.now()) } })} + style={({ pressed }) => ({ + ...auraButtonStyle(colors, "primary", false), + alignSelf: "flex-start", + borderRadius: layout.pillRadius, + paddingHorizontal: 13, + paddingVertical: 9, + opacity: pressed ? 0.82 : 1, + })} + > + + Add item + + + )} + + } + showsVerticalScrollIndicator={false} + keyboardShouldPersistTaps="handled" + removeClippedSubviews + initialNumToRender={8} + maxToRenderPerBatch={8} + updateCellsBatchingPeriod={40} + windowSize={7} + extraData={`${Array.from(selectedIds).join(",")}:${cleanOnly}:${activeFilter}`} + contentContainerStyle={{ + paddingTop: 12, + paddingHorizontal: layout.horizontalPadding, + paddingBottom: layout.bottomDockPadding + 112, + }} + /> + + ); +} diff --git a/app/(tabs)/today.tsx b/app/(tabs)/today.tsx index bd817fa..77d42fa 100644 --- a/app/(tabs)/today.tsx +++ b/app/(tabs)/today.tsx @@ -1 +1,361 @@ -export { default } from "./calendar"; +import { Ionicons } from "@expo/vector-icons"; +import { router } from "expo-router"; +import React, { useEffect, useMemo, useState } from "react"; +import { ActivityIndicator, ScrollView, StyleSheet, Text, View } from "react-native"; + +import AuraPressable from "@/src/components/aura/AuraPressable"; +import FlatLayCanvas from "@/src/components/outfit/FlatLayCanvas"; +import { SafeScreen } from "@/src/components/SafeScreen"; +import { useAuth } from "@/src/hooks/useAuth"; +import { useAppTheme } from "@/src/hooks/useAppTheme"; +import { useResponsiveLayout } from "@/src/hooks/useResponsiveLayout"; +import { listenToItems, type ClosetItem } from "@/src/lib/items"; +import { sanitizeDisplayText } from "@/src/lib/text"; +import { subscribeOutfitByDate, type DailyOutfitRecord } from "@/src/utils/dailyOutfits"; +import { formatHeaderDate, toDayKey } from "@/src/utils/date"; + +type SlotKey = "outerwear" | "top" | "bottom" | "shoes"; + +const SLOT_LABELS: Record = { + outerwear: "Outerwear", + top: "Top", + bottom: "Bottom", + shoes: "Shoes", +}; + +function itemLabel(item: ClosetItem | null, fallback: string) { + if (!item) return fallback; + return ( + sanitizeDisplayText(item.name) || + sanitizeDisplayText(item.subCategory) || + sanitizeDisplayText(item.category) || + fallback + ); +} + +export default function TodayScreen() { + const { user } = useAuth(); + const { colors } = useAppTheme(); + const layout = useResponsiveLayout(); + const styles = useMemo(() => createStyles(colors, layout), [colors, layout]); + const uid = user?.uid ?? null; + const today = useMemo(() => new Date(), []); + const todayKey = useMemo(() => toDayKey(today), [today]); + const [items, setItems] = useState([]); + const [record, setRecord] = useState(null); + const [loadingItems, setLoadingItems] = useState(true); + const [loadingOutfit, setLoadingOutfit] = useState(true); + + useEffect(() => { + if (!uid) { + setItems([]); + setLoadingItems(false); + router.replace("/(auth)/login"); + return; + } + + setLoadingItems(true); + return listenToItems( + uid, + (next) => { + setItems(next); + setLoadingItems(false); + }, + { + status: "ALL", + sort: "NEWEST", + onError: () => setLoadingItems(false), + }, + ); + }, [uid]); + + useEffect(() => { + if (!uid) { + setRecord(null); + setLoadingOutfit(false); + return; + } + + setLoadingOutfit(true); + return subscribeOutfitByDate( + uid, + todayKey, + (next) => { + setRecord(next); + setLoadingOutfit(false); + }, + () => { + setRecord(null); + setLoadingOutfit(false); + }, + ); + }, [todayKey, uid]); + + const itemsById = useMemo(() => new Map(items.map((item) => [item.id, item])), [items]); + const planned = record?.plannedOutfit ?? null; + const plannedItems = useMemo( + () => ({ + outerwear: planned?.itemsByCategory.outerwear ? itemsById.get(planned.itemsByCategory.outerwear) ?? null : null, + top: planned?.itemsByCategory.top ? itemsById.get(planned.itemsByCategory.top) ?? null : null, + bottom: planned?.itemsByCategory.bottom ? itemsById.get(planned.itemsByCategory.bottom) ?? null : null, + shoes: planned?.itemsByCategory.shoes ? itemsById.get(planned.itemsByCategory.shoes) ?? null : null, + }), + [itemsById, planned], + ); + const hasPlannedOutfit = Boolean(planned); + const loading = loadingItems || loadingOutfit; + + return ( + + + + Today + {formatHeaderDate(today)} + A focused view of what is planned for right now. + + + {loading ? ( + + + Loading today... + + ) : hasPlannedOutfit ? ( + + + + Planned outfit + Ready for today + + {typeof planned?.score === "number" ? ( + + {planned.score}% + + ) : null} + + + + + + {(Object.keys(SLOT_LABELS) as SlotKey[]).map((slot) => ( + + {SLOT_LABELS[slot]} + + {itemLabel(plannedItems[slot], "Not planned")} + + + ))} + + + {planned?.reasons?.length ? ( + + {planned.reasons.slice(0, 2).map((reason) => ( + + {reason} + + ))} + + ) : null} + + ) : ( + + + + + No outfit planned yet + Plan one from Calendar, or ask AURA to help you pull something together. + + )} + + + router.push("/(tabs)/calendar")} + /> + router.push("/(tabs)/ai")} + /> + + + + ); +} + +function TodayAction({ + icon, + label, + primary, + onPress, +}: { + icon: keyof typeof Ionicons.glyphMap; + label: string; + primary?: boolean; + onPress: () => void; +}) { + const { colors } = useAppTheme(); + return ( + + + + {label} + + + ); +} + +function createStyles( + colors: ReturnType["colors"], + layout: ReturnType, +) { + return StyleSheet.create({ + safeContent: { + paddingHorizontal: layout.horizontalPadding, + }, + content: { + gap: 16, + paddingBottom: 24, + }, + header: { + gap: 5, + paddingTop: 4, + }, + kicker: { + color: colors.lightPurple, + fontSize: 11, + fontWeight: "900", + letterSpacing: 1.5, + textTransform: "uppercase", + }, + title: { + color: colors.text, + fontSize: 30 * layout.titleScale, + lineHeight: 36 * layout.titleScale, + fontWeight: "900", + }, + subtitle: { + color: colors.textSecondary, + fontSize: 13, + lineHeight: 19, + }, + card: { + gap: 14, + borderRadius: layout.largeRadius, + borderWidth: 1, + borderColor: colors.border, + backgroundColor: colors.surfaceGlass, + padding: layout.cardPadding, + }, + cardHeader: { + flexDirection: "row", + alignItems: "center", + gap: 12, + }, + cardEyebrow: { + color: colors.lightPurple, + fontSize: 11, + fontWeight: "900", + letterSpacing: 1.2, + textTransform: "uppercase", + }, + cardTitle: { + color: colors.text, + fontSize: 20, + lineHeight: 25, + fontWeight: "900", + }, + scorePill: { + borderRadius: 999, + borderWidth: 1, + borderColor: colors.purpleBorder, + backgroundColor: colors.purpleSurface, + paddingHorizontal: 10, + paddingVertical: 6, + }, + scoreText: { + color: colors.lightPurple, + fontSize: 12, + fontWeight: "900", + }, + slotList: { + gap: 8, + }, + slotRow: { + flexDirection: "row", + alignItems: "center", + justifyContent: "space-between", + gap: 12, + borderRadius: 14, + borderWidth: 1, + borderColor: colors.border, + backgroundColor: colors.surfaceSoft, + paddingHorizontal: 12, + paddingVertical: 10, + }, + slotName: { + color: colors.textSecondary, + fontSize: 12, + fontWeight: "800", + }, + slotValue: { + flex: 1, + color: colors.text, + fontSize: 13, + fontWeight: "800", + textAlign: "right", + }, + reasonBlock: { + gap: 6, + }, + reasonText: { + color: colors.textSecondary, + fontSize: 12, + lineHeight: 18, + fontWeight: "600", + }, + muted: { + color: colors.textSecondary, + fontSize: 13, + lineHeight: 20, + }, + emptyIcon: { + width: 42, + height: 42, + borderRadius: 999, + alignItems: "center", + justifyContent: "center", + backgroundColor: colors.purpleSurface, + borderWidth: 1, + borderColor: colors.purpleBorder, + }, + actions: { + flexDirection: "row", + gap: 10, + }, + }); +} diff --git a/app/_layout.tsx b/app/_layout.tsx index f81506e..b39f383 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -1,88 +1,395 @@ -import { DarkTheme, DefaultTheme, ThemeProvider } from "@react-navigation/native"; +import { DarkTheme, ThemeProvider } from "@react-navigation/native"; import { router, Stack, useSegments } from "expo-router"; import { StatusBar } from "expo-status-bar"; -import "react-native-reanimated"; -import { useEffect } from "react"; -import { ActivityIndicator, Platform, Text, View } from "react-native"; +import { signOut } from "firebase/auth"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { Platform, Pressable, StyleSheet, Text, View } from "react-native"; +import Animated, { + Easing, + runOnJS, + useAnimatedStyle, + useReducedMotion, + useSharedValue, + withTiming, +} from "react-native-reanimated"; import { AuthProvider, useAuth } from "../src/contexts/AuthContext"; import { isVisionBackgroundRemovalAvailable } from "../src/bg/removeBackground"; +import { loadUserProfilePreferences } from "../src/lib/userProfile"; +import { getCachedProfilePreferences } from "../src/lib/localCache"; import { useColorScheme } from "@/hooks/use-color-scheme"; import { Colors } from "@/constants/theme"; import { SafeAreaProvider } from "react-native-safe-area-context"; +import AuraRing, { RING_SIZE_LG } from "@/src/components/brand/AuraRing"; +import BrandedLoadingAnimation from "@/src/components/brand/BrandedLoadingAnimation"; +import { configureGoogleSignIn } from "@/src/auth/googleAuth"; +import { logDeviceSecurityContext } from "@/src/lib/security"; +import { auth, hasFirebaseServices } from "@/src/lib/firebase"; +import { firebaseEnvDiagnostics } from "@/src/lib/firebaseConfig"; +import { installGlobalErrorTracking, trackLaunchEvent } from "@/src/lib/analytics"; export const unstable_settings = { anchor: "(tabs)", }; +const LOADING_MESSAGES = [ + "Reading your style profile...", + "Mapping your wardrobe preferences...", + "Calibrating your AURA...", + "Almost ready...", +]; + +type ProfileGateState = "idle" | "loading" | "onboarded" | "needs_onboarding" | "error"; + +function BrandedLoadingScreen() { + const reducedMotion = useReducedMotion(); + const [messageIndex, setMessageIndex] = useState(0); + const opacity = useSharedValue(1); + const advanceMessage = useCallback(() => { + setMessageIndex((current) => (current + 1) % LOADING_MESSAGES.length); + }, []); + + useEffect(() => { + if (reducedMotion) return; + const interval = setInterval(() => { + opacity.value = withTiming(0, { duration: 300, easing: Easing.inOut(Easing.quad) }, (finished) => { + if (!finished) return; + runOnJS(advanceMessage)(); + opacity.value = withTiming(1, { duration: 300, easing: Easing.inOut(Easing.quad) }); + }); + }, 2000); + return () => clearInterval(interval); + }, [advanceMessage, opacity, reducedMotion]); + + const messageStyle = useAnimatedStyle(() => ({ + opacity: reducedMotion ? 1 : opacity.value, + })); + + return ( + + + + + {LOADING_MESSAGES[messageIndex]} + + + + + ); +} + +function ProfileLoadErrorScreen({ onRetry }: { onRetry: () => void }) { + return ( + + + Couldn't load your profile + + Check your connection and try again. Your account is still signed in. + + + Retry + + + + ); +} + +function AuthLoadTimeoutScreen({ + onRetry, + onSignInAgain, +}: { + onRetry: () => void; + onSignInAgain: () => void; +}) { + return ( + + + Still checking your session… + + Your connection may be offline or Firebase is taking longer than usual. + + + + Retry + + + Sign in again + + + + + ); +} + +function ConfigurationErrorScreen() { + return ( + + + Configuration Error + + AURA is missing required app configuration. Please install the latest build or contact support. + + + + Build diagnostics: Firebase env validation failed + + {firebaseEnvDiagnostics.map((diagnostic) => ( + + {diagnostic.name}: required {String(diagnostic.requiredForLaunch)}, present{" "} + {String(diagnostic.present)}, length {diagnostic.length}, valid{" "} + {String(diagnostic.valid)}, checks {JSON.stringify(diagnostic.checks)} + + ))} + + + + ); +} + function AuthGate() { - const { user, loading } = useAuth(); + const { user, loading, authCheckTimedOut, retryAuthCheck } = useAuth(); const segments = useSegments(); - const colorScheme = useColorScheme(); - const palette = Colors[colorScheme === "dark" ? "dark" : "light"]; + const [profileState, setProfileState] = useState("loading"); + const [profileRetryKey, setProfileRetryKey] = useState(0); + const routedRef = useRef(null); + const appOpenTrackedForUidRef = useRef(null); + + useEffect(() => { + let cancelled = false; + + if (loading) return () => { cancelled = true; }; + + if (!user?.uid) { + setProfileState("idle"); + return () => { + cancelled = true; + }; + } + + setProfileState("loading"); + void (async () => { + const cached = await getCachedProfilePreferences(user.uid).catch(() => null); + const cachedOnboarded = Boolean(cached?.data?.onboardingCompleted); + if (cachedOnboarded && !cancelled) { + setProfileState("onboarded"); + } + try { + const profile = await loadUserProfilePreferences(user.uid); + if (cancelled) return; + setProfileState(profile.onboardingCompleted ? "onboarded" : "needs_onboarding"); + } catch { + if (cancelled) return; + setProfileState(cachedOnboarded ? "onboarded" : "error"); + } + })(); + + return () => { + cancelled = true; + }; + }, [loading, profileRetryKey, user?.uid]); useEffect(() => { - if (loading) return; + if (loading || profileState === "loading" || profileState === "error") return; const inAuthGroup = segments[0] === "(auth)"; + const inOnboardingGroup = segments[0] === "(onboarding)"; if (!user && !inAuthGroup) { - router.replace("/(auth)/login"); + if (routedRef.current !== "auth") { + routedRef.current = "auth"; + router.replace("/(auth)/welcome"); + } + return; + } + + if (user && profileState === "needs_onboarding" && !inOnboardingGroup) { + if (routedRef.current !== "onboarding") { + routedRef.current = "onboarding"; + router.replace("/(onboarding)"); + } return; } - if (user && inAuthGroup) { - router.replace("/(tabs)"); + if (user && profileState === "onboarded" && (inAuthGroup || inOnboardingGroup)) { + if (routedRef.current !== "tabs") { + routedRef.current = "tabs"; + router.replace("/(tabs)"); + } } - }, [loading, segments, user]); + }, [loading, profileState, segments, user]); + + useEffect(() => { + if (loading || profileState === "loading" || profileState === "error") return; + if (!user?.uid || appOpenTrackedForUidRef.current === user.uid) return; + appOpenTrackedForUidRef.current = user.uid; + void trackLaunchEvent({ + userId: user.uid, + eventName: "app_opened", + properties: { + onboardingState: profileState, + }, + }); + }, [loading, profileState, user?.uid]); - if (loading) { + if (loading && authCheckTimedOut) { return ( - - - Checking session… - + { + void signOut(auth).finally(() => { + retryAuthCheck(); + router.replace("/(auth)/login"); + }); + }} + /> ); } + if (loading || (user && profileState === "loading")) { + return ; + } + if (user && profileState === "error") { + return setProfileRetryKey((value) => value + 1)} />; + } + return ( + - + + ); } +const loadingStyles = StyleSheet.create({ + container: { + flex: 1, + alignItems: "center", + backgroundColor: Colors.dark.background, + overflow: "hidden", + }, + loadingStack: { + position: "absolute", + top: "40%", + alignItems: "center", + gap: 22, + transform: [{ translateY: -80 }], + }, + message: { + color: Colors.dark.textPrimary, + fontSize: 16, + letterSpacing: 1, + textAlign: "center", + fontWeight: "700", + }, + lottie: { + width: 40, + height: 40, + }, + errorPanel: { + position: "absolute", + top: "38%", + width: "82%", + maxWidth: 360, + alignItems: "center", + gap: 14, + transform: [{ translateY: -60 }], + }, + configErrorPanel: { + top: "24%", + width: "88%", + maxWidth: 430, + transform: [{ translateY: -32 }], + }, + errorTitle: { + color: Colors.dark.textPrimary, + fontSize: 20, + fontWeight: "800", + textAlign: "center", + }, + errorCopy: { + color: Colors.dark.textSecondary, + fontSize: 14, + lineHeight: 20, + textAlign: "center", + }, + retryButton: { + minHeight: 44, + minWidth: 120, + alignItems: "center", + justifyContent: "center", + borderRadius: 8, + backgroundColor: Colors.dark.tint, + paddingHorizontal: 18, + }, + retryText: { + color: Colors.dark.background, + fontSize: 14, + fontWeight: "800", + }, + errorActions: { + flexDirection: "row", + flexWrap: "wrap", + justifyContent: "center", + gap: 10, + }, + secondaryButton: { + minHeight: 44, + minWidth: 120, + alignItems: "center", + justifyContent: "center", + borderRadius: 8, + borderWidth: 1, + borderColor: Colors.dark.border, + paddingHorizontal: 18, + }, + secondaryText: { + color: Colors.dark.textPrimary, + fontSize: 14, + fontWeight: "800", + }, + configDiagnostics: { + width: "100%", + gap: 6, + marginTop: 10, + }, + configDiagnosticTitle: { + color: Colors.dark.textPrimary, + fontSize: 12, + fontWeight: "800", + textAlign: "center", + }, + configDiagnosticText: { + color: Colors.dark.textSecondary, + fontSize: 10, + lineHeight: 14, + textAlign: "left", + }, +}); + export default function RootLayout() { - const colorScheme = useColorScheme(); - const palette = Colors[colorScheme === "dark" ? "dark" : "light"]; - const navigationTheme = - colorScheme === "dark" - ? { - ...DarkTheme, - colors: { - ...DarkTheme.colors, - background: palette.background, - card: palette.surface, - border: palette.border, - primary: palette.tint, - text: palette.text, - }, - } - : { - ...DefaultTheme, - colors: { - ...DefaultTheme.colors, - background: palette.background, - card: palette.surface, - border: palette.border, - primary: palette.tint, - text: palette.text, - }, - }; + useColorScheme(); + const palette = Colors.dark; + const firebaseReady = hasFirebaseServices(); + const navigationTheme = { + ...DarkTheme, + colors: { + ...DarkTheme.colors, + background: palette.background, + card: palette.surface, + border: palette.border, + primary: palette.tint, + text: palette.text, + }, + }; + + useEffect(() => { + if (!firebaseReady) return; + installGlobalErrorTracking(); + configureGoogleSignIn(); + void logDeviceSecurityContext(); + }, [firebaseReady]); useEffect(() => { if (Platform.OS !== "ios" || !__DEV__) return; @@ -94,13 +401,19 @@ export default function RootLayout() { return ( - + {firebaseReady ? ( + + + + + + ) : ( - + - + )} - + ); diff --git a/app/aura/swipe.tsx b/app/aura/swipe.tsx new file mode 100644 index 0000000..2062aaf --- /dev/null +++ b/app/aura/swipe.tsx @@ -0,0 +1,1304 @@ +import { Ionicons } from "@expo/vector-icons"; +import { LinearGradient } from "expo-linear-gradient"; +import { router } from "expo-router"; +import * as Haptics from "expo-haptics"; +import React from "react"; +import { + Alert, + Animated, + Image, + Modal, + PanResponder, + Pressable, + ScrollView, + StyleSheet, + Text, + View, + useWindowDimensions, +} from "react-native"; +import Reanimated, { + Easing, + Extrapolation, + interpolate, + runOnJS, + useAnimatedStyle, + useSharedValue, + withSpring, + withRepeat, + withSequence, + withTiming, +} from "react-native-reanimated"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; + +import { Colors, Fonts } from "@/constants/theme"; +import { AuraLookCard } from "@/src/components/aura/AuraLookCard"; +import AuraSubpageHeader from "@/src/components/ui/AuraSubpageHeader"; +import AuraRing, { RING_SIZE_MD } from "@/src/components/brand/AuraRing"; +import { auraShadow, auraTheme } from "@/src/components/ai/aiTheme"; +import { useReduceMotion } from "@/hooks/useReduceMotion"; +import { useAppTheme } from "@/src/hooks/useAppTheme"; +import { useAuth } from "@/src/hooks/useAuth"; +import { logAuraLookStyleEvent } from "@/src/lib/auraMemory"; +import { saveAuraFavoriteOutfit } from "@/src/lib/auraLooks"; +import { saveAuraOutfitFeedback } from "@/src/lib/auraOutfitFeedback"; +import { Toast } from "@/src/lib/toast"; +import { + feedbackTypeForSwipe, + generateAuraSwipeBatch, + type AuraSwipeBatchLook, + type AuraSwipeDirectionLabel, +} from "@/src/lib/auraSwipe"; +import { isVisibleWardrobeItem, listenToItems, toCanonicalCategory } from "@/src/lib/items"; +import type { ClothingItem } from "@/src/types/ClothingItem"; + +const SWIPE_X_THRESHOLD = 110; +const SWIPE_Y_THRESHOLD = -110; +const SWIPE_X_VELOCITY_THRESHOLD = 850; +const SWIPE_Y_VELOCITY_THRESHOLD = -850; +const DOMINANT_AXIS_RATIO = 1.15; +const NON_DOMINANT_DAMPING = 0.14; +const STACK_NEXT_SCALE = 0.94; +const STACK_NEXT_OFFSET = 14; +const CANCEL_SPRING = { damping: 18, stiffness: 180 }; +const palette = Colors.dark; +const DEBUG_AURA_SWIPE_SCREEN = + __DEV__ && process.env.EXPO_PUBLIC_AURA_DEBUG === "1"; + +type SwipeMetrics = { + translationX: number; + translationY: number; + velocityX: number; + velocityY: number; + source: "gesture" | "button"; +}; + +function createEmptyItemsMap(items: ClothingItem[]) { + return new Map(items.map((item) => [item.id, item])); +} + +function wardrobeCounts(items: ClothingItem[]) { + return items.reduce( + (acc, item) => { + if (!isVisibleWardrobeItem(item)) return acc; + const category = toCanonicalCategory(item.category); + if (category === "top") acc.tops += 1; + if (category === "bottom") acc.bottoms += 1; + if (category === "shoes") acc.shoes += 1; + return acc; + }, + { tops: 0, bottoms: 0, shoes: 0 }, + ); +} + +function getConstrainedPan(dx: number, dy: number) { + const absX = Math.abs(dx); + const absY = Math.abs(dy); + + if (dy < 0 && absY > absX * DOMINANT_AXIS_RATIO) { + return { x: dx * NON_DOMINANT_DAMPING, y: dy }; + } + + if (absX > absY * DOMINANT_AXIS_RATIO) { + return { x: dx, y: dy * NON_DOMINANT_DAMPING }; + } + + if (dy < 0 && absY >= absX) { + return { x: dx * NON_DOMINANT_DAMPING, y: dy * 0.92 }; + } + + return { x: dx, y: dy * 0.22 }; +} + +function swipeLookSignature(lookEntry: AuraSwipeBatchLook) { + const pieceKey = (lookEntry.look.pieces ?? []) + .map((piece) => piece.itemId || `${piece.role}:${piece.itemName}`) + .filter(Boolean) + .sort() + .join("|"); + return pieceKey || lookEntry.id || lookEntry.look.lookTitle; +} + +function dedupeSwipeLooks(looks: AuraSwipeBatchLook[]) { + const seen = new Set(); + return looks.filter((lookEntry) => { + const signature = swipeLookSignature(lookEntry); + if (seen.has(signature)) return false; + seen.add(signature); + return true; + }); +} + +function swipeToastTitle(direction: AuraSwipeDirectionLabel) { + if (direction === "right") return "Got it — more like this"; + if (direction === "left") return "Less of this"; + return "Saved"; +} + +function swipeActionLabel(direction: AuraSwipeDirectionLabel) { + if (direction === "right") return "like"; + if (direction === "left") return "nope"; + return "save"; +} + +function getSwipeLookId(lookEntry?: AuraSwipeBatchLook | null) { + if (!lookEntry) return null; + return lookEntry.id || lookEntry.look.lookTitle || swipeLookSignature(lookEntry); +} + +function preloadLookImages(looks: AuraSwipeBatchLook[]) { + const urls = looks + .flatMap((lookEntry) => lookEntry.look.pieces ?? []) + .map((piece) => String(piece.imageUrl ?? "").trim()) + .filter(Boolean); + + for (const url of new Set(urls)) { + void Image.prefetch(url); + } +} + +export default function AuraSwipeScreen() { + const { user } = useAuth(); + const insets = useSafeAreaInsets(); + const { width, height } = useWindowDimensions(); + const uid = user?.uid ?? null; + + const [items, setItems] = React.useState([]); + const [batch, setBatch] = React.useState([]); + const [batchId, setBatchId] = React.useState(""); + const [currentIndex, setCurrentIndex] = React.useState(0); + const [loading, setLoading] = React.useState(false); + const [error, setError] = React.useState(null); + const [detailsLook, setDetailsLook] = React.useState(null); + const [processingSwipe, setProcessingSwipe] = React.useState(false); + + const itemsById = React.useMemo(() => createEmptyItemsMap(items), [items]); + const counts = React.useMemo(() => wardrobeCounts(items), [items]); + const canGenerate = counts.tops > 0 && counts.bottoms > 0 && counts.shoes > 0; + const progress = React.useRef(new Animated.Value(0)).current; + const translateX = useSharedValue(0); + const translateY = useSharedValue(0); + const activeScale = useSharedValue(1); + const activeOpacity = useSharedValue(1); + const activeLookRef = React.useRef(null); + const pendingSwipeRef = React.useRef<{ + direction: AuraSwipeDirectionLabel; + lookEntry: AuraSwipeBatchLook; + } | null>(null); + const processingSwipeRef = React.useRef(false); + const touchStartRef = React.useRef({ moved: false }); + + const current = batch[currentIndex] ?? null; + const next = batch[currentIndex + 1] ?? null; + const activeLook = current; + const remaining = Math.max(0, batch.length - currentIndex); + const looksLeftLabel = `${remaining} ${remaining === 1 ? "look" : "looks"} left`; + const progressWidth = progress.interpolate({ + inputRange: [0, 1], + outputRange: ["0%", "100%"], + extrapolate: "clamp", + }); + + React.useEffect(() => { + activeLookRef.current = activeLook; + }, [activeLook]); + + React.useEffect(() => { + processingSwipeRef.current = processingSwipe; + }, [processingSwipe]); + + const resetMotionValues = React.useCallback(() => { + translateX.value = 0; + translateY.value = 0; + activeScale.value = 1; + activeOpacity.value = 1; + }, [activeOpacity, activeScale, translateX, translateY]); + + React.useEffect(() => { + resetMotionValues(); + }, [currentIndex, resetMotionValues]); + + React.useEffect(() => { + const nextProgress = batch.length + ? Math.min(1, Math.max(0, currentIndex / batch.length)) + : 0; + Animated.timing(progress, { + toValue: nextProgress, + duration: 180, + useNativeDriver: false, + }).start(); + }, [batch.length, currentIndex, progress]); + + React.useEffect(() => { + if (!uid) return undefined; + return listenToItems(uid, (nextItems) => setItems(nextItems)); + }, [uid]); + + const loadBatch = React.useCallback(async () => { + if (!uid || !canGenerate) { + setLoading(false); + return; + } + setLoading(true); + setError(null); + try { + const result = await generateAuraSwipeBatch({ items, numOutfits: 8 }); + if (DEBUG_AURA_SWIPE_SCREEN) { + console.log("[AURA_SWIPE] normalized response", result); + } + setBatch(dedupeSwipeLooks(result.lookOptions)); + setBatchId(result.batchId); + setCurrentIndex(0); + progress.setValue(0); + resetMotionValues(); + } catch (loadError) { + if (DEBUG_AURA_SWIPE_SCREEN) { + console.log("[AURA_SWIPE] loadBatch failed", loadError); + } + setError(loadError instanceof Error ? loadError.message : "Unable to load swipe looks."); + } finally { + setLoading(false); + } + }, [canGenerate, items, progress, resetMotionValues, uid]); + + React.useEffect(() => { + if (!uid) return; + if (!canGenerate) { + setLoading(false); + return; + } + if (items.length === 0) return; + if (batch.length > 0 || loading) return; + void loadBatch(); + }, [batch.length, canGenerate, items.length, loadBatch, loading, uid]); + + React.useEffect(() => { + preloadLookImages(batch.slice(currentIndex, currentIndex + 3)); + }, [batch, currentIndex]); + + const persistSwipe = React.useCallback( + async (lookEntry: AuraSwipeBatchLook, direction: AuraSwipeDirectionLabel) => { + if (!uid) { + if (DEBUG_AURA_SWIPE_SCREEN) { + console.warn("No user auth, skipping remote save"); + } + return; + } + const feedbackType = feedbackTypeForSwipe(direction); + try { + await saveAuraOutfitFeedback(uid, { + feedbackType, + look: lookEntry.look, + source: "aura_swipe", + sessionId: batchId, + batchPosition: lookEntry.position, + directionLabel: lookEntry.directionLabel ?? direction, + }); + + if (direction === "right") { + await logAuraLookStyleEvent(uid, "outfit_liked", lookEntry.look, { source: "aura" }); + } + if (direction === "left") { + await logAuraLookStyleEvent(uid, "outfit_disliked", lookEntry.look, { source: "aura" }); + } + if (direction === "up") { + await saveAuraFavoriteOutfit(uid, lookEntry.look, { + source: "aura_swipe", + sessionId: batchId, + title: lookEntry.look.lookTitle, + }); + } + } catch (e) { + if (DEBUG_AURA_SWIPE_SCREEN) { + if (__DEV__) { + console.error("SAVE LOOK ERROR:", e); + } + } + throw e; + } + }, + [batchId, uid], + ); + + const finishCommittedSwipe = React.useCallback(() => { + const pendingSwipe = pendingSwipeRef.current; + if (!pendingSwipe) { + processingSwipeRef.current = false; + setProcessingSwipe(false); + resetMotionValues(); + return; + } + + pendingSwipeRef.current = null; + const { direction, lookEntry } = pendingSwipe; + resetMotionValues(); + setCurrentIndex((previous) => previous + 1); + processingSwipeRef.current = false; + setProcessingSwipe(false); + Toast.success(swipeToastTitle(direction)); + void persistSwipe(lookEntry, direction).catch((persistError) => { + Alert.alert( + "Swipe saved locally", + persistError instanceof Error + ? persistError.message + : "We could not finish saving that feedback.", + ); + }); + }, [persistSwipe, resetMotionValues]); + + const commitSwipe = React.useCallback( + (direction: AuraSwipeDirectionLabel, metrics: SwipeMetrics) => { + if (processingSwipeRef.current || !activeLookRef.current) return; + + const lookEntry = activeLookRef.current; + pendingSwipeRef.current = { direction, lookEntry }; + processingSwipeRef.current = true; + setProcessingSwipe(true); + void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); + + if (DEBUG_AURA_SWIPE_SCREEN) { + const nextLook = batch[currentIndex + 1] ?? null; + console.log("[AURA_SWIPE] commit", { + currentIndex, + action: swipeActionLabel(direction), + source: metrics.source, + translationX: Math.round(metrics.translationX), + translationY: Math.round(metrics.translationY), + velocityX: Math.round(metrics.velocityX), + velocityY: Math.round(metrics.velocityY), + previousLookId: getSwipeLookId(lookEntry), + nextLookId: getSwipeLookId(nextLook), + looksRemaining: Math.max(0, batch.length - currentIndex - 1), + }); + } + + const finishOnUi = (finished?: boolean) => { + "worklet"; + if (!finished) return; + runOnJS(finishCommittedSwipe)(); + }; + + if (direction === "up") { + translateX.value = withTiming(0, { + duration: 260, + easing: Easing.out(Easing.cubic), + }); + translateY.value = withTiming(-height * 0.78, { + duration: 270, + easing: Easing.out(Easing.cubic), + }, finishOnUi); + activeScale.value = withTiming(0.92, { + duration: 270, + easing: Easing.out(Easing.cubic), + }); + activeOpacity.value = withTiming(0, { + duration: 250, + easing: Easing.out(Easing.quad), + }); + return; + } + + const directionSign = direction === "right" ? 1 : -1; + const exitY = Math.max(-70, Math.min(76, metrics.translationY || 24)); + translateX.value = withTiming(directionSign * width * 1.18, { + duration: 240, + easing: Easing.out(Easing.cubic), + }, finishOnUi); + translateY.value = withTiming(exitY, { + duration: 240, + easing: Easing.out(Easing.cubic), + }); + activeScale.value = withTiming(0.985, { + duration: 220, + easing: Easing.out(Easing.quad), + }); + activeOpacity.value = withTiming(1, { duration: 180 }); + }, + [ + activeOpacity, + activeScale, + batch, + currentIndex, + finishCommittedSwipe, + height, + translateX, + translateY, + width, + ], + ); + + const advanceDeck = React.useCallback( + (direction: AuraSwipeDirectionLabel) => { + commitSwipe(direction, { + translationX: 0, + translationY: 0, + velocityX: 0, + velocityY: 0, + source: "button", + }); + }, + [commitSwipe], + ); + + const panResponder = React.useMemo( + () => + PanResponder.create({ + onMoveShouldSetPanResponder: (_, gestureState) => + !processingSwipeRef.current && + Boolean(activeLookRef.current) && + (Math.abs(gestureState.dx) > 6 || Math.abs(gestureState.dy) > 6), + onPanResponderGrant: () => { + touchStartRef.current = { moved: false }; + activeOpacity.value = 1; + activeScale.value = withTiming(0.98, { + duration: 110, + easing: Easing.out(Easing.quad), + }); + }, + onPanResponderMove: (_, gestureState) => { + touchStartRef.current.moved = true; + const constrainedPan = getConstrainedPan(gestureState.dx, gestureState.dy); + translateX.value = constrainedPan.x; + translateY.value = constrainedPan.y; + }, + onPanResponderRelease: (_, gestureState) => { + const absX = Math.abs(gestureState.dx); + const absY = Math.abs(gestureState.dy); + const velocityX = gestureState.vx * 1000; + const velocityY = gestureState.vy * 1000; + const isFastUp = + velocityY <= SWIPE_Y_VELOCITY_THRESHOLD && + gestureState.dy < -36 && + absY > absX * 0.55; + const isFastRight = + velocityX >= SWIPE_X_VELOCITY_THRESHOLD && + gestureState.dx > 42 && + absX > absY * 0.55; + const isFastLeft = + velocityX <= -SWIPE_X_VELOCITY_THRESHOLD && + gestureState.dx < -42 && + absX > absY * 0.55; + const metrics: SwipeMetrics = { + translationX: gestureState.dx, + translationY: gestureState.dy, + velocityX, + velocityY, + source: "gesture", + }; + + if ( + (gestureState.dy <= SWIPE_Y_THRESHOLD && absY > absX * 0.8) || + isFastUp + ) { + commitSwipe("up", metrics); + return; + } + + if (gestureState.dx >= SWIPE_X_THRESHOLD || isFastRight) { + commitSwipe("right", metrics); + return; + } + + if (gestureState.dx <= -SWIPE_X_THRESHOLD || isFastLeft) { + commitSwipe("left", metrics); + return; + } + + if (!touchStartRef.current.moved && activeLookRef.current) { + setDetailsLook(activeLookRef.current); + } + + translateX.value = withSpring(0, CANCEL_SPRING); + translateY.value = withSpring(0, CANCEL_SPRING); + activeScale.value = withSpring(1, CANCEL_SPRING); + activeOpacity.value = withSpring(1, CANCEL_SPRING); + }, + onPanResponderTerminate: () => { + translateX.value = withSpring(0, CANCEL_SPRING); + translateY.value = withSpring(0, CANCEL_SPRING); + activeScale.value = withSpring(1, CANCEL_SPRING); + activeOpacity.value = withSpring(1, CANCEL_SPRING); + }, + }), + [activeOpacity, activeScale, commitSwipe, translateX, translateY], + ); + + const revealProgressStyle = useAnimatedStyle(() => { + const horizontalReveal = interpolate( + Math.abs(translateX.value), + [0, 160], + [0, 1], + Extrapolation.CLAMP, + ); + const verticalReveal = interpolate( + -translateY.value, + [0, 170], + [0, 1], + Extrapolation.CLAMP, + ); + const reveal = Math.max(horizontalReveal, verticalReveal); + + return { + opacity: interpolate(reveal, [0, 1], [0.55, 1], Extrapolation.CLAMP), + transform: [ + { + translateY: interpolate( + reveal, + [0, 1], + [STACK_NEXT_OFFSET, 0], + Extrapolation.CLAMP, + ), + }, + { + scale: interpolate( + reveal, + [0, 1], + [STACK_NEXT_SCALE, 1], + Extrapolation.CLAMP, + ), + }, + ], + }; + }); + + const nextDimStyle = useAnimatedStyle(() => { + const reveal = Math.max( + interpolate(Math.abs(translateX.value), [0, 160], [0, 1], Extrapolation.CLAMP), + interpolate(-translateY.value, [0, 170], [0, 1], Extrapolation.CLAMP), + ); + + return { + opacity: interpolate(reveal, [0, 1], [0.22, 0], Extrapolation.CLAMP), + }; + }); + + const activeCardStyle = useAnimatedStyle(() => { + const verticalPullDamping = interpolate( + translateY.value, + [-150, 0], + [0.2, 1], + Extrapolation.CLAMP, + ); + const rotation = interpolate( + translateX.value, + [-width, 0, width], + [-7, 0, 7], + Extrapolation.CLAMP, + ) * verticalPullDamping; + + return { + opacity: activeOpacity.value, + transform: [ + { translateX: translateX.value }, + { translateY: translateY.value }, + { rotate: `${rotation}deg` }, + { scale: activeScale.value }, + ], + }; + }); + + const dragGlowStyle = useAnimatedStyle(() => { + const dragAmount = Math.max(Math.abs(translateX.value), Math.max(0, -translateY.value)); + return { + opacity: interpolate(dragAmount, [0, 80, 150], [0, 0.34, 0.5], Extrapolation.CLAMP), + }; + }); + + const likeBadgeStyle = useAnimatedStyle(() => ({ + opacity: interpolate(translateX.value, [28, 120], [0, 0.9], Extrapolation.CLAMP), + transform: [ + { translateY: interpolate(translateX.value, [28, 120], [8, 0], Extrapolation.CLAMP) }, + ], + })); + + const nopeBadgeStyle = useAnimatedStyle(() => ({ + opacity: interpolate(-translateX.value, [28, 120], [0, 0.9], Extrapolation.CLAMP), + transform: [ + { translateY: interpolate(-translateX.value, [28, 120], [8, 0], Extrapolation.CLAMP) }, + ], + })); + + const saveBadgeStyle = useAnimatedStyle(() => ({ + opacity: interpolate(-translateY.value, [28, 118], [0, 0.9], Extrapolation.CLAMP), + transform: [ + { translateY: interpolate(-translateY.value, [28, 118], [8, 0], Extrapolation.CLAMP) }, + ], + })); + + return ( + + void loadBatch()} />} + /> + + + + {looksLeftLabel} + + + + + + + + {loading ? ( + + ) : !canGenerate ? ( + + ) : error ? ( + void loadBatch()} /> + ) : !activeLook ? ( + void loadBatch()} /> + ) : ( + + + {next ? ( + + + + + + + ) : null} + + {current ? ( + + setDetailsLook(current)}> + + + + + LIKE + + + + NOPE + + + + SAVE + + + + ) : null} + + + )} + + + + + advanceDeck("left")} disabled={!activeLook || processingSwipe} tone="subtle" /> + advanceDeck("right")} disabled={!activeLook || processingSwipe} primary /> + advanceDeck("up")} disabled={!activeLook || processingSwipe} tone="secondary" /> + + + + setDetailsLook(null)} + > + + + + + LOOK DETAILS + {detailsLook?.look.lookTitle ?? "Outfit details"} + + setDetailsLook(null)} /> + + {detailsLook ? ( + + + + PIECES + {detailsLook.look.pieces.map((piece) => ( + + {piece.role.toUpperCase()} + {piece.itemName} + + ))} + + + ) : null} + + + + + ); +} + +function TasteTrainedStateCard({ onRefresh }: { onRefresh: () => void }) { + const { colors } = useAppTheme(); + const reduceMotion = useReduceMotion(); + const scale = useSharedValue(1); + + React.useEffect(() => { + if (reduceMotion) { + scale.value = 1; + return; + } + scale.value = withRepeat( + withSequence( + withTiming(1.04, { duration: 1000 }), + withTiming(1, { duration: 1000 }), + ), + -1, + true, + ); + }, [reduceMotion, scale]); + + const markStyle = useAnimatedStyle(() => ({ + transform: [{ scale: scale.value }], + })); + + return ( + + + + + Taste trained. + AURA has more signal for your next outfit. + + + + + Generate new set + + + + router.push("/profile/my-looks")} style={styles.endSecondaryButton}> + View saved looks + + router.push("/(tabs)/ai")} style={styles.endSecondaryButton}> + Back to AURA + + + + + ); +} + +function HeaderButton({ + icon, + label, + onPress, +}: { + icon: keyof typeof Ionicons.glyphMap; + label: string; + onPress: () => void; +}) { + return ( + + + {label} + + ); +} + +function ActionButton({ + label, + icon, + onPress, + disabled, + primary, + tone = "secondary", +}: { + label: string; + icon: keyof typeof Ionicons.glyphMap; + onPress: () => void; + disabled: boolean; + primary?: boolean; + tone?: "subtle" | "secondary"; +}) { + const iconColor = primary ? palette.ctaText : tone === "subtle" ? auraTheme.textMuted : palette.textPrimary; + const textStyle = primary + ? styles.actionPrimaryText + : tone === "subtle" + ? styles.actionSubtleText + : null; + + return ( + [ + styles.actionButton, + primary + ? [styles.actionPrimary, styles.actionPrimarySize] + : tone === "subtle" + ? styles.actionSubtle + : styles.actionSecondary, + disabled ? { opacity: 0.45 } : null, + pressed && !disabled ? { transform: [{ scale: 0.98 }] } : null, + ]} + > + + {label} + + ); +} + +function StateCard({ + title, + body, + actionLabel, + onAction, +}: { + title: string; + body: string; + actionLabel?: string; + onAction?: () => void; +}) { + return ( + + {title} + {body} + {actionLabel && onAction ? ( + + {actionLabel} + + ) : null} + + ); +} + +const styles = StyleSheet.create({ + headerButton: { + flexDirection: "row", + alignItems: "center", + gap: 6, + borderRadius: 999, + borderWidth: 1, + borderColor: auraTheme.borderSoft, + backgroundColor: auraTheme.surfaceSoft, + paddingHorizontal: 10, + paddingVertical: 8, + }, + headerButtonText: { + color: palette.textPrimary, + fontSize: 12, + fontWeight: "700", + fontFamily: Fonts.sans, + }, + progressCard: { + borderRadius: 16, + borderWidth: 1, + borderColor: auraTheme.borderSoft, + backgroundColor: auraTheme.surfaceSoft, + paddingHorizontal: 13, + paddingVertical: 11, + gap: 8, + }, + progressTitle: { + color: palette.textPrimary, + fontSize: 14, + fontWeight: "800", + fontFamily: Fonts.sans, + }, + progressTrack: { + height: 4, + borderRadius: 999, + overflow: "hidden", + backgroundColor: "rgba(255,255,255,0.1)", + }, + progressFill: { + height: "100%", + borderRadius: 999, + backgroundColor: "#F8D4C8", + }, + deckArea: { + flex: 1, + paddingHorizontal: 18, + paddingTop: 18, + alignItems: "center", + justifyContent: "center", + }, + deckCard: { + position: "absolute", + top: 0, + left: 0, + right: 0, + overflow: "visible", + }, + currentDeckCard: { + zIndex: 2, + }, + nextDeckCard: { + zIndex: 1, + alignItems: "center", + }, + nextPreviewShell: { + width: "100%", + alignItems: "center", + borderRadius: 24, + paddingTop: 18, + paddingHorizontal: 10, + paddingBottom: 18, + overflow: "hidden", + backgroundColor: "rgba(255,255,255,0.08)", + borderWidth: 1, + borderColor: "rgba(255,255,255,0.1)", + }, + nextPreviewDim: { + ...StyleSheet.absoluteFillObject, + borderRadius: 24, + backgroundColor: "rgba(4, 7, 12, 0.32)", + }, + cardPressable: { + position: "relative", + width: "100%", + overflow: "visible", + }, + cardGlow: { + ...StyleSheet.absoluteFillObject, + zIndex: 19, + borderRadius: 32, + borderWidth: 1.5, + borderColor: "rgba(248, 212, 200, 0.62)", + backgroundColor: "rgba(248, 212, 200, 0.025)", + }, + swipeBadge: { + position: "absolute", + zIndex: 20, + top: 18, + minHeight: 34, + borderRadius: 999, + borderWidth: 1, + paddingHorizontal: 12, + flexDirection: "row", + alignItems: "center", + justifyContent: "center", + gap: 6, + }, + likeBadge: { + right: 18, + backgroundColor: "rgba(33, 130, 82, 0.26)", + borderColor: "rgba(190, 247, 216, 0.34)", + }, + nopeBadge: { + left: 18, + backgroundColor: "rgba(150, 44, 56, 0.28)", + borderColor: "rgba(255, 201, 207, 0.34)", + }, + saveBadge: { + left: "50%", + width: 96, + marginLeft: -48, + backgroundColor: "rgba(165, 122, 42, 0.28)", + borderColor: "rgba(255, 235, 176, 0.38)", + }, + swipeBadgeText: { + color: palette.textPrimary, + fontSize: 11, + fontWeight: "900", + letterSpacing: 1.1, + fontFamily: Fonts.sans, + }, + overlayBadge: { + position: "absolute", + zIndex: 20, + borderRadius: 999, + borderWidth: 1, + borderColor: auraTheme.borderAccent, + backgroundColor: palette.dockBackground, + paddingHorizontal: 12, + paddingVertical: 8, + }, + overlayLeft: { + top: 14, + left: 14, + }, + overlayRight: { + top: 14, + right: 14, + }, + overlayTop: { + top: 14, + alignSelf: "center", + }, + overlayText: { + color: auraTheme.accentStrong, + fontSize: 11, + fontWeight: "900", + letterSpacing: 1.2, + fontFamily: Fonts.sans, + }, + footer: { + paddingHorizontal: 18, + paddingTop: 8, + gap: 10, + }, + footerHint: { + textAlign: "center", + color: auraTheme.textMuted, + fontSize: 12.5, + lineHeight: 18, + fontFamily: Fonts.sans, + }, + footerActions: { + flexDirection: "row", + gap: 8, + }, + actionButton: { + flex: 1, + minHeight: 42, + borderRadius: 16, + alignItems: "center", + justifyContent: "center", + flexDirection: "row", + gap: 6, + borderWidth: 1, + }, + actionPrimary: { + backgroundColor: palette.ctaCream, + borderColor: palette.borderWarm, + }, + actionPrimarySize: { + minHeight: 42, + }, + actionSecondary: { + backgroundColor: "rgba(255,255,255,0.07)", + borderColor: auraTheme.borderSoft, + }, + actionSubtle: { + backgroundColor: "rgba(255,255,255,0.035)", + borderColor: "rgba(255,255,255,0.08)", + }, + actionButtonText: { + color: palette.textPrimary, + fontSize: 12, + fontWeight: "800", + fontFamily: Fonts.sans, + }, + actionPrimaryText: { + color: palette.ctaText, + fontSize: 13, + fontWeight: "900", + }, + actionSubtleText: { + color: auraTheme.textMuted, + fontWeight: "700", + }, + stateCard: { + width: "100%", + maxWidth: 420, + borderRadius: 28, + paddingHorizontal: 22, + paddingVertical: 24, + borderWidth: 1, + borderColor: auraTheme.border, + backgroundColor: auraTheme.surfaceStrong, + gap: 10, + ...auraShadow(0.18), + }, + stateTitle: { + color: palette.textPrimary, + fontSize: 24, + lineHeight: 28, + fontWeight: "800", + fontFamily: Fonts.sans, + }, + stateBody: { + color: auraTheme.textMuted, + fontSize: 14, + lineHeight: 21, + fontFamily: Fonts.sans, + }, + stateButton: { + alignSelf: "flex-start", + borderRadius: 999, + backgroundColor: auraTheme.accentTint, + borderWidth: 1, + borderColor: auraTheme.borderAccent, + paddingHorizontal: 13, + paddingVertical: 8, + }, + stateButtonText: { + color: palette.textPrimary, + fontSize: 12.5, + fontWeight: "800", + fontFamily: Fonts.sans, + }, + caughtUpMark: { + width: 58, + height: 58, + borderRadius: 29, + borderWidth: 1, + alignItems: "center", + justifyContent: "center", + backgroundColor: "rgba(255,255,255,0.04)", + marginBottom: 2, + }, + caughtUpMarkText: { + fontSize: 24, + fontWeight: "900", + letterSpacing: 0, + fontFamily: Fonts.sans, + }, + caughtUpButton: { + alignSelf: "flex-start", + borderRadius: 999, + overflow: "hidden", + marginTop: 2, + }, + caughtUpButtonGradient: { + flexDirection: "row", + alignItems: "center", + gap: 7, + paddingHorizontal: 14, + paddingVertical: 9, + }, + caughtUpButtonText: { + fontSize: 12.5, + fontWeight: "900", + fontFamily: Fonts.sans, + }, + endActionStack: { + gap: 10, + marginTop: 4, + }, + endSecondaryRow: { + flexDirection: "row", + gap: 8, + }, + endSecondaryButton: { + flex: 1, + minHeight: 38, + borderRadius: 14, + borderWidth: 1, + borderColor: "rgba(255,255,255,0.12)", + backgroundColor: "rgba(255,255,255,0.05)", + alignItems: "center", + justifyContent: "center", + paddingHorizontal: 10, + }, + endSecondaryText: { + color: palette.textPrimary, + fontSize: 12, + fontWeight: "800", + fontFamily: Fonts.sans, + }, + modalScrim: { + flex: 1, + backgroundColor: palette.overlay, + justifyContent: "flex-end", + }, + modalCard: { + maxHeight: "88%", + borderTopLeftRadius: 28, + borderTopRightRadius: 28, + backgroundColor: palette.surfaceElevated, + paddingHorizontal: 18, + paddingTop: 18, + }, + modalHeader: { + flexDirection: "row", + alignItems: "center", + gap: 12, + marginBottom: 14, + }, + modalEyebrow: { + color: auraTheme.textFaint, + fontSize: 11, + fontWeight: "800", + letterSpacing: 1.2, + fontFamily: Fonts.sans, + }, + modalTitle: { + color: palette.textPrimary, + fontSize: 22, + lineHeight: 26, + fontWeight: "800", + fontFamily: Fonts.sans, + marginTop: 2, + }, + detailsSection: { + marginTop: 16, + borderRadius: 20, + borderWidth: 1, + borderColor: auraTheme.borderSoft, + backgroundColor: auraTheme.surfaceSoft, + padding: 14, + gap: 10, + }, + detailsLabel: { + color: auraTheme.textFaint, + fontSize: 11, + fontWeight: "800", + letterSpacing: 1, + fontFamily: Fonts.sans, + }, + pieceRow: { + flexDirection: "row", + alignItems: "center", + gap: 10, + }, + pieceRole: { + width: 84, + color: auraTheme.accentStrong, + fontSize: 11, + fontWeight: "800", + fontFamily: Fonts.sans, + }, + pieceName: { + flex: 1, + color: palette.textPrimary, + fontSize: 13.5, + lineHeight: 18, + fontFamily: Fonts.sans, + }, +}); diff --git a/app/insights.tsx b/app/insights.tsx new file mode 100644 index 0000000..bd10fc3 --- /dev/null +++ b/app/insights.tsx @@ -0,0 +1,220 @@ +import { router } from "expo-router"; +import React, { useCallback, useEffect, useMemo, useState } from "react"; +import { Alert } from "react-native"; + +import AuraInsightsDashboard, { + type InsightPeriodKey, +} from "@/src/components/insights/AuraInsightsDashboard"; +import ShopOptionsSheet from "@/src/components/shop/ShopOptionsSheet"; +import { useAuth } from "@/src/hooks/useAuth"; +import { listenToItems } from "@/src/lib/items"; +import { loadUserProfilePreferences } from "@/src/lib/userProfile"; +import { + buildWardrobeInsights, + type MissingPieceInsight, +} from "@/src/lib/wardrobeInsights"; +import { safeGoBack } from "@/src/lib/navigation"; +import { + buildWardrobeSuggestions, + type WardrobeSuggestion, +} from "@/src/lib/wardrobeSuggestions"; +import type { ClothingItem } from "@/src/types/ClothingItem"; +import type { UserProfilePreferences } from "@/src/types/UserProfilePreferences"; +import { addDays, toDayKey } from "@/src/utils/date"; +import { + subscribeOutfitsInRange, + type DailyOutfitRecord, +} from "@/src/utils/dailyOutfits"; + +function rangeForPeriod(period: InsightPeriodKey) { + const end = new Date(); + const endKey = toDayKey(end); + + if (period === "all") { + return { + startKey: "1970-01-01", + endKey, + }; + } + + const days = period === "90d" ? 90 : 30; + return { + startKey: toDayKey(addDays(end, -(days - 1))), + endKey, + }; +} + +function promptItemLabel(item: ClothingItem) { + return ( + item.name?.trim() || + item.subCategory?.trim() || + String(item.category ?? "").trim() || + "this piece" + ); +} + +export default function InsightsScreen() { + const { user } = useAuth(); + const uid = user?.uid ?? null; + const [period, setPeriod] = useState("30d"); + const [items, setItems] = useState([]); + const [outfitRecords, setOutfitRecords] = useState([]); + const [profilePreferences, setProfilePreferences] = useState(null); + const [activeShopSuggestion, setActiveShopSuggestion] = useState(null); + const [loadingItems, setLoadingItems] = useState(true); + + useEffect(() => { + if (!uid) { + setItems([]); + setLoadingItems(false); + router.replace("/(auth)/welcome"); + return; + } + + setItems([]); + setLoadingItems(true); + const unsubscribe = listenToItems( + uid, + (next) => { + setItems(next as ClothingItem[]); + setLoadingItems(false); + }, + { + status: "ALL", + sort: "NEWEST", + onError: (message) => { + setLoadingItems(false); + Alert.alert("Firestore error", message); + }, + } + ); + + return () => unsubscribe(); + }, [uid]); + + useEffect(() => { + let active = true; + if (!uid) { + setProfilePreferences(null); + return () => { + active = false; + }; + } + void loadUserProfilePreferences(uid) + .then((preferences) => { + if (active) setProfilePreferences(preferences); + }) + .catch(() => { + if (active) setProfilePreferences(null); + }); + return () => { + active = false; + }; + }, [uid]); + + useEffect(() => { + if (!uid) { + setOutfitRecords([]); + return; + } + + let active = true; + const { startKey, endKey } = rangeForPeriod(period); + setOutfitRecords([]); + + const unsubscribe = subscribeOutfitsInRange( + uid, + startKey, + endKey, + (recordsByDate) => { + if (!active) return; + setOutfitRecords( + Object.values(recordsByDate).filter( + (record): record is DailyOutfitRecord => Boolean(record) + ) + ); + }, + () => { + if (!active) return; + setOutfitRecords([]); + } + ); + + return () => { + active = false; + unsubscribe(); + }; + }, [period, uid]); + + const insights = useMemo( + () => buildWardrobeInsights({ items, outfitRecords }), + [items, outfitRecords] + ); + const wardrobeSuggestions = useMemo( + () => + buildWardrobeSuggestions({ + items, + profilePreferences, + savedLooks: outfitRecords.map(() => ({})), + }), + [items, outfitRecords, profilePreferences], + ); + + const openAuraWithPrompt = useCallback((prompt: string) => { + router.push({ + pathname: "/(tabs)/ai", + params: { + prompt, + promptKey: String(Date.now()), + }, + }); + }, []); + + const handleStyleItem = useCallback( + (item: ClothingItem) => { + const label = promptItemLabel(item); + openAuraWithPrompt( + `Style ${label} from my wardrobe. Build a wearable outfit around it and explain why it works.` + ); + }, + [openAuraWithPrompt] + ); + + const handleAskAuraWhatToBuy = useCallback( + (missingPieces: MissingPieceInsight[]) => { + const missingSummary = missingPieces.length + ? missingPieces + .map((piece) => `${piece.label} (${piece.missingCount})`) + .join(", ") + : "no foundational gaps"; + openAuraWithPrompt( + `Review my wardrobe insights and tell me what to buy next to unlock more outfits. Current gaps: ${missingSummary}. Keep it concise and prioritize versatile pieces.` + ); + }, + [openAuraWithPrompt] + ); + + return ( + <> + safeGoBack("/")} + onStyleItem={handleStyleItem} + onAskAuraWhatToBuy={handleAskAuraWhatToBuy} + onFindSuggestionOptions={setActiveShopSuggestion} + /> + setActiveShopSuggestion(null)} + /> + + ); +} diff --git a/app/insights/gaps.tsx b/app/insights/gaps.tsx new file mode 100644 index 0000000..831946b --- /dev/null +++ b/app/insights/gaps.tsx @@ -0,0 +1,5 @@ +import { Redirect } from "expo-router"; + +export default function WardrobeGapsRedirect() { + return ; +} diff --git a/app/insights/unworn.tsx b/app/insights/unworn.tsx new file mode 100644 index 0000000..08a2e25 --- /dev/null +++ b/app/insights/unworn.tsx @@ -0,0 +1,5 @@ +import { Redirect } from "expo-router"; + +export default function UnwornItemsRedirect() { + return ; +} diff --git a/app/laundry.tsx b/app/laundry.tsx new file mode 100644 index 0000000..0505f78 --- /dev/null +++ b/app/laundry.tsx @@ -0,0 +1 @@ +export { default } from "./(tabs)/laundry"; diff --git a/app/modal.tsx b/app/modal.tsx deleted file mode 100644 index 6dfbc1a..0000000 --- a/app/modal.tsx +++ /dev/null @@ -1,29 +0,0 @@ -import { Link } from 'expo-router'; -import { StyleSheet } from 'react-native'; - -import { ThemedText } from '@/components/themed-text'; -import { ThemedView } from '@/components/themed-view'; - -export default function ModalScreen() { - return ( - - This is a modal - - Go to home screen - - - ); -} - -const styles = StyleSheet.create({ - container: { - flex: 1, - alignItems: 'center', - justifyContent: 'center', - padding: 20, - }, - link: { - marginTop: 15, - paddingVertical: 15, - }, -}); diff --git a/app/packing.tsx b/app/packing.tsx new file mode 100644 index 0000000..796331e --- /dev/null +++ b/app/packing.tsx @@ -0,0 +1,5 @@ +import { Redirect } from "expo-router"; + +export default function PackingRedirect() { + return ; +} diff --git a/app/profile/my-looks.tsx b/app/profile/my-looks.tsx new file mode 100644 index 0000000..4e4a4b4 --- /dev/null +++ b/app/profile/my-looks.tsx @@ -0,0 +1,513 @@ +import { router, useLocalSearchParams } from "expo-router"; +import * as Haptics from "expo-haptics"; +import React, { useEffect, useMemo, useState } from "react"; +import { Alert, Pressable, ScrollView, Share, StyleSheet, Text, View, useWindowDimensions } from "react-native"; + +import { Colors } from "@/constants/theme"; +import { SafeScreen } from "@/src/components/SafeScreen"; +import AuraSubpageHeader from "@/src/components/ui/AuraSubpageHeader"; +import { AuraAnimatedListItem } from "@/src/components/motion"; +import { AuraTopSafeAreaScrim, auraButtonStyle, auraButtonTextStyle } from "@/src/components/ui/auraStylePrimitives"; +import { LookDetailModal } from "@/src/components/profile/LookDetailModal"; +import { MyLookSkeleton, MyLookThumbnail } from "@/src/components/profile/MyLookThumbnail"; +import { AURA_TRAINING_ROUTE } from "@/src/constants/routes"; +import { useAuth } from "@/src/hooks/useAuth"; +import { useAppTheme } from "@/src/hooks/useAppTheme"; +import { useResponsiveLayout } from "@/src/hooks/useResponsiveLayout"; +import { + dislikeProfileLooks, + favouriteProfileLooks, + likeProfileLooks, + removeProfileLooks, + subscribeFavouriteLooks, + subscribeProfileFeedbackLooks, + type ProfileLookRecord, +} from "@/src/lib/profileLooks"; + +type TabKey = "liked" | "disliked" | "favourites"; + +const TABS: { key: TabKey; label: string }[] = [ + { key: "liked", label: "Liked" }, + { key: "disliked", label: "Disliked" }, + { key: "favourites", label: "Favourites" }, +]; +const palette = Colors.dark; + +function normalizeInitialTab(value: unknown): TabKey { + const raw = Array.isArray(value) ? value[0] : value; + if (raw === "disliked") return "disliked"; + if (raw === "favourites" || raw === "favorites") return "favourites"; + return "liked"; +} + +function selectionKey(record: ProfileLookRecord) { + return `${record.collection}:${record.id}`; +} + +function buildShareMessage(records: ProfileLookRecord[]) { + if (records.length === 1) { + const record = records[0]; + return [ + record.title || "AURA look", + ...record.look.pieces.map((piece) => `• ${piece.itemName}`), + ].join("\n"); + } + + return records + .map((record, index) => { + const pieces = record.look.pieces.map((piece) => piece.itemName).filter(Boolean).join(", "); + return `${index + 1}. ${record.title || "AURA look"}${pieces ? `: ${pieces}` : ""}`; + }) + .join("\n"); +} + +function EmptyState({ tab }: { tab: TabKey }) { + const content = { + liked: { + icon: "♥", + title: "No liked outfits yet", + subtext: "Like outfits in AURA chat to save them here", + button: "Ask AURA for outfits →", + onPress: () => router.push("/(tabs)/ai"), + }, + disliked: { + icon: "✗", + title: "No disliked outfits", + subtext: "AURA learns from what you don't like too", + button: "", + onPress: undefined, + }, + favourites: { + icon: "★", + title: "No favourites yet", + subtext: "Save looks from AURA chat or swipe up in training mode", + button: "Train AURA →", + onPress: () => router.push(AURA_TRAINING_ROUTE), + }, + }[tab]; + + return ( + + {content.icon} + {content.title} + {content.subtext} + {content.button && content.onPress ? ( + + {content.button} + + ) : null} + + ); +} + +export default function MyLooksScreen() { + const { colors } = useAppTheme(); + const layout = useResponsiveLayout(); + const { user } = useAuth(); + const params = useLocalSearchParams<{ tab?: string }>(); + const [activeTab, setActiveTab] = useState(() => normalizeInitialTab(params.tab)); + const [liked, setLiked] = useState([]); + const [disliked, setDisliked] = useState([]); + const [favourites, setFavourites] = useState([]); + const [loading, setLoading] = useState({ liked: true, disliked: true, favourites: true }); + const [error, setError] = useState(null); + const [selectedLook, setSelectedLook] = useState(null); + const [selectedKeys, setSelectedKeys] = useState>(new Set()); + const [bulkWorking, setBulkWorking] = useState(false); + const { width: screenWidth } = useWindowDimensions(); + const thumbnailWidth = Math.floor((screenWidth - 48) / 2); + const selectionMode = selectedKeys.size > 0; + + useEffect(() => { + setActiveTab(normalizeInitialTab(params.tab)); + }, [params.tab]); + + useEffect(() => { + if (!user?.uid) { + setLiked([]); + setDisliked([]); + setFavourites([]); + setLoading({ liked: false, disliked: false, favourites: false }); + return; + } + + setLoading({ liked: true, disliked: true, favourites: true }); + setError(null); + const handleError = (nextError: Error) => { + setError(nextError.message || "Unable to load your looks."); + setLoading({ liked: false, disliked: false, favourites: false }); + }; + + const unsubs = [ + subscribeProfileFeedbackLooks(user.uid, "outfit_liked", (records) => { + setLiked(records); + setLoading((prev) => ({ ...prev, liked: false })); + }, handleError), + subscribeProfileFeedbackLooks(user.uid, "outfit_disliked", (records) => { + setDisliked(records); + setLoading((prev) => ({ ...prev, disliked: false })); + }, handleError), + subscribeFavouriteLooks(user.uid, (records) => { + setFavourites(records); + setLoading((prev) => ({ ...prev, favourites: false })); + }, handleError), + ]; + + return () => { + unsubs.forEach((unsubscribe) => unsubscribe()); + }; + }, [user?.uid]); + + useEffect(() => { + setSelectedKeys(new Set()); + }, [activeTab]); + + const recordsByTab: Record = { + liked, + disliked, + favourites, + }; + const currentRecords = recordsByTab[activeTab]; + const currentLoading = loading[activeTab]; + const selectedRecords = useMemo( + () => currentRecords.filter((record) => selectedKeys.has(selectionKey(record))), + [currentRecords, selectedKeys], + ); + + const clearSelection = () => setSelectedKeys(new Set()); + + const toggleSelection = (record: ProfileLookRecord) => { + setSelectedKeys((prev) => { + const next = new Set(prev); + const key = selectionKey(record); + if (next.has(key)) { + next.delete(key); + } else { + next.add(key); + } + return next; + }); + }; + + const startSelection = (record: ProfileLookRecord) => { + void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light).catch(() => undefined); + setSelectedKeys(new Set([selectionKey(record)])); + }; + + const handleThumbnailPress = (record: ProfileLookRecord) => { + if (selectionMode) { + toggleSelection(record); + return; + } + setSelectedLook(record); + }; + + const runBulkAction = async (label: string, action: () => Promise, clearAfter = true) => { + if (!user?.uid || selectedRecords.length === 0 || bulkWorking) return; + try { + setBulkWorking(true); + await action(); + if (clearAfter) clearSelection(); + await Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success); + } catch (nextError: any) { + await Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error); + Alert.alert(label, nextError?.message ?? "Unable to update selected looks."); + } finally { + setBulkWorking(false); + } + }; + + const confirmDelete = () => { + if (!selectedRecords.length) return; + Alert.alert( + "Delete selected looks?", + `Remove ${selectedRecords.length} selected ${selectedRecords.length === 1 ? "look" : "looks"} from this collection?`, + [ + { text: "Cancel", style: "cancel" }, + { + text: "Delete", + style: "destructive", + onPress: () => { + void runBulkAction("Delete", async () => { + if (!user?.uid) return; + await removeProfileLooks(user.uid, selectedRecords); + }); + }, + }, + ], + ); + }; + + const favouriteSelected = () => { + void runBulkAction("Favourite", async () => { + if (!user?.uid) return; + await favouriteProfileLooks(user.uid, selectedRecords); + }); + }; + + const dislikeSelected = () => { + void runBulkAction("Dislike", async () => { + if (!user?.uid) return; + await dislikeProfileLooks(user.uid, selectedRecords); + }); + }; + + const likeSelected = () => { + void runBulkAction("Like", async () => { + if (!user?.uid) return; + await likeProfileLooks(user.uid, selectedRecords); + }); + }; + + const shareSelected = () => { + void runBulkAction("Share", async () => { + const title = selectedRecords.length === 1 ? selectedRecords[0]?.title || "AURA look" : "AURA looks"; + await Share.share({ title, message: buildShareMessage(selectedRecords) }); + }); + }; + + return ( + + + {selectionMode ? ( + + + Cancel + + {selectedKeys.size} selected + + + + {activeTab === "favourites" ? "Remove" : "Delete"} + + + {activeTab === "disliked" ? ( + + Like + + ) : null} + {activeTab !== "favourites" ? ( + + Favourite + + ) : null} + {activeTab !== "disliked" ? ( + + Dislike + + ) : null} + + Share + + + + ) : ( + <> + + + + {TABS.map((tab) => { + const active = activeTab === tab.key; + const count = recordsByTab[tab.key].length; + return ( + setActiveTab(tab.key)} + style={[styles.tab, active ? styles.activeTab : null]} + accessibilityRole="button" + > + + {tab.label} ({count}) + + + ); + })} + + + )} + + {error ? ( + + Couldn’t load My Looks + {error} + + ) : ( + + {currentLoading + ? Array.from({ length: 6 }).map((_, index) => ( + + )) + : currentRecords.length + ? currentRecords.map((record, index) => ( + + handleThumbnailPress(record)} + onLongPress={() => startSelection(record)} + selected={selectedKeys.has(selectionKey(record))} + selectionMode={selectionMode} + /> + + )) + : } + + )} + + setSelectedLook(null)} + /> + + ); +} + +const styles = StyleSheet.create({ + selectionToolbar: { + paddingHorizontal: 16, + paddingTop: 8, + paddingBottom: 14, + gap: 10, + borderBottomWidth: 1, + borderBottomColor: palette.border, + backgroundColor: palette.background, + }, + toolbarButton: { + alignSelf: "flex-start", + minHeight: 44, + paddingHorizontal: 4, + justifyContent: "center", + }, + toolbarText: { + color: palette.textPrimary, + fontSize: 15, + fontWeight: "800", + }, + selectionTitle: { + position: "absolute", + top: 20, + left: 0, + right: 0, + color: palette.textPrimary, + textAlign: "center", + fontSize: 17, + fontWeight: "900", + }, + toolbarActions: { + gap: 8, + paddingRight: 4, + }, + toolbarPill: { + minHeight: 44, + paddingHorizontal: 14, + borderRadius: 999, + alignItems: "center", + justifyContent: "center", + backgroundColor: palette.chipBackground, + borderWidth: 1, + borderColor: palette.borderStrong, + }, + toolbarPillText: { + color: palette.textPrimary, + fontSize: 13, + fontWeight: "800", + }, + deleteText: { + color: palette.danger, + }, + tabContainer: { + marginHorizontal: 16, + marginTop: 12, + marginBottom: 16, + borderRadius: 12, + padding: 3, + backgroundColor: palette.surfaceBase, + borderWidth: 1, + borderColor: palette.border, + flexDirection: "row", + }, + tab: { + flex: 1, + minHeight: 44, + paddingVertical: 8, + borderRadius: 10, + alignItems: "center", + justifyContent: "center", + }, + activeTab: { + backgroundColor: palette.purpleSurface, + }, + tabText: { + color: palette.textMuted, + fontSize: 12.5, + lineHeight: 16, + fontWeight: "800", + letterSpacing: 0, + }, + activeTabText: { + color: palette.ctaCream, + }, + grid: { + paddingHorizontal: 16, + paddingTop: 2, + paddingBottom: 140, + flexDirection: "row", + flexWrap: "wrap", + columnGap: 16, + rowGap: 18, + minHeight: "78%", + }, + emptyState: { + flex: 1, + minHeight: 420, + alignItems: "center", + justifyContent: "center", + paddingHorizontal: 24, + }, + emptyIcon: { + color: palette.borderStrong, + fontSize: 48, + lineHeight: 56, + fontWeight: "800", + }, + emptyTitle: { + marginTop: 8, + color: palette.textSecondary, + fontSize: 16, + fontWeight: "800", + textAlign: "center", + }, + emptySubtext: { + marginTop: 8, + color: palette.textMuted, + fontSize: 13, + lineHeight: 19, + textAlign: "center", + }, + emptyButton: { + marginTop: 18, + ...auraButtonStyle(palette, "primary"), + minHeight: 44, + paddingHorizontal: 16, + borderRadius: 999, + }, + emptyButtonText: { + ...auraButtonTextStyle(palette, "primary"), + fontSize: 13, + lineHeight: 17, + }, + errorState: { + padding: 24, + gap: 8, + }, + errorTitle: { + color: palette.textPrimary, + fontSize: 16, + fontWeight: "800", + }, + errorText: { + color: palette.textSecondary, + lineHeight: 20, + }, +}); diff --git a/app/studio.tsx b/app/studio.tsx new file mode 100644 index 0000000..e092e3b --- /dev/null +++ b/app/studio.tsx @@ -0,0 +1,5 @@ +import { Redirect } from "expo-router"; + +export default function StudioRedirect() { + return ; +} diff --git a/assets/animations/checkmark.json b/assets/animations/checkmark.json new file mode 100644 index 0000000..ddc271d --- /dev/null +++ b/assets/animations/checkmark.json @@ -0,0 +1 @@ +{"v":"5.6.3","fr":29.9700012207031,"ip":0,"op":47.0000019143492,"w":1920,"h":1080,"nm":"Successful","ddd":0,"assets":[],"fonts":{"list":[{"fName":"SFUIDisplay-Bold","fFamily":"SF UI Display","fStyle":"Bold","ascent":73.828125}]},"layers":[{"ddd":0,"ind":1,"ty":5,"nm":"Payment Successful","sr":1,"ks":{"o":{"a":1,"k":[{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":27,"s":[0]},{"t":37.0000015070409,"s":[100]}],"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[664.641,864,0],"ix":2},"a":{"a":0,"k":[0,0,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"t":{"d":{"k":[{"s":{"s":64,"f":"SFUIDisplay-Bold","t":"","j":0,"tr":0,"lh":76.8,"ls":0,"fc":[0,1,0.592]},"t":0}]},"p":{},"m":{"g":1,"a":{"a":0,"k":[0,0],"ix":2}},"a":[]},"ip":0,"op":47.0000019143492,"st":0,"bm":0},{"ddd":0,"ind":2,"ty":4,"nm":"check","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[961.141,540,0],"ix":2},"a":{"a":0,"k":[0,0,0],"ix":1},"s":{"a":0,"k":[376.304,376.304,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0]],"v":[[-23,-3.5],[-7.5,12],[20.5,-16]],"c":false},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[1,1,1,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":12,"ix":5},"lc":2,"lj":1,"ml":4,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Shape 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false},{"ty":"tm","s":{"a":0,"k":0,"ix":1},"e":{"a":1,"k":[{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":14,"s":[0]},{"t":27.0000010997325,"s":[100]}],"ix":2},"o":{"a":0,"k":0,"ix":3},"m":1,"ix":2,"nm":"Trim Paths 1","mn":"ADBE Vector Filter - Trim","hd":false}],"ip":0,"op":47.0000019143492,"st":0,"bm":0},{"ddd":0,"ind":3,"ty":4,"nm":"elipse bold","sr":1,"ks":{"o":{"a":1,"k":[{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":0,"s":[0]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":14,"s":[81]},{"t":27.0000010997325,"s":[100]}],"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[960,535.5,0],"ix":2},"a":{"a":0,"k":[-62,-4.5,0],"ix":1},"s":{"a":1,"k":[{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.333,0.333,0.333],"y":[0,0,0]},"t":0,"s":[41.25,41.25,100]},{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.333,0.333,0.333],"y":[0,0,0]},"t":14,"s":[532.5,532.5,100]},{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.333,0.333,0.333],"y":[0,0,0]},"t":21,"s":[413.25,413.25,100]},{"t":27.0000010997325,"s":[508.75,508.75,100]}],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"d":1,"ty":"el","s":{"a":0,"k":[80,80],"ix":2},"p":{"a":0,"k":[0,0],"ix":3},"nm":"Ellipse Path 1","mn":"ADBE Vector Shape - Ellipse","hd":false},{"ty":"fl","c":{"a":0,"k":[0,0.8117647058823529,0.2,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[-62,-4.5],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Ellipse 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":47.0000019143492,"st":0,"bm":0},{"ddd":0,"ind":4,"ty":4,"nm":"elipse light","sr":1,"ks":{"o":{"a":1,"k":[{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":0,"s":[0]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":14,"s":[80]},{"t":27.0000010997325,"s":[0]}],"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[960,535.5,0],"ix":2},"a":{"a":0,"k":[-60.75,-4.5,0],"ix":1},"s":{"a":1,"k":[{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.333,0.333,0.333],"y":[0,0,0]},"t":0,"s":[100,100,100]},{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.333,0.333,0.333],"y":[0,0,0]},"t":14,"s":[530,530,100]},{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.333,0.333,0.333],"y":[0,0,0]},"t":21,"s":[431,431,100]},{"t":27.0000010997325,"s":[535,535,100]}],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"d":1,"ty":"el","s":{"a":0,"k":[122.5,122.5],"ix":2},"p":{"a":0,"k":[0,0],"ix":3},"nm":"Ellipse Path 1","mn":"ADBE Vector Shape - Ellipse","hd":false},{"ty":"fl","c":{"a":0,"k":[0.22745098039215686,0.9647058823529412,0.35294117647058826,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[-60.75,-4.25],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Ellipse 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":47.0000019143492,"st":0,"bm":0},{"ddd":0,"ind":5,"ty":4,"nm":"stars","sr":1,"ks":{"o":{"a":1,"k":[{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":9,"s":[0]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":21,"s":[100]},{"t":33.0000013441176,"s":[0]}],"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[960,540,0],"ix":2},"a":{"a":0,"k":[0,0,0],"ix":1},"s":{"a":1,"k":[{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.333,0.333,0.333],"y":[0,0,0]},"t":9,"s":[89.979,89.979,100]},{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.333,0.333,0.333],"y":[0,0,0]},"t":21,"s":[440.397,440.397,100]},{"t":33.0000013441176,"s":[554.944,554.944,100]}],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"sr","sy":1,"d":1,"pt":{"a":0,"k":5,"ix":3},"p":{"a":0,"k":[0,0],"ix":4},"r":{"a":0,"k":0,"ix":5},"ir":{"a":0,"k":4.301,"ix":6},"is":{"a":0,"k":0,"ix":8},"or":{"a":0,"k":8.602,"ix":7},"os":{"a":0,"k":0,"ix":9},"ix":1,"nm":"Polystar Path 1","mn":"ADBE Vector Shape - Star","hd":false},{"ty":"fl","c":{"a":0,"k":[1,0.5372549019607843,0.011764705882352941,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[2.5,81],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Polystar 7","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ty":"sr","sy":1,"d":1,"pt":{"a":0,"k":5,"ix":3},"p":{"a":0,"k":[0,0],"ix":4},"r":{"a":0,"k":0,"ix":5},"ir":{"a":0,"k":2.761,"ix":6},"is":{"a":0,"k":0,"ix":8},"or":{"a":0,"k":5.523,"ix":7},"os":{"a":0,"k":0,"ix":9},"ix":1,"nm":"Polystar Path 1","mn":"ADBE Vector Shape - Star","hd":false},{"ty":"fl","c":{"a":0,"k":[1,0.5372549019607843,0.011764705882352941,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[-3.5,-91.75],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Polystar 6","np":3,"cix":2,"bm":0,"ix":2,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ty":"sr","sy":1,"d":1,"pt":{"a":0,"k":5,"ix":3},"p":{"a":0,"k":[0,0],"ix":4},"r":{"a":0,"k":0,"ix":5},"ir":{"a":0,"k":4.301,"ix":6},"is":{"a":0,"k":0,"ix":8},"or":{"a":0,"k":8.602,"ix":7},"os":{"a":0,"k":0,"ix":9},"ix":1,"nm":"Polystar Path 1","mn":"ADBE Vector Shape - Star","hd":false},{"ty":"fl","c":{"a":0,"k":[1,0.5372549019607843,0.011764705882352941,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[-82,-12],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Polystar 4","np":3,"cix":2,"bm":0,"ix":3,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ty":"sr","sy":1,"d":1,"pt":{"a":0,"k":5,"ix":3},"p":{"a":0,"k":[0,0],"ix":4},"r":{"a":0,"k":0,"ix":5},"ir":{"a":0,"k":3.64,"ix":6},"is":{"a":0,"k":0,"ix":8},"or":{"a":0,"k":7.28,"ix":7},"os":{"a":0,"k":0,"ix":9},"ix":1,"nm":"Polystar Path 1","mn":"ADBE Vector Shape - Star","hd":false},{"ty":"fl","c":{"a":0,"k":[1,0.5372549019607843,0.011764705882352941,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[89.5,-14.5],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Polystar 2","np":3,"cix":2,"bm":0,"ix":4,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":47.0000019143492,"st":0,"bm":0},{"ddd":0,"ind":6,"ty":4,"nm":"circle","sr":1,"ks":{"o":{"a":1,"k":[{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":9,"s":[0]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":21,"s":[100]},{"t":33.0000013441176,"s":[0]}],"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[960,540,0],"ix":2},"a":{"a":0,"k":[0,0,0],"ix":1},"s":{"a":1,"k":[{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.333,0.333,0.333],"y":[0,0,0]},"t":9,"s":[139.752,139.752,100]},{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.333,0.333,0.333],"y":[0,0,0]},"t":21,"s":[535.117,535.117,100]},{"t":33.0000013441176,"s":[785.336,785.336,100]}],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"d":1,"ty":"el","s":{"a":0,"k":[7.5,7.5],"ix":2},"p":{"a":0,"k":[0,0],"ix":3},"nm":"Ellipse Path 1","mn":"ADBE Vector Shape - Ellipse","hd":false},{"ty":"fl","c":{"a":0,"k":[0.011764706817,1,0.596078431373,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[-0.25,58.75],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Ellipse 8","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"d":1,"ty":"el","s":{"a":0,"k":[6.5,6.5],"ix":2},"p":{"a":0,"k":[0,0],"ix":3},"nm":"Ellipse Path 1","mn":"ADBE Vector Shape - Ellipse","hd":false},{"ty":"fl","c":{"a":0,"k":[0.011764706817,1,0.596078431373,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[-4.25,-61.75],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Ellipse 7","np":3,"cix":2,"bm":0,"ix":2,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"d":1,"ty":"el","s":{"a":0,"k":[7,7],"ix":2},"p":{"a":0,"k":[0,0],"ix":3},"nm":"Ellipse Path 1","mn":"ADBE Vector Shape - Ellipse","hd":false},{"ty":"fl","c":{"a":0,"k":[0.011764705882352941,1,0.596078431372549,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[55,-14],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Ellipse 6","np":3,"cix":2,"bm":0,"ix":3,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"d":1,"ty":"el","s":{"a":0,"k":[6,6],"ix":2},"p":{"a":0,"k":[0,0],"ix":3},"nm":"Ellipse Path 1","mn":"ADBE Vector Shape - Ellipse","hd":false},{"ty":"fl","c":{"a":0,"k":[0.011764706817,1,0.596078431373,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[-56.5,9.5],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Ellipse 5","np":3,"cix":2,"bm":0,"ix":4,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"d":1,"ty":"el","s":{"a":0,"k":[11.5,11.5],"ix":2},"p":{"a":0,"k":[0,0],"ix":3},"nm":"Ellipse Path 1","mn":"ADBE Vector Shape - Ellipse","hd":false},{"ty":"fl","c":{"a":0,"k":[0.011764706817,1,0.596078431373,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[54.25,32.75],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Ellipse 4","np":3,"cix":2,"bm":0,"ix":5,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"d":1,"ty":"el","s":{"a":0,"k":[14.5,14.5],"ix":2},"p":{"a":0,"k":[0,0],"ix":3},"nm":"Ellipse Path 1","mn":"ADBE Vector Shape - Ellipse","hd":false},{"ty":"fl","c":{"a":0,"k":[0.011764706817,1,0.596078431373,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[-50.25,41.25],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Ellipse 3","np":3,"cix":2,"bm":0,"ix":6,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"d":1,"ty":"el","s":{"a":0,"k":[12.5,12.5],"ix":2},"p":{"a":0,"k":[0,0],"ix":3},"nm":"Ellipse Path 1","mn":"ADBE Vector Shape - Ellipse","hd":false},{"ty":"fl","c":{"a":0,"k":[0.011764706817,1,0.596078431373,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[-46.75,-50.25],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Ellipse 2","np":3,"cix":2,"bm":0,"ix":7,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"d":1,"ty":"el","s":{"a":0,"k":[15.5,15.5],"ix":2},"p":{"a":0,"k":[0,0],"ix":3},"nm":"Ellipse Path 1","mn":"ADBE Vector Shape - Ellipse","hd":false},{"ty":"fl","c":{"a":0,"k":[0.011764706817,1,0.596078431373,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[45.25,-60.75],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Ellipse 1","np":3,"cix":2,"bm":0,"ix":8,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":47.0000019143492,"st":0,"bm":0}],"markers":[],"chars":[{"ch":"P","size":64,"style":"Bold","w":62.5,"data":{"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0],[0,0],[0,14.453],[14.453,0]],"o":[[0,0],[0,0],[0,0],[0,0],[14.844,0],[0,-14.502],[0,0]],"v":[[5.859,-70.459],[5.859,0],[20.605,0],[20.605,-22.217],[34.521,-22.217],[59.668,-46.338],[35.254,-70.459]],"c":true},"ix":2},"nm":"P","mn":"ADBE Vector Shape - Group","hd":false},{"ind":1,"ty":"sh","ix":2,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,-8.008],[8.447,0],[0,0]],"o":[[0,0],[8.447,0],[0,8.057],[0,0],[0,0]],"v":[[20.605,-58.789],[31.348,-58.789],[44.678,-46.289],[31.299,-33.74],[20.605,-33.74]],"c":true},"ix":2},"nm":"P","mn":"ADBE Vector Shape - Group","hd":false}],"nm":"P","np":5,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}]},"fFamily":"SF UI Display"},{"ch":"a","size":64,"style":"Bold","w":56.01,"data":{"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[6.592,0],[0,3.955],[-5.957,0.391],[0,0],[0,0]],"o":[[-5.176,0],[0,-3.955],[0,0],[0,0],[0,5.859]],"v":[[25.391,-9.766],[16.846,-16.211],[25.977,-22.9],[37.256,-23.682],[37.256,-19.727]],"c":true},"ix":2},"nm":"a","mn":"ADBE Vector Shape - Group","hd":false},{"ind":1,"ty":"sh","ix":2,"ks":{"a":0,"k":{"i":[[-10.352,0],[-2.93,5.615],[0,0],[0,0],[0,0],[0,0],[13.818,0],[0.391,-10.254],[0,0],[-5.176,0],[0,-5.176],[0,0],[0,0],[0,-9.766]],"o":[[6.885,0],[0,0],[0,0],[0,0],[0,0],[0,-10.742],[-13.428,0],[0,0],[0.684,-3.906],[5.859,0],[0,0],[0,0],[-13.721,0.83],[0,9.619]],"v":[[20.752,0.635],[37.207,-8.301],[37.5,-8.301],[37.5,0],[51.27,0],[51.27,-36.865],[28.418,-54.688],[5.322,-37.354],[18.359,-37.354],[27.832,-43.994],[37.256,-35.986],[37.256,-32.422],[23.828,-31.592],[2.637,-15.479]],"c":true},"ix":2},"nm":"a","mn":"ADBE Vector Shape - Group","hd":false}],"nm":"a","np":5,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}]},"fFamily":"SF UI Display"},{"ch":"y","size":64,"style":"Bold","w":56.2,"data":{"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[-0.537,0],[-4.59,13.721],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[5.322,0],[0.488,0.098],[0,0]],"o":[[13.428,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[-1.123,4.297],[-0.586,0],[0,0],[0.537,0.098]],"v":[[11.67,19.629],[36.279,1.074],[54.883,-53.76],[39.844,-53.76],[28.174,-11.572],[27.881,-11.572],[16.26,-53.76],[0.635,-53.76],[19.482,0.342],[18.994,2.393],[9.473,8.545],[6.25,8.398],[6.25,19.434]],"c":true},"ix":2},"nm":"y","mn":"ADBE Vector Shape - Group","hd":false}],"nm":"y","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}]},"fFamily":"SF UI Display"},{"ch":"m","size":64,"style":"Bold","w":88.43,"data":{"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[-5.615,0],[0,-5.762],[0,0],[0,0],[0,0],[-5.615,0],[0,-6.299],[0,0],[0,0],[0,0],[10.547,0],[2.441,-6.738],[0,0],[7.715,0],[2.295,-6.494],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,-6.104],[5.518,0],[0,0],[0,0],[0,0],[0,-6.104],[5.811,0],[0,0],[0,0],[0,0],[0,-10.84],[-7.813,0],[0,0],[-1.807,-7.031],[-7.178,0],[0,0],[0,0],[0,0],[0,0]],"v":[[4.834,0],[19.092,0],[19.092,-32.324],[28.613,-42.725],[37.451,-33.496],[37.451,0],[51.221,0],[51.221,-32.52],[60.596,-42.725],[69.58,-32.959],[69.58,0],[83.838,0],[83.838,-36.523],[66.211,-54.541],[49.512,-43.555],[49.219,-43.555],[34.229,-54.541],[18.896,-43.994],[18.604,-43.994],[18.604,-53.76],[4.834,-53.76]],"c":true},"ix":2},"nm":"m","mn":"ADBE Vector Shape - Group","hd":false}],"nm":"m","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}]},"fFamily":"SF UI Display"},{"ch":"e","size":64,"style":"Bold","w":56.84,"data":{"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[-6.494,0],[-0.244,-7.031],[0,0]],"o":[[6.592,0],[0,0],[0.488,-6.885]],"v":[[28.809,-44.043],[40.234,-32.422],[17.041,-32.422]],"c":true},"ix":2},"nm":"e","mn":"ADBE Vector Shape - Group","hd":false},{"ind":1,"ty":"sh","ix":2,"ks":{"a":0,"k":{"i":[[0,0],[5.371,0],[0,7.764],[0,0],[0,0],[0,0],[15.479,0],[0,-17.09],[-16.357,0],[-1.563,10.4]],"o":[[-1.367,4.15],[-7.52,0],[0,0],[0,0],[0,0],[0,-16.357],[-15.771,0],[0,17.09],[13.086,0],[0,0]],"v":[[40.43,-16.406],[29.443,-9.717],[16.895,-22.705],[16.895,-23.584],[54.004,-23.584],[54.004,-27.979],[28.662,-54.688],[2.832,-26.66],[29.199,0.928],[53.564,-16.406]],"c":true},"ix":2},"nm":"e","mn":"ADBE Vector Shape - Group","hd":false}],"nm":"e","np":5,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}]},"fFamily":"SF UI Display"},{"ch":"n","size":64,"style":"Bold","w":58.94,"data":{"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[-6.836,0],[0,-7.129],[0,0],[0,0],[0,0],[11.914,0],[3.076,-6.787],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,-7.129],[6.738,0],[0,0],[0,0],[0,0],[0,-12.549],[-8.252,0],[0,0],[0,0],[0,0],[0,0]],"v":[[4.834,0],[19.092,0],[19.092,-30.664],[30.029,-42.676],[40.088,-31.494],[40.088,0],[54.346,0],[54.346,-34.473],[35.693,-54.541],[18.896,-44.141],[18.604,-44.141],[18.604,-53.76],[4.834,-53.76]],"c":true},"ix":2},"nm":"n","mn":"ADBE Vector Shape - Group","hd":false}],"nm":"n","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}]},"fFamily":"SF UI Display"},{"ch":"t","size":64,"style":"Bold","w":36.18,"data":{"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[-12.451,0],[-1.465,0.293],[0,0],[1.27,0],[0,4.15],[0,0],[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0],[0,0],[0,10.498],[2.881,0],[0,0],[-0.879,0.146],[-4.346,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0]],"v":[[8.643,-65.723],[8.643,-53.76],[1.367,-53.76],[1.367,-42.969],[8.643,-42.969],[8.643,-14.502],[25.977,0.195],[32.861,-0.391],[32.861,-10.938],[29.297,-10.693],[22.9,-16.846],[22.9,-42.969],[32.861,-42.969],[32.861,-53.76],[22.9,-53.76],[22.9,-65.723]],"c":true},"ix":2},"nm":"t","mn":"ADBE Vector Shape - Group","hd":false}],"nm":"t","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}]},"fFamily":"SF UI Display"},{"ch":" ","size":64,"style":"Bold","w":21.48,"data":{},"fFamily":"SF UI Display"},{"ch":"S","size":64,"style":"Bold","w":63.13,"data":{"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[-16.943,0],[0,13.672],[14.111,3.027],[0,0],[0,4.346],[-7.275,0],[-0.488,-5.957],[0,0],[16.406,0],[0,-12.988],[-12.988,-2.783],[0,0],[0,-4.59],[7.861,0],[0.684,5.811]],"o":[[0.439,13.232],[17.48,0],[0,-10.742],[0,0],[-8.105,-1.855],[0,-5.225],[7.373,0],[0,0],[-0.244,-12.744],[-15.723,0],[0,10.303],[0,0],[8.496,1.904],[0,5.176],[-7.813,0],[0,0]],"v":[[3.223,-20.068],[31.396,1.221],[59.961,-21.094],[39.697,-40.918],[31.152,-42.725],[19.629,-51.367],[31.787,-60.059],[44.873,-50.488],[58.691,-50.488],[31.738,-71.68],[4.785,-50.098],[24.17,-30.42],[33.35,-28.418],[45.215,-19.385],[31.982,-10.449],[17.432,-20.068]],"c":true},"ix":2},"nm":"S","mn":"ADBE Vector Shape - Group","hd":false}],"nm":"S","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}]},"fFamily":"SF UI Display"},{"ch":"u","size":64,"style":"Bold","w":58.94,"data":{"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[6.641,0],[0,7.275],[0,0],[0,0],[0,0],[-11.621,0],[-2.734,6.738],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,7.275],[-6.592,0],[0,0],[0,0],[0,0],[0,12.5],[8.154,0],[0,0],[0,0],[0,0],[0,0]],"v":[[54.102,-53.76],[39.844,-53.76],[39.844,-23.145],[29.053,-11.084],[18.848,-22.266],[18.848,-53.76],[4.59,-53.76],[4.59,-19.287],[23.877,0.781],[40.039,-9.717],[40.332,-9.717],[40.332,0],[54.102,0]],"c":true},"ix":2},"nm":"u","mn":"ADBE Vector Shape - Group","hd":false}],"nm":"u","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}]},"fFamily":"SF UI Display"},{"ch":"c","size":64,"style":"Bold","w":55.76,"data":{"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[14.453,0],[0,-17.041],[-16.064,0],[-0.83,12.5],[0,0],[6.006,0],[0,10.498],[-7.324,0],[-0.781,-5.566]],"o":[[-0.684,-11.963],[-15.967,0],[0,17.285],[14.111,0],[0,0],[-0.928,5.908],[-7.373,0],[0,-10.303],[6.201,0],[0,0]],"v":[[53.418,-33.936],[29.053,-54.688],[2.832,-26.904],[29.15,0.928],[53.467,-19.531],[40.186,-19.531],[29.297,-10.352],[17.285,-26.904],[29.248,-43.457],[40.137,-33.936]],"c":true},"ix":2},"nm":"c","mn":"ADBE Vector Shape - Group","hd":false}],"nm":"c","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}]},"fFamily":"SF UI Display"},{"ch":"s","size":64,"style":"Bold","w":52.64,"data":{"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,-10.107],[-10.107,-2.295],[0,0],[0,-2.783],[5.664,0],[0.684,4.541],[0,0],[-14.16,0],[0,10.449],[10.791,2.441],[0,0],[0,2.686],[-5.127,0],[-0.293,-4.248],[0,0],[13.281,0]],"o":[[0,7.861],[0,0],[4.883,1.172],[0,3.516],[-5.908,0],[0,0],[0.732,10.352],[14.063,0],[0,-8.008],[0,0],[-4.834,-1.123],[0,-3.467],[5.469,0],[0,0],[-0.195,-10.254],[-13.623,0]],"v":[[3.906,-37.939],[19.238,-22.559],[29.15,-20.313],[35.889,-14.893],[26.758,-9.18],[16.504,-16.26],[2.588,-16.26],[26.367,0.928],[50.049,-16.406],[34.961,-31.006],[24.854,-33.203],[17.676,-38.818],[26.221,-44.58],[35.547,-37.646],[48.73,-37.646],[26.416,-54.688]],"c":true},"ix":2},"nm":"s","mn":"ADBE Vector Shape - Group","hd":false}],"nm":"s","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}]},"fFamily":"SF UI Display"},{"ch":"f","size":64,"style":"Bold","w":35.84,"data":{"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[-4.297,0],[-1.025,-0.195],[0,0],[2.441,0],[0,-10.352],[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0.049,-3.76],[1.563,0],[0,0],[-1.953,-0.293],[-12.451,0],[0,0],[0,0],[0,0],[0,0],[0,0]],"v":[[8.643,0],[22.9,0],[22.9,-42.969],[32.666,-42.969],[32.666,-53.76],[22.607,-53.76],[22.607,-57.715],[28.711,-63.086],[32.617,-62.744],[32.617,-72.656],[26.025,-73.145],[8.643,-58.545],[8.643,-53.76],[1.367,-53.76],[1.367,-42.969],[8.643,-42.969]],"c":true},"ix":2},"nm":"f","mn":"ADBE Vector Shape - Group","hd":false}],"nm":"f","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}]},"fFamily":"SF UI Display"},{"ch":"l","size":64,"style":"Bold","w":24.66,"data":{"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[5.225,0],[19.482,0],[19.482,-73.828],[5.225,-73.828]],"c":true},"ix":2},"nm":"l","mn":"ADBE Vector Shape - Group","hd":false}],"nm":"l","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}]},"fFamily":"SF UI Display"}]} \ No newline at end of file diff --git a/assets/animations/loading.json b/assets/animations/loading.json new file mode 100644 index 0000000..4046a96 --- /dev/null +++ b/assets/animations/loading.json @@ -0,0 +1 @@ +{"v":"5.7.8","fr":29.9700012207031,"ip":0,"op":30.0000012219251,"w":500,"h":500,"nm":"Loading","ddd":0,"assets":[],"layers":[{"ddd":0,"ind":1,"ty":4,"nm":"Shape Layer 4","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.833,"y":1},"o":{"x":0.167,"y":0},"t":13,"s":[546,266,0],"to":[0,-11,0],"ti":[0,0,0]},{"i":{"x":0.833,"y":1},"o":{"x":0.167,"y":0},"t":17,"s":[546,200,0],"to":[0,0,0],"ti":[0,-11,0]},{"t":21.0000008553475,"s":[546,266,0]}],"ix":2,"l":2,"x":"var $bm_rt;\nvar $bm_rt;\nvar n, n, t, t, v, amp, freq, decay;\n$bm_rt = $bm_rt = n = 0;\nif (numKeys > 0) {\n $bm_rt = $bm_rt = n = nearestKey(time).index;\n if (key(n).time > time) {\n n--;\n }\n}\nif (n == 0) {\n $bm_rt = $bm_rt = t = 0;\n} else {\n $bm_rt = $bm_rt = t = sub(time, key(n).time);\n}\nif (n > 0 && t < 1) {\n v = velocityAtTime(sub(key(n).time, div(thisComp.frameDuration, 10)));\n amp = 0.05;\n freq = 4;\n decay = 8;\n $bm_rt = $bm_rt = add(value, div(mul(mul(v, amp), Math.sin(mul(mul(mul(freq, t), 2), Math.PI))), Math.exp(mul(decay, t))));\n} else {\n $bm_rt = $bm_rt = value;\n}"},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"d":1,"ty":"el","s":{"a":0,"k":[45.137,45.137],"ix":2},"p":{"a":0,"k":[0,0],"ix":3},"nm":"Ellipse Path 1","mn":"ADBE Vector Shape - Ellipse","hd":false},{"ty":"st","c":{"a":0,"k":[1,0.7490196078431373,0.24705882352941178,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":8,"ix":5},"lc":1,"lj":1,"ml":4,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"fl","c":{"a":0,"k":[1,0.7490196078431373,0.24705882352941178,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[-167.432,-17.432],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Ellipse 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":30.0000012219251,"st":0,"bm":0},{"ddd":0,"ind":2,"ty":4,"nm":"Shape Layer 3","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.833,"y":1},"o":{"x":0.167,"y":0},"t":9,"s":[454,266,0],"to":[0,-11,0],"ti":[0,0,0]},{"i":{"x":0.833,"y":1},"o":{"x":0.167,"y":0},"t":13,"s":[454,200,0],"to":[0,0,0],"ti":[0,-11,0]},{"t":17.0000006924242,"s":[454,266,0]}],"ix":2,"l":2,"x":"var $bm_rt;\nvar $bm_rt;\nvar n, n, t, t, v, amp, freq, decay;\n$bm_rt = $bm_rt = n = 0;\nif (numKeys > 0) {\n $bm_rt = $bm_rt = n = nearestKey(time).index;\n if (key(n).time > time) {\n n--;\n }\n}\nif (n == 0) {\n $bm_rt = $bm_rt = t = 0;\n} else {\n $bm_rt = $bm_rt = t = sub(time, key(n).time);\n}\nif (n > 0 && t < 1) {\n v = velocityAtTime(sub(key(n).time, div(thisComp.frameDuration, 10)));\n amp = 0.05;\n freq = 4;\n decay = 8;\n $bm_rt = $bm_rt = add(value, div(mul(mul(v, amp), Math.sin(mul(mul(mul(freq, t), 2), Math.PI))), Math.exp(mul(decay, t))));\n} else {\n $bm_rt = $bm_rt = value;\n}"},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"d":1,"ty":"el","s":{"a":0,"k":[45.137,45.137],"ix":2},"p":{"a":0,"k":[0,0],"ix":3},"nm":"Ellipse Path 1","mn":"ADBE Vector Shape - Ellipse","hd":false},{"ty":"st","c":{"a":0,"k":[0.3568627450980392,0.7607843137254902,0.9058823529411765,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":8,"ix":5},"lc":1,"lj":1,"ml":4,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"fl","c":{"a":0,"k":[0.3568627450980392,0.7607843137254902,0.9058823529411765,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[-167.432,-17.432],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Ellipse 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":30.0000012219251,"st":-359.00001462237,"bm":0},{"ddd":0,"ind":3,"ty":4,"nm":"Shape Layer 2","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.833,"y":1},"o":{"x":0.167,"y":0},"t":4,"s":[365,266,0],"to":[0,-11,0],"ti":[0,0,0]},{"i":{"x":0.833,"y":1},"o":{"x":0.167,"y":0},"t":9,"s":[365,200,0],"to":[0,0,0],"ti":[0,-11,0]},{"t":13.0000005295009,"s":[365,266,0]}],"ix":2,"l":2,"x":"var $bm_rt;\nvar $bm_rt;\nvar n, n, t, t, v, amp, freq, decay;\n$bm_rt = $bm_rt = n = 0;\nif (numKeys > 0) {\n $bm_rt = $bm_rt = n = nearestKey(time).index;\n if (key(n).time > time) {\n n--;\n }\n}\nif (n == 0) {\n $bm_rt = $bm_rt = t = 0;\n} else {\n $bm_rt = $bm_rt = t = sub(time, key(n).time);\n}\nif (n > 0 && t < 1) {\n v = velocityAtTime(sub(key(n).time, div(thisComp.frameDuration, 10)));\n amp = 0.05;\n freq = 4;\n decay = 8;\n $bm_rt = $bm_rt = add(value, div(mul(mul(v, amp), Math.sin(mul(mul(mul(freq, t), 2), Math.PI))), Math.exp(mul(decay, t))));\n} else {\n $bm_rt = $bm_rt = value;\n}"},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"d":1,"ty":"el","s":{"a":0,"k":[45.137,45.137],"ix":2},"p":{"a":0,"k":[0,0],"ix":3},"nm":"Ellipse Path 1","mn":"ADBE Vector Shape - Ellipse","hd":false},{"ty":"st","c":{"a":0,"k":[0,0.7019607843137254,0.5333333333333333,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":8,"ix":5},"lc":1,"lj":1,"ml":4,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"fl","c":{"a":0,"k":[0,0.7019607843137254,0.5333333333333333,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[-167.432,-17.432],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Ellipse 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":30.0000012219251,"st":0,"bm":0},{"ddd":0,"ind":4,"ty":4,"nm":"Shape Layer 1","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.833,"y":1},"o":{"x":0.167,"y":0},"t":0,"s":[277,266,0],"to":[0,-11,0],"ti":[0,0,0]},{"i":{"x":0.833,"y":1},"o":{"x":0.167,"y":0},"t":4,"s":[277,200,0],"to":[0,0,0],"ti":[0,-11,0]},{"t":9.00000036657752,"s":[277,266,0]}],"ix":2,"l":2,"x":"var $bm_rt;\nvar $bm_rt;\nvar n, n, t, t, v, amp, freq, decay;\n$bm_rt = $bm_rt = n = 0;\nif (numKeys > 0) {\n $bm_rt = $bm_rt = n = nearestKey(time).index;\n if (key(n).time > time) {\n n--;\n }\n}\nif (n == 0) {\n $bm_rt = $bm_rt = t = 0;\n} else {\n $bm_rt = $bm_rt = t = sub(time, key(n).time);\n}\nif (n > 0 && t < 1) {\n v = velocityAtTime(sub(key(n).time, div(thisComp.frameDuration, 10)));\n amp = 0.05;\n freq = 4;\n decay = 8;\n $bm_rt = $bm_rt = add(value, div(mul(mul(v, amp), Math.sin(mul(mul(mul(freq, t), 2), Math.PI))), Math.exp(mul(decay, t))));\n} else {\n $bm_rt = $bm_rt = value;\n}"},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"d":1,"ty":"el","s":{"a":0,"k":[45.137,45.137],"ix":2},"p":{"a":0,"k":[0,0],"ix":3},"nm":"Ellipse Path 1","mn":"ADBE Vector Shape - Ellipse","hd":false},{"ty":"st","c":{"a":0,"k":[0.06274509803921569,0.023529411764705882,0.6235294117647059,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":8,"ix":5},"lc":1,"lj":1,"ml":4,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"fl","c":{"a":0,"k":[0.06274509803921569,0.023529411764705882,0.6235294117647059,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[-167.432,-17.432],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Ellipse 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":30.0000012219251,"st":0,"bm":0}],"markers":[]} \ No newline at end of file diff --git a/assets/animations/sparkle.json b/assets/animations/sparkle.json new file mode 100644 index 0000000..f49ad42 --- /dev/null +++ b/assets/animations/sparkle.json @@ -0,0 +1 @@ +{"v":"5.7.12","fr":30,"ip":0,"op":60,"w":512,"h":512,"nm":"Confetti 1","ddd":0,"assets":[],"layers":[{"ddd":0,"ind":1,"ty":3,"nm":"Null 3","sr":1,"ks":{"o":{"a":0,"k":0,"ix":11},"r":{"a":0,"k":260,"ix":10},"p":{"a":0,"k":[256,256,0],"ix":2,"l":2},"a":{"a":0,"k":[50,50,0],"ix":1,"l":2},"s":{"a":0,"k":[102,102,100],"ix":6,"l":2}},"ao":0,"ip":5,"op":60,"st":0,"bm":0},{"ddd":0,"ind":2,"ty":4,"nm":"Shape Layer 50","parent":1,"sr":1,"ks":{"o":{"a":1,"k":[{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":14,"s":[0]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":19,"s":[100]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":41,"s":[100]},{"t":57,"s":[0]}],"ix":11},"r":{"a":0,"k":487,"ix":10},"p":{"a":0,"k":[50,50,0],"ix":2,"l":2},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":0,"k":[41,41,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[-19.362,34.498],[7.899,-25.433],[37.607,53.867],[11.905,108.592]],"o":[[0,0],[42.457,-75.648],[-9.025,29.061],[-47.221,-67.637],[-3.807,-34.72]],"v":[[1,2],[-48.536,-23.607],[-76.476,-100.021],[27.098,-130.112],[-120.809,-222.715]],"c":false},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[0.49411764705882355,0.8274509803921568,0.12941176470588237,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":5,"ix":5,"x":"var $bm_rt;\n$bm_rt = wiggle(5, 3);"},"lc":1,"lj":1,"ml":4,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Shape 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0],"y":[1]},"o":{"x":[0.139],"y":[0]},"t":17,"s":[0]},{"t":54,"s":[100]}],"ix":1},"e":{"a":1,"k":[{"i":{"x":[0],"y":[1]},"o":{"x":[0.139],"y":[0]},"t":14,"s":[0]},{"t":49,"s":[100]}],"ix":2},"o":{"a":0,"k":0,"ix":3},"m":1,"ix":2,"nm":"Trim Paths 1","mn":"ADBE Vector Filter - Trim","hd":false}],"ip":14,"op":60,"st":14,"bm":0},{"ddd":0,"ind":3,"ty":4,"nm":"Shape Layer 49","parent":1,"sr":1,"ks":{"o":{"a":1,"k":[{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":13,"s":[0]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":18,"s":[100]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":40,"s":[100]},{"t":56,"s":[0]}],"ix":11},"r":{"a":0,"k":353,"ix":10},"p":{"a":0,"k":[50,50,0],"ix":2,"l":2},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":0,"k":[70,70,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[-14,37],[8.506,-58.529],[28.328,67.272],[11.905,108.592]],"o":[[0,0],[9.966,-26.338],[-12.365,85.083],[-32.013,-76.024],[-3.807,-34.72]],"v":[[1,2],[-48.536,-23.607],[-76.476,-100.021],[27.098,-130.112],[-120.809,-222.715]],"c":false},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[0.728918397193,0.113771438599,1,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":5,"ix":5,"x":"var $bm_rt;\n$bm_rt = wiggle(5, 3);"},"lc":1,"lj":1,"ml":4,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Shape 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0],"y":[1]},"o":{"x":[0.139],"y":[0]},"t":16,"s":[0]},{"t":53,"s":[100]}],"ix":1},"e":{"a":1,"k":[{"i":{"x":[0],"y":[1]},"o":{"x":[0.139],"y":[0]},"t":13,"s":[0]},{"t":48,"s":[100]}],"ix":2},"o":{"a":0,"k":0,"ix":3},"m":1,"ix":2,"nm":"Trim Paths 1","mn":"ADBE Vector Filter - Trim","hd":false}],"ip":13,"op":60,"st":13,"bm":0},{"ddd":0,"ind":4,"ty":4,"nm":"Shape Layer 48","parent":1,"sr":1,"ks":{"o":{"a":1,"k":[{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":12,"s":[0]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":17,"s":[100]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":39,"s":[100]},{"t":55,"s":[0]}],"ix":11},"r":{"a":0,"k":187,"ix":10},"p":{"a":0,"k":[50,50,0],"ix":2,"l":2},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":0,"k":[70,70,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[-14,37],[8.254,-31.239],[20.685,70.001],[17.795,30.055]],"o":[[0,0],[9.966,-26.338],[-7.439,28.154],[-10.769,-36.442],[-17.795,-30.055]],"v":[[1,2],[2.692,-79.782],[-49.416,-90.653],[-14.589,-135.23],[-105.31,-168.421]],"c":false},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[1,0.89785563151,0.113771438599,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":5,"ix":5,"x":"var $bm_rt;\n$bm_rt = wiggle(5, 3);"},"lc":1,"lj":1,"ml":4,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Shape 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0],"y":[1]},"o":{"x":[0.139],"y":[0]},"t":15,"s":[0]},{"t":52,"s":[100]}],"ix":1},"e":{"a":1,"k":[{"i":{"x":[0],"y":[1]},"o":{"x":[0.139],"y":[0]},"t":12,"s":[0]},{"t":47,"s":[100]}],"ix":2},"o":{"a":0,"k":0,"ix":3},"m":1,"ix":2,"nm":"Trim Paths 1","mn":"ADBE Vector Filter - Trim","hd":false}],"ip":12,"op":60,"st":12,"bm":0},{"ddd":0,"ind":5,"ty":4,"nm":"Shape Layer 47","parent":1,"sr":1,"ks":{"o":{"a":1,"k":[{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":11,"s":[0]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":16,"s":[100]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":38,"s":[100]},{"t":54,"s":[0]}],"ix":11},"r":{"a":0,"k":203,"ix":10},"p":{"a":0,"k":[50,50,0],"ix":2,"l":2},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":0,"k":[58,58,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[-14,37],[-1,38],[35.009,-27.828],[-11,31]],"o":[[0,0],[9.966,-26.338],[1,-38],[-39,31],[11,-31]],"v":[[1,2],[48,-39],[29,-112],[136,-107],[103,-176]],"c":false},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[1,0.104197326361,0.38322987276,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":5,"ix":5,"x":"var $bm_rt;\n$bm_rt = wiggle(5, 3);"},"lc":1,"lj":1,"ml":4,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Shape 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0],"y":[1]},"o":{"x":[0.139],"y":[0]},"t":14,"s":[0]},{"t":51,"s":[100]}],"ix":1},"e":{"a":1,"k":[{"i":{"x":[0],"y":[1]},"o":{"x":[0.139],"y":[0]},"t":11,"s":[0]},{"t":46,"s":[100]}],"ix":2},"o":{"a":0,"k":0,"ix":3},"m":1,"ix":2,"nm":"Trim Paths 1","mn":"ADBE Vector Filter - Trim","hd":false}],"ip":11,"op":60,"st":11,"bm":0},{"ddd":0,"ind":6,"ty":4,"nm":"Shape Layer 46","parent":1,"sr":1,"ks":{"o":{"a":1,"k":[{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":10,"s":[0]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":15,"s":[100]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":37,"s":[100]},{"t":53,"s":[0]}],"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[50,50,0],"ix":2,"l":2},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[-14,37],[-1,38],[35.009,-27.828],[-11,31]],"o":[[0,0],[9.966,-26.338],[1,-38],[-39,31],[11,-31]],"v":[[1,2],[48,-39],[29,-112],[136,-107],[103,-176]],"c":false},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[0.3137254901960784,0.8901960784313725,0.7607843137254902,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":5,"ix":5,"x":"var $bm_rt;\n$bm_rt = wiggle(5, 3);"},"lc":1,"lj":1,"ml":4,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Shape 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0],"y":[1]},"o":{"x":[0.139],"y":[0]},"t":13,"s":[0]},{"t":50,"s":[100]}],"ix":1},"e":{"a":1,"k":[{"i":{"x":[0],"y":[1]},"o":{"x":[0.139],"y":[0]},"t":10,"s":[0]},{"t":45,"s":[100]}],"ix":2},"o":{"a":0,"k":0,"ix":3},"m":1,"ix":2,"nm":"Trim Paths 1","mn":"ADBE Vector Filter - Trim","hd":false}],"ip":10,"op":60,"st":10,"bm":0},{"ddd":0,"ind":7,"ty":4,"nm":"Shape Layer 45","sr":1,"ks":{"o":{"a":1,"k":[{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":9,"s":[0]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":14,"s":[100]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":36,"s":[100]},{"t":52,"s":[0]}],"ix":11},"r":{"a":0,"k":487,"ix":10},"p":{"a":0,"k":[256,256,0],"ix":2,"l":2},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":0,"k":[41,41,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[-19.362,34.498],[7.899,-25.433],[37.607,53.867],[11.905,108.592]],"o":[[0,0],[42.457,-75.648],[-9.025,29.061],[-47.221,-67.637],[-3.807,-34.72]],"v":[[1,2],[-48.536,-23.607],[-76.476,-100.021],[27.098,-130.112],[-120.809,-222.715]],"c":false},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[0.49411764705882355,0.8274509803921568,0.12941176470588237,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":5,"ix":5,"x":"var $bm_rt;\n$bm_rt = wiggle(5, 3);"},"lc":1,"lj":1,"ml":4,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Shape 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0],"y":[1]},"o":{"x":[0.139],"y":[0]},"t":12,"s":[0]},{"t":49,"s":[100]}],"ix":1},"e":{"a":1,"k":[{"i":{"x":[0],"y":[1]},"o":{"x":[0.139],"y":[0]},"t":9,"s":[0]},{"t":44,"s":[100]}],"ix":2},"o":{"a":0,"k":0,"ix":3},"m":1,"ix":2,"nm":"Trim Paths 1","mn":"ADBE Vector Filter - Trim","hd":false}],"ip":9,"op":60,"st":9,"bm":0},{"ddd":0,"ind":8,"ty":4,"nm":"Shape Layer 43","sr":1,"ks":{"o":{"a":1,"k":[{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":8,"s":[0]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":13,"s":[100]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":35,"s":[100]},{"t":51,"s":[0]}],"ix":11},"r":{"a":0,"k":353,"ix":10},"p":{"a":0,"k":[256,256,0],"ix":2,"l":2},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":0,"k":[70,70,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[-14,37],[8.506,-58.529],[28.328,67.272],[11.905,108.592]],"o":[[0,0],[9.966,-26.338],[-12.365,85.083],[-32.013,-76.024],[-3.807,-34.72]],"v":[[1,2],[-48.536,-23.607],[-76.476,-100.021],[27.098,-130.112],[-120.809,-222.715]],"c":false},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[0.728918397193,0.113771438599,1,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":5,"ix":5,"x":"var $bm_rt;\n$bm_rt = wiggle(5, 3);"},"lc":1,"lj":1,"ml":4,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Shape 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0],"y":[1]},"o":{"x":[0.139],"y":[0]},"t":11,"s":[0]},{"t":48,"s":[100]}],"ix":1},"e":{"a":1,"k":[{"i":{"x":[0],"y":[1]},"o":{"x":[0.139],"y":[0]},"t":8,"s":[0]},{"t":43,"s":[100]}],"ix":2},"o":{"a":0,"k":0,"ix":3},"m":1,"ix":2,"nm":"Trim Paths 1","mn":"ADBE Vector Filter - Trim","hd":false}],"ip":8,"op":60,"st":8,"bm":0},{"ddd":0,"ind":9,"ty":4,"nm":"Shape Layer 42","sr":1,"ks":{"o":{"a":1,"k":[{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":7,"s":[0]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":12,"s":[100]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":34,"s":[100]},{"t":50,"s":[0]}],"ix":11},"r":{"a":0,"k":187,"ix":10},"p":{"a":0,"k":[256,256,0],"ix":2,"l":2},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":0,"k":[70,70,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[-14,37],[8.254,-31.239],[20.685,70.001],[17.795,30.055]],"o":[[0,0],[9.966,-26.338],[-7.439,28.154],[-10.769,-36.442],[-17.795,-30.055]],"v":[[1,2],[2.692,-79.782],[-49.416,-90.653],[-14.589,-135.23],[-105.31,-168.421]],"c":false},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[1,0.89785563151,0.113771438599,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":5,"ix":5,"x":"var $bm_rt;\n$bm_rt = wiggle(5, 3);"},"lc":1,"lj":1,"ml":4,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Shape 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0],"y":[1]},"o":{"x":[0.139],"y":[0]},"t":10,"s":[0]},{"t":47,"s":[100]}],"ix":1},"e":{"a":1,"k":[{"i":{"x":[0],"y":[1]},"o":{"x":[0.139],"y":[0]},"t":7,"s":[0]},{"t":42,"s":[100]}],"ix":2},"o":{"a":0,"k":0,"ix":3},"m":1,"ix":2,"nm":"Trim Paths 1","mn":"ADBE Vector Filter - Trim","hd":false}],"ip":7,"op":60,"st":7,"bm":0},{"ddd":0,"ind":10,"ty":4,"nm":"Shape Layer 44","sr":1,"ks":{"o":{"a":1,"k":[{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":6,"s":[0]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":11,"s":[100]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":33,"s":[100]},{"t":49,"s":[0]}],"ix":11},"r":{"a":0,"k":203,"ix":10},"p":{"a":0,"k":[256,256,0],"ix":2,"l":2},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":0,"k":[58,58,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[-14,37],[-1,38],[35.009,-27.828],[-11,31]],"o":[[0,0],[9.966,-26.338],[1,-38],[-39,31],[11,-31]],"v":[[1,2],[48,-39],[29,-112],[136,-107],[103,-176]],"c":false},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[1,0.104197326361,0.38322987276,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":5,"ix":5,"x":"var $bm_rt;\n$bm_rt = wiggle(5, 3);"},"lc":1,"lj":1,"ml":4,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Shape 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0],"y":[1]},"o":{"x":[0.139],"y":[0]},"t":9,"s":[0]},{"t":46,"s":[100]}],"ix":1},"e":{"a":1,"k":[{"i":{"x":[0],"y":[1]},"o":{"x":[0.139],"y":[0]},"t":6,"s":[0]},{"t":41,"s":[100]}],"ix":2},"o":{"a":0,"k":0,"ix":3},"m":1,"ix":2,"nm":"Trim Paths 1","mn":"ADBE Vector Filter - Trim","hd":false}],"ip":6,"op":60,"st":6,"bm":0},{"ddd":0,"ind":11,"ty":4,"nm":"Shape Layer 41","sr":1,"ks":{"o":{"a":1,"k":[{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":5,"s":[0]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":10,"s":[100]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":32,"s":[100]},{"t":48,"s":[0]}],"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[256,256,0],"ix":2,"l":2},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[-14,37],[-1,38],[35.009,-27.828],[-11,31]],"o":[[0,0],[9.966,-26.338],[1,-38],[-39,31],[11,-31]],"v":[[1,2],[48,-39],[29,-112],[136,-107],[103,-176]],"c":false},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[0.3137254901960784,0.8901960784313725,0.7607843137254902,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":5,"ix":5,"x":"var $bm_rt;\n$bm_rt = wiggle(5, 3);"},"lc":1,"lj":1,"ml":4,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Shape 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0],"y":[1]},"o":{"x":[0.139],"y":[0]},"t":8,"s":[0]},{"t":45,"s":[100]}],"ix":1},"e":{"a":1,"k":[{"i":{"x":[0],"y":[1]},"o":{"x":[0.139],"y":[0]},"t":5,"s":[0]},{"t":40,"s":[100]}],"ix":2},"o":{"a":0,"k":0,"ix":3},"m":1,"ix":2,"nm":"Trim Paths 1","mn":"ADBE Vector Filter - Trim","hd":false}],"ip":5,"op":60,"st":5,"bm":0},{"ddd":0,"ind":12,"ty":4,"nm":"Shape Layer 40","sr":1,"ks":{"o":{"a":1,"k":[{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":6,"s":[0]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":11,"s":[100]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":33,"s":[100]},{"t":49,"s":[0]}],"ix":11},"r":{"a":0,"k":92,"ix":10,"x":"var $bm_rt;\n$bm_rt = $bm_mul(time, 300);"},"p":{"a":1,"k":[{"i":{"x":0.833,"y":0.647},"o":{"x":0.167,"y":0.167},"t":6,"s":[269.95,307.113,0],"to":[-0.323,-0.36,0],"ti":[1.346,1.541,0]},{"i":{"x":0.833,"y":0.769},"o":{"x":0.167,"y":0.109},"t":7,"s":[268.011,304.951,0],"to":[-1.346,-1.541,0],"ti":[2.46,3.542,0]},{"i":{"x":0.833,"y":0.824},"o":{"x":0.167,"y":0.13},"t":8,"s":[261.875,297.864,0],"to":[-2.46,-3.542,0],"ti":[2.232,5.312,0]},{"i":{"x":0.833,"y":0.844},"o":{"x":0.167,"y":0.159},"t":9,"s":[253.252,283.701,0],"to":[-2.232,-5.312,0],"ti":[0.637,5.627,0]},{"i":{"x":0.833,"y":0.847},"o":{"x":0.167,"y":0.178},"t":10,"s":[248.481,265.992,0],"to":[-0.637,-5.627,0],"ti":[-0.921,4.788,0]},{"i":{"x":0.833,"y":0.846},"o":{"x":0.167,"y":0.183},"t":11,"s":[249.431,249.939,0],"to":[0.921,-4.788,0],"ti":[-1.796,3.705,0]},{"i":{"x":0.833,"y":0.845},"o":{"x":0.167,"y":0.182},"t":12,"s":[254.007,237.262,0],"to":[1.796,-3.705,0],"ti":[-2.147,2.784,0]},{"i":{"x":0.833,"y":0.844},"o":{"x":0.167,"y":0.18},"t":13,"s":[260.206,227.707,0],"to":[2.147,-2.784,0],"ti":[-2.218,2.088,0]},{"i":{"x":0.833,"y":0.843},"o":{"x":0.167,"y":0.179},"t":14,"s":[266.89,220.559,0],"to":[2.218,-2.088,0],"ti":[-2.161,1.578,0]},{"i":{"x":0.833,"y":0.842},"o":{"x":0.167,"y":0.178},"t":15,"s":[273.513,215.179,0],"to":[2.161,-1.578,0],"ti":[-2.053,1.203,0]},{"i":{"x":0.833,"y":0.841},"o":{"x":0.167,"y":0.177},"t":16,"s":[279.855,211.089,0],"to":[2.053,-1.203,0],"ti":[-1.93,0.918,0]},{"i":{"x":0.833,"y":0.841},"o":{"x":0.167,"y":0.176},"t":17,"s":[285.832,207.964,0],"to":[1.93,-0.918,0],"ti":[-1.801,0.7,0]},{"i":{"x":0.833,"y":0.841},"o":{"x":0.167,"y":0.175},"t":18,"s":[291.433,205.582,0],"to":[1.801,-0.7,0],"ti":[-1.666,0.538,0]},{"i":{"x":0.833,"y":0.841},"o":{"x":0.167,"y":0.175},"t":19,"s":[296.641,203.764,0],"to":[1.666,-0.538,0],"ti":[-1.53,0.417,0]},{"i":{"x":0.833,"y":0.841},"o":{"x":0.167,"y":0.175},"t":20,"s":[301.428,202.354,0],"to":[1.53,-0.417,0],"ti":[-1.402,0.32,0]},{"i":{"x":0.833,"y":0.841},"o":{"x":0.167,"y":0.175},"t":21,"s":[305.818,201.265,0],"to":[1.402,-0.32,0],"ti":[-1.283,0.243,0]},{"i":{"x":0.833,"y":0.841},"o":{"x":0.167,"y":0.175},"t":22,"s":[309.838,200.433,0],"to":[1.283,-0.243,0],"ti":[-1.172,0.18,0]},{"i":{"x":0.833,"y":0.841},"o":{"x":0.167,"y":0.175},"t":23,"s":[313.514,199.809,0],"to":[1.172,-0.18,0],"ti":[-1.068,0.128,0]},{"i":{"x":0.833,"y":0.841},"o":{"x":0.167,"y":0.175},"t":24,"s":[316.868,199.355,0],"to":[1.068,-0.128,0],"ti":[-0.97,0.084,0]},{"i":{"x":0.833,"y":0.841},"o":{"x":0.167,"y":0.175},"t":25,"s":[319.92,199.043,0],"to":[0.97,-0.084,0],"ti":[-0.879,0.048,0]},{"i":{"x":0.833,"y":0.842},"o":{"x":0.167,"y":0.176},"t":26,"s":[322.69,198.85,0],"to":[0.879,-0.048,0],"ti":[-0.793,0.017,0]},{"i":{"x":0.833,"y":0.842},"o":{"x":0.167,"y":0.176},"t":27,"s":[325.194,198.756,0],"to":[0.793,-0.017,0],"ti":[-0.712,-0.009,0]},{"i":{"x":0.833,"y":0.842},"o":{"x":0.167,"y":0.176},"t":28,"s":[327.449,198.747,0],"to":[0.712,0.009,0],"ti":[-0.636,-0.03,0]},{"i":{"x":0.833,"y":0.843},"o":{"x":0.167,"y":0.177},"t":29,"s":[329.469,198.808,0],"to":[0.636,0.03,0],"ti":[-0.564,-0.048,0]},{"i":{"x":0.833,"y":0.843},"o":{"x":0.167,"y":0.177},"t":30,"s":[331.266,198.928,0],"to":[0.564,0.048,0],"ti":[-0.497,-0.063,0]},{"i":{"x":0.833,"y":0.844},"o":{"x":0.167,"y":0.178},"t":31,"s":[332.854,199.098,0],"to":[0.497,0.063,0],"ti":[-0.434,-0.076,0]},{"i":{"x":0.833,"y":0.844},"o":{"x":0.167,"y":0.178},"t":32,"s":[334.247,199.309,0],"to":[0.434,0.076,0],"ti":[-0.375,-0.085,0]},{"i":{"x":0.833,"y":0.845},"o":{"x":0.167,"y":0.179},"t":33,"s":[335.457,199.552,0],"to":[0.375,0.085,0],"ti":[-0.32,-0.092,0]},{"i":{"x":0.833,"y":0.846},"o":{"x":0.167,"y":0.18},"t":34,"s":[336.496,199.819,0],"to":[0.32,0.092,0],"ti":[-0.269,-0.097,0]},{"i":{"x":0.833,"y":0.846},"o":{"x":0.167,"y":0.181},"t":35,"s":[337.376,200.105,0],"to":[0.269,0.097,0],"ti":[-0.221,-0.099,0]},{"i":{"x":0.833,"y":0.847},"o":{"x":0.167,"y":0.182},"t":36,"s":[338.108,200.401,0],"to":[0.221,0.099,0],"ti":[-0.178,-0.1,0]},{"i":{"x":0.833,"y":0.848},"o":{"x":0.167,"y":0.183},"t":37,"s":[338.704,200.702,0],"to":[0.178,0.1,0],"ti":[-0.138,-0.098,0]},{"i":{"x":0.833,"y":0.849},"o":{"x":0.167,"y":0.184},"t":38,"s":[339.176,200.999,0],"to":[0.138,0.098,0],"ti":[-0.103,-0.094,0]},{"i":{"x":0.833,"y":0.85},"o":{"x":0.167,"y":0.186},"t":39,"s":[339.534,201.288,0],"to":[0.103,0.094,0],"ti":[-0.071,-0.087,0]},{"i":{"x":0.833,"y":0.85},"o":{"x":0.167,"y":0.187},"t":40,"s":[339.791,201.562,0],"to":[0.071,0.087,0],"ti":[-0.043,-0.079,0]},{"i":{"x":0.833,"y":0.851},"o":{"x":0.167,"y":0.188},"t":41,"s":[339.958,201.813,0],"to":[0.043,0.079,0],"ti":[-0.02,-0.068,0]},{"i":{"x":0.833,"y":0.852},"o":{"x":0.167,"y":0.189},"t":42,"s":[340.049,202.035,0],"to":[0.02,0.068,0],"ti":[0,-0.055,0]},{"i":{"x":0.833,"y":0.852},"o":{"x":0.167,"y":0.191},"t":43,"s":[340.075,202.222,0],"to":[0,0.055,0],"ti":[0.014,-0.04,0]},{"i":{"x":0.833,"y":0.847},"o":{"x":0.167,"y":0.191},"t":44,"s":[340.051,202.365,0],"to":[-0.014,0.04,0],"ti":[0.025,-0.022,0]},{"i":{"x":0.833,"y":0.855},"o":{"x":0.167,"y":0.184},"t":45,"s":[339.989,202.459,0],"to":[-0.025,0.022,0],"ti":[0.024,-0.002,0]},{"i":{"x":0.833,"y":0.799},"o":{"x":0.167,"y":0.197},"t":46,"s":[339.903,202.496,0],"to":[-0.024,0.002,0],"ti":[0.008,0.02,0]},{"i":{"x":0.833,"y":0.778},"o":{"x":0.167,"y":0.143},"t":47,"s":[339.844,202.469,0],"to":[-0.008,-0.02,0],"ti":[-0.011,0.042,0]},{"i":{"x":0.833,"y":0.821},"o":{"x":0.167,"y":0.133},"t":48,"s":[339.858,202.373,0],"to":[0.011,-0.042,0],"ti":[-0.012,0.057,0]},{"i":{"x":0.833,"y":0.823},"o":{"x":0.167,"y":0.156},"t":49,"s":[339.908,202.219,0],"to":[0.012,-0.057,0],"ti":[-0.001,0.066,0]},{"i":{"x":0.833,"y":0.826},"o":{"x":0.167,"y":0.158},"t":50,"s":[339.927,202.033,0],"to":[0.001,-0.066,0],"ti":[0.008,0.072,0]},{"i":{"x":0.833,"y":0.828},"o":{"x":0.167,"y":0.16},"t":51,"s":[339.916,201.823,0],"to":[-0.008,-0.072,0],"ti":[0.017,0.076,0]},{"i":{"x":0.833,"y":0.83},"o":{"x":0.167,"y":0.162},"t":52,"s":[339.877,201.599,0],"to":[-0.017,-0.076,0],"ti":[0.026,0.078,0]},{"i":{"x":0.833,"y":0.832},"o":{"x":0.167,"y":0.163},"t":53,"s":[339.812,201.365,0],"to":[-0.026,-0.078,0],"ti":[0.033,0.078,0]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.165},"t":54,"s":[339.723,201.128,0],"to":[-0.033,-0.078,0],"ti":[0.04,0.076,0]},{"i":{"x":0.833,"y":0.834},"o":{"x":0.167,"y":0.166},"t":55,"s":[339.613,200.895,0],"to":[-0.04,-0.076,0],"ti":[0.046,0.073,0]},{"i":{"x":0.833,"y":0.835},"o":{"x":0.167,"y":0.167},"t":56,"s":[339.484,200.67,0],"to":[-0.046,-0.073,0],"ti":[0.051,0.068,0]},{"i":{"x":0.833,"y":0.836},"o":{"x":0.167,"y":0.168},"t":57,"s":[339.339,200.457,0],"to":[-0.051,-0.068,0],"ti":[0.055,0.062,0]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.169},"t":58,"s":[339.179,200.262,0],"to":[-0.055,-0.062,0],"ti":[0.029,0.029,0]},{"t":59,"s":[339.007,200.087,0]}],"ix":2,"l":2},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":1,"k":[{"i":{"x":[0.833,0.833,0.833],"y":[1.27,0.902,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.167,0.167,0]},"t":6,"s":[140.028,87.797,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.945,4.379,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.064,0.567,0]},"t":7,"s":[149.724,81.426,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.242,0.938,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.158,0.081,0]},"t":8,"s":[108.654,80.328,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.932,1.096,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.062,-0.235,0]},"t":9,"s":[122.85,125.952,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.849,0.986,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.361,0.045,0]},"t":10,"s":[67.435,113.999,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.46,0.786,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.186,-0.016,0]},"t":11,"s":[77.828,139.764,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.921,0.918,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.099,0.136,0]},"t":12,"s":[86.275,118.232,100]},{"i":{"x":[0.833,0.833,0.833],"y":[-0.039,-1.479,1]},"o":{"x":[0.167,0.167,0.167],"y":[-1.604,-4.077,0]},"t":13,"s":[132.517,84.447,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.895,1.074,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.091,0.086,0]},"t":14,"s":[130.233,85.124,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.337,1.045,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.399,0.039,0]},"t":15,"s":[104.061,104.576,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.903,0.992,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.067,0.029,0]},"t":16,"s":[97.155,67.886,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.652,0.971,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.607,-0.009,0]},"t":17,"s":[131.988,124.186,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.86,0.858,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.074,-0.045,0]},"t":18,"s":[137.528,73.357,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.036,1.045,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.207,0.201,0]},"t":19,"s":[88.621,106.456,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.926,0.915,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.025,0.029,0]},"t":20,"s":[55.617,129.849,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.262,4.966,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.634,4.996,0]},"t":21,"s":[102.914,93.756,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.946,0.946,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.094,0.082,0]},"t":22,"s":[97.417,93.144,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.703,0.499,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.154,-0.157,0]},"t":23,"s":[54.264,122.898,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.998,0.898,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.116,0.1,0]},"t":24,"s":[69.385,112.598,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.949,1.263,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.002,0.458,0]},"t":25,"s":[108.191,60.937,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.912,0.891,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.135,0.063,0]},"t":26,"s":[70.4,49.44,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.585,0.999,1]},"o":{"x":[0.167,0.167,0.167],"y":[1.486,0.354,0]},"t":27,"s":[84.854,97.16,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.234,0.72,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.104,-0.001,0]},"t":28,"s":[85.713,111.857,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.975,0.919,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.061,0.119,0]},"t":29,"s":[89.136,97.385,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.375,0.152,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.036,-3.19,0]},"t":30,"s":[76.083,63.267,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.021,0.8,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.068,0.092,0]},"t":31,"s":[85.219,64.136,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.877,1.035,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.017,0.143,0]},"t":32,"s":[34.985,72.111,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.984,0.913,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.257,0.025,0]},"t":33,"s":[97.846,83.275,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.901,2.92,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.02,2.259,0]},"t":34,"s":[127.941,67.428,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.553,0.981,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.536,0.08,0]},"t":35,"s":[103.695,66.821,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.782,0.701,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.102,-0.025,0]},"t":36,"s":[99.227,81.408,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.859,0.804,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.135,0.116,0]},"t":37,"s":[79.727,70.137,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.953,0.952,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.203,0.145,0]},"t":38,"s":[48.153,40.939,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.831,0.706,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.106,-0.113,0]},"t":39,"s":[26.225,1.63,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.052,0.962,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.164,0.116,0]},"t":40,"s":[35.878,18.283,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.639,0.832,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.032,-0.069,0]},"t":41,"s":[45.795,60.473,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.99,0.924,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.108,0.165,0]},"t":42,"s":[29.734,37.417,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.965,-0.589,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.011,-0.907,0]},"t":43,"s":[-23.828,13.866,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.893,0.871,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.06,0.088,0]},"t":44,"s":[23.282,15.847,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.57,1.036,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.379,0.235,0]},"t":45,"s":[-4.081,51.66,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.965,0.796,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.073,0.025,0]},"t":46,"s":[-11.795,71.385,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.84,0.979,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.061,0.141,0]},"t":47,"s":[48.641,43.137,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.129,0.825,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.174,-0.028,0]},"t":48,"s":[13.642,2.267,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.979,1.021,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.051,0.159,0]},"t":49,"s":[-18.389,32.951,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.947,0.864,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.028,0.017,0]},"t":50,"s":[63.189,66.722,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.844,1.094,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.146,0.216,0]},"t":51,"s":[2.261,24.31,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.934,0.977,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.179,0.044,0]},"t":52,"s":[24.372,-2.358,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.521,0.999,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.314,-0.032,0]},"t":53,"s":[43.66,54.3,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.932,0.99,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.101,-0.001,0]},"t":54,"s":[39.61,13.352,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.265,0.893,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.359,-0.012,0]},"t":55,"s":[20.359,53.589,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.823,0.847,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.094,0.371,0]},"t":56,"s":[23.988,18.274,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.068,1.058,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.157,0.183,0]},"t":57,"s":[52.384,8.05,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.833,0.833,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.038,0.034,0]},"t":58,"s":[84.408,-0.484,100]},{"t":59,"s":[26.091,14.017,100]}],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"sr","sy":2,"d":1,"pt":{"a":0,"k":3,"ix":3},"p":{"a":0,"k":[6,32],"ix":4},"r":{"a":0,"k":0,"ix":5},"or":{"a":0,"k":6.599,"ix":7},"os":{"a":0,"k":0,"ix":9},"ix":1,"nm":"Polystar Path 1","mn":"ADBE Vector Shape - Star","hd":false},{"ty":"fl","c":{"a":0,"k":[0.3137254901960784,0.8901960784313725,0.7607843137254902,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[-7.382,-25.677],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Polystar 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":6,"op":60,"st":6,"bm":0},{"ddd":0,"ind":13,"ty":4,"nm":"Shape Layer 39","sr":1,"ks":{"o":{"a":1,"k":[{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":11,"s":[0]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":16,"s":[100]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":38,"s":[100]},{"t":54,"s":[0]}],"ix":11},"r":{"a":0,"k":92,"ix":10,"x":"var $bm_rt;\n$bm_rt = $bm_mul(time, 300);"},"p":{"a":1,"k":[{"i":{"x":0.833,"y":0.587},"o":{"x":0.167,"y":0.167},"t":11,"s":[266.709,299.63,0],"to":[-0.015,0.376,0],"ti":[0.204,-1.854,0]},{"i":{"x":0.833,"y":0.764},"o":{"x":0.167,"y":0.104},"t":12,"s":[266.621,301.887,0],"to":[-0.204,1.854,0],"ti":[0.96,-4.091,0]},{"i":{"x":0.833,"y":0.824},"o":{"x":0.167,"y":0.129},"t":13,"s":[265.488,310.754,0],"to":[-0.96,4.091,0],"ti":[2.202,-5.284,0]},{"i":{"x":0.833,"y":0.844},"o":{"x":0.167,"y":0.158},"t":14,"s":[260.859,326.432,0],"to":[-2.202,5.284,0],"ti":[3.145,-4.688,0]},{"i":{"x":0.833,"y":0.847},"o":{"x":0.167,"y":0.179},"t":15,"s":[252.276,342.456,0],"to":[-3.145,4.688,0],"ti":[3.441,-3.397,0]},{"i":{"x":0.833,"y":0.846},"o":{"x":0.167,"y":0.183},"t":16,"s":[241.99,354.561,0],"to":[-3.441,3.397,0],"ti":[3.35,-2.293,0]},{"i":{"x":0.833,"y":0.845},"o":{"x":0.167,"y":0.182},"t":17,"s":[231.629,362.84,0],"to":[-3.35,2.293,0],"ti":[3.107,-1.507,0]},{"i":{"x":0.833,"y":0.844},"o":{"x":0.167,"y":0.18},"t":18,"s":[221.889,368.32,0],"to":[-3.107,1.507,0],"ti":[2.823,-0.973,0]},{"i":{"x":0.833,"y":0.843},"o":{"x":0.167,"y":0.179},"t":19,"s":[212.988,371.885,0],"to":[-2.823,0.973,0],"ti":[2.545,-0.61,0]},{"i":{"x":0.833,"y":0.843},"o":{"x":0.167,"y":0.178},"t":20,"s":[204.948,374.155,0],"to":[-2.545,0.61,0],"ti":[2.287,-0.361,0]},{"i":{"x":0.833,"y":0.842},"o":{"x":0.167,"y":0.177},"t":21,"s":[197.719,375.542,0],"to":[-2.287,0.361,0],"ti":[2.057,-0.189,0]},{"i":{"x":0.833,"y":0.842},"o":{"x":0.167,"y":0.176},"t":22,"s":[191.223,376.321,0],"to":[-2.057,0.189,0],"ti":[1.855,-0.069,0]},{"i":{"x":0.833,"y":0.841},"o":{"x":0.167,"y":0.176},"t":23,"s":[185.374,376.678,0],"to":[-1.855,0.069,0],"ti":[1.678,0.016,0]},{"i":{"x":0.833,"y":0.841},"o":{"x":0.167,"y":0.175},"t":24,"s":[180.091,376.736,0],"to":[-1.678,-0.016,0],"ti":[1.52,0.078,0]},{"i":{"x":0.833,"y":0.841},"o":{"x":0.167,"y":0.175},"t":25,"s":[175.308,376.58,0],"to":[-1.52,-0.078,0],"ti":[1.379,0.122,0]},{"i":{"x":0.833,"y":0.841},"o":{"x":0.167,"y":0.175},"t":26,"s":[170.97,376.27,0],"to":[-1.379,-0.122,0],"ti":[1.252,0.155,0]},{"i":{"x":0.833,"y":0.841},"o":{"x":0.167,"y":0.175},"t":27,"s":[167.033,375.845,0],"to":[-1.252,-0.155,0],"ti":[1.137,0.18,0]},{"i":{"x":0.833,"y":0.841},"o":{"x":0.167,"y":0.175},"t":28,"s":[163.458,375.337,0],"to":[-1.137,-0.18,0],"ti":[1.031,0.198,0]},{"i":{"x":0.833,"y":0.841},"o":{"x":0.167,"y":0.175},"t":29,"s":[160.214,374.766,0],"to":[-1.031,-0.198,0],"ti":[0.933,0.212,0]},{"i":{"x":0.833,"y":0.841},"o":{"x":0.167,"y":0.175},"t":30,"s":[157.274,374.147,0],"to":[-0.933,-0.212,0],"ti":[0.843,0.223,0]},{"i":{"x":0.833,"y":0.841},"o":{"x":0.167,"y":0.175},"t":31,"s":[154.615,373.492,0],"to":[-0.843,-0.223,0],"ti":[0.758,0.232,0]},{"i":{"x":0.833,"y":0.84},"o":{"x":0.167,"y":0.175},"t":32,"s":[152.218,372.807,0],"to":[-0.758,-0.232,0],"ti":[0.683,0.239,0]},{"i":{"x":0.833,"y":0.84},"o":{"x":0.167,"y":0.174},"t":33,"s":[150.067,372.098,0],"to":[-0.683,-0.239,0],"ti":[0.619,0.242,0]},{"i":{"x":0.833,"y":0.84},"o":{"x":0.167,"y":0.174},"t":34,"s":[148.12,371.372,0],"to":[-0.619,-0.242,0],"ti":[0.56,0.239,0]},{"i":{"x":0.833,"y":0.841},"o":{"x":0.167,"y":0.174},"t":35,"s":[146.355,370.647,0],"to":[-0.56,-0.239,0],"ti":[0.506,0.232,0]},{"i":{"x":0.833,"y":0.841},"o":{"x":0.167,"y":0.175},"t":36,"s":[144.758,369.937,0],"to":[-0.506,-0.232,0],"ti":[0.454,0.219,0]},{"i":{"x":0.833,"y":0.842},"o":{"x":0.167,"y":0.176},"t":37,"s":[143.32,369.258,0],"to":[-0.454,-0.219,0],"ti":[0.406,0.203,0]},{"i":{"x":0.833,"y":0.843},"o":{"x":0.167,"y":0.176},"t":38,"s":[142.032,368.621,0],"to":[-0.406,-0.203,0],"ti":[0.36,0.184,0]},{"i":{"x":0.833,"y":0.844},"o":{"x":0.167,"y":0.177},"t":39,"s":[140.885,368.038,0],"to":[-0.36,-0.184,0],"ti":[0.317,0.162,0]},{"i":{"x":0.833,"y":0.845},"o":{"x":0.167,"y":0.179},"t":40,"s":[139.871,367.517,0],"to":[-0.317,-0.162,0],"ti":[0.277,0.138,0]},{"i":{"x":0.833,"y":0.847},"o":{"x":0.167,"y":0.18},"t":41,"s":[138.982,367.066,0],"to":[-0.277,-0.138,0],"ti":[0.238,0.111,0]},{"i":{"x":0.833,"y":0.849},"o":{"x":0.167,"y":0.183},"t":42,"s":[138.211,366.692,0],"to":[-0.238,-0.111,0],"ti":[0.201,0.083,0]},{"i":{"x":0.833,"y":0.851},"o":{"x":0.167,"y":0.185},"t":43,"s":[137.554,366.399,0],"to":[-0.201,-0.083,0],"ti":[0.166,0.055,0]},{"i":{"x":0.833,"y":0.854},"o":{"x":0.167,"y":0.189},"t":44,"s":[137.005,366.191,0],"to":[-0.166,-0.055,0],"ti":[0.132,0.025,0]},{"i":{"x":0.833,"y":0.854},"o":{"x":0.167,"y":0.194},"t":45,"s":[136.559,366.071,0],"to":[-0.132,-0.025,0],"ti":[0.1,-0.005,0]},{"i":{"x":0.833,"y":0.846},"o":{"x":0.167,"y":0.195},"t":46,"s":[136.211,366.04,0],"to":[-0.1,0.005,0],"ti":[0.069,-0.034,0]},{"i":{"x":0.833,"y":0.824},"o":{"x":0.167,"y":0.182},"t":47,"s":[135.959,366.098,0],"to":[-0.069,0.034,0],"ti":[0.039,-0.063,0]},{"i":{"x":0.833,"y":0.81},"o":{"x":0.167,"y":0.158},"t":48,"s":[135.797,366.244,0],"to":[-0.039,0.063,0],"ti":[0.011,-0.091,0]},{"i":{"x":0.833,"y":0.81},"o":{"x":0.167,"y":0.148},"t":49,"s":[135.723,366.476,0],"to":[-0.011,0.091,0],"ti":[-0.016,-0.118,0]},{"i":{"x":0.833,"y":0.819},"o":{"x":0.167,"y":0.149},"t":50,"s":[135.732,366.79,0],"to":[0.016,0.118,0],"ti":[-0.036,-0.141,0]},{"i":{"x":0.833,"y":0.825},"o":{"x":0.167,"y":0.154},"t":51,"s":[135.82,367.181,0],"to":[0.036,0.141,0],"ti":[-0.044,-0.159,0]},{"i":{"x":0.833,"y":0.827},"o":{"x":0.167,"y":0.159},"t":52,"s":[135.95,367.634,0],"to":[0.044,0.159,0],"ti":[-0.046,-0.173,0]},{"i":{"x":0.833,"y":0.829},"o":{"x":0.167,"y":0.161},"t":53,"s":[136.085,368.134,0],"to":[0.046,0.173,0],"ti":[-0.047,-0.184,0]},{"i":{"x":0.833,"y":0.83},"o":{"x":0.167,"y":0.162},"t":54,"s":[136.223,368.671,0],"to":[0.047,0.184,0],"ti":[-0.048,-0.193,0]},{"i":{"x":0.833,"y":0.832},"o":{"x":0.167,"y":0.164},"t":55,"s":[136.365,369.239,0],"to":[0.048,0.193,0],"ti":[-0.048,-0.197,0]},{"i":{"x":0.833,"y":0.834},"o":{"x":0.167,"y":0.165},"t":56,"s":[136.509,369.827,0],"to":[0.048,0.197,0],"ti":[-0.048,-0.198,0]},{"i":{"x":0.833,"y":0.836},"o":{"x":0.167,"y":0.167},"t":57,"s":[136.653,370.423,0],"to":[0.048,0.198,0],"ti":[-0.047,-0.195,0]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.169},"t":58,"s":[136.796,371.017,0],"to":[0.047,0.195,0],"ti":[-0.023,-0.096,0]},{"t":59,"s":[136.937,371.594,0]}],"ix":2,"l":2},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":1,"k":[{"i":{"x":[0.833,0.833,0.833],"y":[0.877,0.953,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.167,0.167,0]},"t":11,"s":[59.343,96.124,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.969,0.832,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.259,-0.107,0]},"t":12,"s":[89.744,43.042,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.861,1.087,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.05,0.166,0]},"t":13,"s":[104.203,66.307,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.424,0.974,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.208,0.043,0]},"t":14,"s":[95.197,89.821,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.819,0.904,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.07,-0.039,0]},"t":15,"s":[89.153,41.816,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.993,0.548,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.154,0.617,0]},"t":16,"s":[125.946,74.556,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.815,0.926,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.008,0.102,0]},"t":17,"s":[169.121,79.673,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.937,1.513,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.152,-0.669,0]},"t":18,"s":[129.751,102.297,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.747,1.071,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.266,0.072,0]},"t":19,"s":[81.726,99.791,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.055,0.899,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.124,0.038,0]},"t":20,"s":[93.197,117.733,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.922,1.324,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.033,0.474,0]},"t":21,"s":[116.588,84.536,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.473,1.053,1]},"o":{"x":[0.167,0.167,0.167],"y":[-1.179,0.066,0]},"t":22,"s":[77.774,77.458,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.932,0.902,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.099,0.033,0]},"t":23,"s":[80.336,112.065,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.026,1.108,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.364,0.57,0]},"t":24,"s":[93.979,55.33,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.832,0.728,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.02,0.047,0]},"t":25,"s":[91.437,45.624,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.069,0.952,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.076,0.12,0]},"t":26,"s":[94.777,67.955,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.954,0.9,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.038,-0.116,0]},"t":27,"s":[58.109,118.474,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.954,0.352,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.102,0.493,0]},"t":28,"s":[125.074,97.355,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.408,0.998,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.104,0.096,0]},"t":29,"s":[95.015,93.056,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.982,0.922,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.069,-0.002,0]},"t":30,"s":[108.364,63.932,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.838,-0.683,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.022,-1.218,0]},"t":31,"s":[29.719,92.263,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.943,0.975,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.172,0.088,0]},"t":32,"s":[91.786,90.449,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.84,0.834,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.177,-0.036,0]},"t":33,"s":[150.239,55.635,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.842,0.888,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.174,0.167,0]},"t":34,"s":[131.528,79.867,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.948,1.053,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.177,0.326,0]},"t":35,"s":[114.424,104,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.464,0.726,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.141,0.032,0]},"t":36,"s":[99.134,112.3,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.912,0.762,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.071,0.12,0]},"t":37,"s":[104.825,98.77,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.398,1.006,1]},"o":{"x":[0.167,0.167,0.167],"y":[1.54,0.128,0]},"t":38,"s":[67.48,67.824,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.924,0.877,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.069,0.006,0]},"t":39,"s":[65.344,10.519,100]},{"i":{"x":[0.833,0.833,0.833],"y":[-3.208,1.093,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.911,0.258,0]},"t":40,"s":[77.671,71.977,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.93,0.905,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.085,0.044,0]},"t":41,"s":[76.639,101.368,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.96,1.238,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.443,0.68,0]},"t":42,"s":[25.532,39.221,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.134,0.942,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.077,0.062,0]},"t":43,"s":[33.627,30.543,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.683,0.714,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.051,-0.194,0]},"t":44,"s":[29.417,64.046,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.008,0.948,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.113,0.118,0]},"t":45,"s":[40.374,53.993,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.787,1.36,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.007,-0.137,0]},"t":46,"s":[71.134,29.6,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.078,1.002,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.137,0.068,0]},"t":47,"s":[37.38,38.828,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.948,0.937,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.04,0.002,0]},"t":48,"s":[-15.322,-10.291,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.912,0.695,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.139,-0.254,0]},"t":49,"s":[86.713,39.937,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.314,1.014,1]},"o":{"x":[0.167,0.167,0.167],"y":[1.669,0.115,0]},"t":50,"s":[48.493,27.546,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.73,0.854,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.066,0.012,0]},"t":51,"s":[46.484,-5.44,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.035,0.884,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.12,0.194,0]},"t":52,"s":[56.051,33.097,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.934,1.27,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.025,0.299,0]},"t":53,"s":[77.513,62.169,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.866,0.851,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.324,0.064,0]},"t":54,"s":[46.909,73.396,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.037,0.912,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.222,0.188,0]},"t":55,"s":[53.173,25.847,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.024,1.423,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.091,1.71,0]},"t":56,"s":[56.946,-11.873,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.891,0.665,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.019,0.07,0]},"t":57,"s":[96.787,-13.805,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.833,0.833,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.356,0.111,0]},"t":58,"s":[45.548,-2.059,100]},{"t":59,"s":[29.917,33.378,100]}],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"sr","sy":2,"d":1,"pt":{"a":0,"k":3,"ix":3},"p":{"a":0,"k":[6,32],"ix":4},"r":{"a":0,"k":0,"ix":5},"or":{"a":0,"k":6.599,"ix":7},"os":{"a":0,"k":0,"ix":9},"ix":1,"nm":"Polystar Path 1","mn":"ADBE Vector Shape - Star","hd":false},{"ty":"fl","c":{"a":0,"k":[0.167325382607,0.716982433843,1,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[-7.382,-25.677],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Polystar 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":11,"op":60,"st":11,"bm":0},{"ddd":0,"ind":14,"ty":4,"nm":"Shape Layer 38","sr":1,"ks":{"o":{"a":1,"k":[{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":7,"s":[0]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":12,"s":[100]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":34,"s":[100]},{"t":50,"s":[0]}],"ix":11},"r":{"a":0,"k":92,"ix":10,"x":"var $bm_rt;\n$bm_rt = $bm_mul(time, 300);"},"p":{"a":1,"k":[{"i":{"x":0.833,"y":0.591},"o":{"x":0.167,"y":0.167},"t":7,"s":[262.436,299.331,0],"to":[0.489,-0.187,0],"ti":[-2.348,1.046,0]},{"i":{"x":0.833,"y":0.764},"o":{"x":0.167,"y":0.105},"t":8,"s":[265.373,298.21,0],"to":[2.348,-1.046,0],"ti":[-5.031,2.854,0]},{"i":{"x":0.833,"y":0.824},"o":{"x":0.167,"y":0.129},"t":9,"s":[276.523,293.055,0],"to":[5.031,-2.854,0],"ti":[-6.175,4.882,0]},{"i":{"x":0.833,"y":0.844},"o":{"x":0.167,"y":0.158},"t":10,"s":[295.56,281.084,0],"to":[6.175,-4.882,0],"ti":[-4.919,5.974,0]},{"i":{"x":0.833,"y":0.847},"o":{"x":0.167,"y":0.179},"t":11,"s":[313.573,263.763,0],"to":[4.919,-5.974,0],"ti":[-2.793,5.995,0]},{"i":{"x":0.833,"y":0.846},"o":{"x":0.167,"y":0.183},"t":12,"s":[325.076,245.24,0],"to":[2.793,-5.995,0],"ti":[-0.958,5.467,0]},{"i":{"x":0.833,"y":0.845},"o":{"x":0.167,"y":0.182},"t":13,"s":[330.333,227.794,0],"to":[0.958,-5.467,0],"ti":[0.382,4.708,0]},{"i":{"x":0.833,"y":0.844},"o":{"x":0.167,"y":0.18},"t":14,"s":[330.823,212.44,0],"to":[-0.382,-4.708,0],"ti":[1.261,3.892,0]},{"i":{"x":0.833,"y":0.843},"o":{"x":0.167,"y":0.179},"t":15,"s":[328.039,199.545,0],"to":[-1.261,-3.892,0],"ti":[1.765,3.129,0]},{"i":{"x":0.833,"y":0.842},"o":{"x":0.167,"y":0.178},"t":16,"s":[323.257,189.086,0],"to":[-1.765,-3.129,0],"ti":[2.001,2.482,0]},{"i":{"x":0.833,"y":0.842},"o":{"x":0.167,"y":0.177},"t":17,"s":[317.447,180.769,0],"to":[-2.001,-2.482,0],"ti":[2.064,1.961,0]},{"i":{"x":0.833,"y":0.842},"o":{"x":0.167,"y":0.177},"t":18,"s":[311.25,174.195,0],"to":[-2.064,-1.961,0],"ti":[2.024,1.552,0]},{"i":{"x":0.833,"y":0.842},"o":{"x":0.167,"y":0.176},"t":19,"s":[305.064,169.001,0],"to":[-2.024,-1.552,0],"ti":[1.932,1.236,0]},{"i":{"x":0.833,"y":0.841},"o":{"x":0.167,"y":0.176},"t":20,"s":[299.106,164.881,0],"to":[-1.932,-1.236,0],"ti":[1.816,0.99,0]},{"i":{"x":0.833,"y":0.841},"o":{"x":0.167,"y":0.176},"t":21,"s":[293.474,161.588,0],"to":[-1.816,-0.99,0],"ti":[1.69,0.797,0]},{"i":{"x":0.833,"y":0.841},"o":{"x":0.167,"y":0.176},"t":22,"s":[288.212,158.939,0],"to":[-1.69,-0.797,0],"ti":[1.563,0.644,0]},{"i":{"x":0.833,"y":0.841},"o":{"x":0.167,"y":0.176},"t":23,"s":[283.333,156.803,0],"to":[-1.563,-0.644,0],"ti":[1.437,0.519,0]},{"i":{"x":0.833,"y":0.841},"o":{"x":0.167,"y":0.176},"t":24,"s":[278.836,155.078,0],"to":[-1.437,-0.519,0],"ti":[1.316,0.416,0]},{"i":{"x":0.833,"y":0.841},"o":{"x":0.167,"y":0.176},"t":25,"s":[274.712,153.691,0],"to":[-1.316,-0.416,0],"ti":[1.201,0.33,0]},{"i":{"x":0.833,"y":0.842},"o":{"x":0.167,"y":0.176},"t":26,"s":[270.942,152.585,0],"to":[-1.201,-0.33,0],"ti":[1.094,0.257,0]},{"i":{"x":0.833,"y":0.842},"o":{"x":0.167,"y":0.176},"t":27,"s":[267.505,151.714,0],"to":[-1.094,-0.257,0],"ti":[0.992,0.194,0]},{"i":{"x":0.833,"y":0.842},"o":{"x":0.167,"y":0.176},"t":28,"s":[264.381,151.045,0],"to":[-0.992,-0.194,0],"ti":[0.897,0.139,0]},{"i":{"x":0.833,"y":0.842},"o":{"x":0.167,"y":0.176},"t":29,"s":[261.551,150.551,0],"to":[-0.897,-0.139,0],"ti":[0.808,0.091,0]},{"i":{"x":0.833,"y":0.842},"o":{"x":0.167,"y":0.177},"t":30,"s":[258.997,150.211,0],"to":[-0.808,-0.091,0],"ti":[0.724,0.048,0]},{"i":{"x":0.833,"y":0.843},"o":{"x":0.167,"y":0.177},"t":31,"s":[256.702,150.007,0],"to":[-0.724,-0.048,0],"ti":[0.646,0.009,0]},{"i":{"x":0.833,"y":0.843},"o":{"x":0.167,"y":0.177},"t":32,"s":[254.651,149.925,0],"to":[-0.646,-0.009,0],"ti":[0.572,-0.025,0]},{"i":{"x":0.833,"y":0.843},"o":{"x":0.167,"y":0.178},"t":33,"s":[252.828,149.951,0],"to":[-0.572,0.025,0],"ti":[0.502,-0.055,0]},{"i":{"x":0.833,"y":0.843},"o":{"x":0.167,"y":0.178},"t":34,"s":[251.221,150.073,0],"to":[-0.502,0.055,0],"ti":[0.437,-0.082,0]},{"i":{"x":0.833,"y":0.843},"o":{"x":0.167,"y":0.178},"t":35,"s":[249.815,150.281,0],"to":[-0.437,0.082,0],"ti":[0.377,-0.105,0]},{"i":{"x":0.833,"y":0.843},"o":{"x":0.167,"y":0.178},"t":36,"s":[248.597,150.563,0],"to":[-0.377,0.105,0],"ti":[0.321,-0.125,0]},{"i":{"x":0.833,"y":0.843},"o":{"x":0.167,"y":0.178},"t":37,"s":[247.552,150.91,0],"to":[-0.321,0.125,0],"ti":[0.269,-0.141,0]},{"i":{"x":0.833,"y":0.843},"o":{"x":0.167,"y":0.178},"t":38,"s":[246.671,151.311,0],"to":[-0.269,0.141,0],"ti":[0.22,-0.154,0]},{"i":{"x":0.833,"y":0.842},"o":{"x":0.167,"y":0.177},"t":39,"s":[245.941,151.756,0],"to":[-0.22,0.154,0],"ti":[0.174,-0.163,0]},{"i":{"x":0.833,"y":0.842},"o":{"x":0.167,"y":0.177},"t":40,"s":[245.353,152.233,0],"to":[-0.174,0.163,0],"ti":[0.132,-0.168,0]},{"i":{"x":0.833,"y":0.841},"o":{"x":0.167,"y":0.176},"t":41,"s":[244.897,152.732,0],"to":[-0.132,0.168,0],"ti":[0.093,-0.169,0]},{"i":{"x":0.833,"y":0.841},"o":{"x":0.167,"y":0.175},"t":42,"s":[244.562,153.241,0],"to":[-0.093,0.169,0],"ti":[0.057,-0.166,0]},{"i":{"x":0.833,"y":0.841},"o":{"x":0.167,"y":0.175},"t":43,"s":[244.34,153.746,0],"to":[-0.057,0.166,0],"ti":[0.024,-0.158,0]},{"i":{"x":0.833,"y":0.841},"o":{"x":0.167,"y":0.175},"t":44,"s":[244.221,154.236,0],"to":[-0.024,0.158,0],"ti":[-0.006,-0.145,0]},{"i":{"x":0.833,"y":0.841},"o":{"x":0.167,"y":0.175},"t":45,"s":[244.197,154.694,0],"to":[0.006,0.145,0],"ti":[-0.033,-0.128,0]},{"i":{"x":0.833,"y":0.841},"o":{"x":0.167,"y":0.175},"t":46,"s":[244.257,155.108,0],"to":[0.033,0.128,0],"ti":[-0.049,-0.109,0]},{"i":{"x":0.833,"y":0.826},"o":{"x":0.167,"y":0.175},"t":47,"s":[244.394,155.461,0],"to":[0.049,0.109,0],"ti":[-0.051,-0.108,0]},{"i":{"x":0.833,"y":0.827},"o":{"x":0.167,"y":0.16},"t":48,"s":[244.552,155.765,0],"to":[0.051,0.108,0],"ti":[-0.047,-0.12,0]},{"i":{"x":0.833,"y":0.829},"o":{"x":0.167,"y":0.16},"t":49,"s":[244.699,156.107,0],"to":[0.047,0.12,0],"ti":[-0.044,-0.131,0]},{"i":{"x":0.833,"y":0.831},"o":{"x":0.167,"y":0.163},"t":50,"s":[244.836,156.487,0],"to":[0.044,0.131,0],"ti":[-0.041,-0.137,0]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.164},"t":51,"s":[244.964,156.891,0],"to":[0.041,0.137,0],"ti":[-0.039,-0.14,0]},{"i":{"x":0.833,"y":0.834},"o":{"x":0.167,"y":0.166},"t":52,"s":[245.085,157.309,0],"to":[0.039,0.14,0],"ti":[-0.037,-0.14,0]},{"i":{"x":0.833,"y":0.836},"o":{"x":0.167,"y":0.168},"t":53,"s":[245.198,157.733,0],"to":[0.037,0.14,0],"ti":[-0.035,-0.138,0]},{"i":{"x":0.833,"y":0.837},"o":{"x":0.167,"y":0.169},"t":54,"s":[245.305,158.152,0],"to":[0.035,0.138,0],"ti":[-0.033,-0.133,0]},{"i":{"x":0.833,"y":0.839},"o":{"x":0.167,"y":0.171},"t":55,"s":[245.406,158.559,0],"to":[0.033,0.133,0],"ti":[-0.031,-0.125,0]},{"i":{"x":0.833,"y":0.84},"o":{"x":0.167,"y":0.172},"t":56,"s":[245.502,158.947,0],"to":[0.031,0.125,0],"ti":[-0.03,-0.116,0]},{"i":{"x":0.833,"y":0.842},"o":{"x":0.167,"y":0.174},"t":57,"s":[245.594,159.31,0],"to":[0.03,0.116,0],"ti":[-0.029,-0.104,0]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.176},"t":58,"s":[245.683,159.641,0],"to":[0.029,0.104,0],"ti":[-0.014,-0.049,0]},{"t":59,"s":[245.769,159.936,0]}],"ix":2,"l":2},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":1,"k":[{"i":{"x":[0.833,0.833,0.833],"y":[0.684,1.023,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.167,0.167,0]},"t":7,"s":[100.63,69.657,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.351,-1.07,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.113,0.018,0]},"t":8,"s":[104.514,69.052,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.905,1.153,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.067,0.087,0]},"t":9,"s":[115.346,69.821,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.398,0.948,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.695,0.054,0]},"t":10,"s":[58.871,88.161,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.965,0.738,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.069,-0.137,0]},"t":11,"s":[51.174,36.165,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.972,0.948,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.06,0.122,0]},"t":12,"s":[95.636,55.822,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.882,0.78,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.042,-0.141,0]},"t":13,"s":[69.725,97.853,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.246,1.072,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.285,0.134,0]},"t":14,"s":[86.959,82.233,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.96,0.883,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.094,0.039,0]},"t":15,"s":[94.096,56.625,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.833,1.134,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.077,0.29,0]},"t":16,"s":[151.503,104.404,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.845,0.96,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.166,0.051,0]},"t":17,"s":[121.653,123.646,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.987,1.023,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.181,-0.079,0]},"t":18,"s":[91.668,73.435,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.867,0.872,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.015,0.018,0]},"t":19,"s":[66.045,99.285,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.952,1.2,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.223,0.238,0]},"t":20,"s":[87.746,66.291,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.929,0.943,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.113,0.059,0]},"t":21,"s":[100.66,48.526,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.102,0.778,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.477,-0.182,0]},"t":22,"s":[95.172,108.893,100]},{"i":{"x":[0.833,0.833,0.833],"y":[2.575,0.936,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.046,0.133,0]},"t":23,"s":[95.987,89.94,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.108,0.256,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.079,-0.27,0]},"t":24,"s":[94.176,58.401,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.975,0.976,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.047,0.094,0]},"t":25,"s":[130.21,65.835,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.897,0.966,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.036,-0.034,0]},"t":26,"s":[47.499,124.757,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.822,1.024,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.43,-0.057,0]},"t":27,"s":[105.068,83.037,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.17,0.979,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.157,0.019,0]},"t":28,"s":[118.917,107.79,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.86,0.93,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.056,-0.028,0]},"t":29,"s":[134.616,75.912,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.094,0.206,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.205,-0.452,0]},"t":30,"s":[86.877,99.716,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.948,0.852,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.044,0.093,0]},"t":31,"s":[54.265,96.009,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.9,1.036,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.139,0.19,0]},"t":32,"s":[123.839,64.381,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.39,0.839,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.496,0.025,0]},"t":33,"s":[97.726,39.711,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.984,0.871,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.097,0.173,0]},"t":34,"s":[92.458,75.119,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.931,1.058,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.019,0.235,0]},"t":35,"s":[59.151,108.162,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.407,0.742,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.405,0.034,0]},"t":36,"s":[86.256,126.307,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.957,0.931,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.097,0.123,0]},"t":37,"s":[81.627,95.551,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.83,0.652,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.089,-0.391,0]},"t":38,"s":[53.31,31.043,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.098,1.066,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.164,0.11,0]},"t":39,"s":[67.002,42.37,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.867,0.972,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.045,0.037,0]},"t":40,"s":[81.207,78.404,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.908,0.922,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.224,-0.041,0]},"t":41,"s":[50.269,13.881,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.43,0.675,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.933,-1.114,0]},"t":42,"s":[31.981,57.039,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.97,0.375,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.07,0.112,0]},"t":43,"s":[30.188,54.035,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.919,0.947,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.046,0.096,0]},"t":44,"s":[41.246,45.317,100]},{"i":{"x":[0.833,0.833,0.833],"y":[17.435,1.051,1]},"o":{"x":[0.167,0.167,0.167],"y":[-2.661,-0.148,0]},"t":45,"s":[34.148,-11.396,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.997,0.938,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.083,0.032,0]},"t":46,"s":[34.363,9.033,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.893,0.829,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.004,-0.247,0]},"t":47,"s":[-8.365,-24.009,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.892,0.989,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.375,0.163,0]},"t":48,"s":[32.621,-15.669,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.847,1.17,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.364,-0.012,0]},"t":49,"s":[44.332,-6.94,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.476,0.712,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.183,0.056,0]},"t":50,"s":[47.804,-14.568,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.098,0.898,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.099,0.117,0]},"t":51,"s":[50.704,8.589,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.912,1.348,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.045,0.46,0]},"t":52,"s":[66.039,65.528,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.638,0.848,1]},"o":{"x":[0.167,0.167,0.167],"y":[1.665,0.067,0]},"t":53,"s":[32.735,78.112,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.718,0.935,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.074,0.185,0]},"t":54,"s":[30.98,12.995,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.934,0.692,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.118,-0.305,0]},"t":55,"s":[46.164,-40.385,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.25,0.862,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.313,0.114,0]},"t":56,"s":[82.432,-28.944,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.952,0.97,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.094,0.211,0]},"t":57,"s":[74.813,1.96,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.833,0.833,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.112,-0.047,0]},"t":58,"s":[13.817,22.06,100]},{"t":59,"s":[39.779,9.173,100]}],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"sr","sy":2,"d":1,"pt":{"a":0,"k":3,"ix":3},"p":{"a":0,"k":[6,32],"ix":4},"r":{"a":0,"k":0,"ix":5},"or":{"a":0,"k":6.599,"ix":7},"os":{"a":0,"k":0,"ix":9},"ix":1,"nm":"Polystar Path 1","mn":"ADBE Vector Shape - Star","hd":false},{"ty":"fl","c":{"a":0,"k":[0.3137254901960784,0.8901960784313725,0.7607843137254902,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[-7.382,-25.677],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Polystar 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":7,"op":60,"st":7,"bm":0},{"ddd":0,"ind":15,"ty":4,"nm":"Shape Layer 37","sr":1,"ks":{"o":{"a":1,"k":[{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":9,"s":[0]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":14,"s":[100]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":36,"s":[100]},{"t":52,"s":[0]}],"ix":11},"r":{"a":0,"k":92,"ix":10,"x":"var $bm_rt;\n$bm_rt = $bm_mul(time, 300);"},"p":{"a":1,"k":[{"i":{"x":0.833,"y":0.583},"o":{"x":0.167,"y":0.167},"t":9,"s":[264.075,300.144,0],"to":[-0.069,0.468,0],"ti":[0.355,-2.342,0]},{"i":{"x":0.833,"y":0.763},"o":{"x":0.167,"y":0.104},"t":10,"s":[263.663,302.955,0],"to":[-0.355,2.342,0],"ti":[0.559,-5.353,0]},{"i":{"x":0.833,"y":0.824},"o":{"x":0.167,"y":0.129},"t":11,"s":[261.945,314.199,0],"to":[-0.559,5.353,0],"ti":[0.004,-7.357,0]},{"i":{"x":0.833,"y":0.844},"o":{"x":0.167,"y":0.158},"t":12,"s":[260.312,335.072,0],"to":[-0.004,7.357,0],"ti":[-1.215,-7.142,0]},{"i":{"x":0.833,"y":0.847},"o":{"x":0.167,"y":0.179},"t":13,"s":[261.922,358.34,0],"to":[1.215,7.142,0],"ti":[-2.396,-5.718,0]},{"i":{"x":0.833,"y":0.846},"o":{"x":0.167,"y":0.183},"t":14,"s":[267.601,377.926,0],"to":[2.396,5.718,0],"ti":[-3.152,-4.167,0]},{"i":{"x":0.833,"y":0.845},"o":{"x":0.167,"y":0.182},"t":15,"s":[276.299,392.647,0],"to":[3.152,4.167,0],"ti":[-3.444,-2.85,0]},{"i":{"x":0.833,"y":0.844},"o":{"x":0.167,"y":0.18},"t":16,"s":[286.512,402.925,0],"to":[3.444,2.85,0],"ti":[-3.405,-1.874,0]},{"i":{"x":0.833,"y":0.843},"o":{"x":0.167,"y":0.179},"t":17,"s":[296.962,409.748,0],"to":[3.405,1.874,0],"ti":[-3.2,-1.208,0]},{"i":{"x":0.833,"y":0.842},"o":{"x":0.167,"y":0.178},"t":18,"s":[306.94,414.169,0],"to":[3.2,1.208,0],"ti":[-2.937,-0.769,0]},{"i":{"x":0.833,"y":0.842},"o":{"x":0.167,"y":0.177},"t":19,"s":[316.159,416.998,0],"to":[2.937,0.769,0],"ti":[-2.672,-0.481,0]},{"i":{"x":0.833,"y":0.841},"o":{"x":0.167,"y":0.176},"t":20,"s":[324.559,418.783,0],"to":[2.672,0.481,0],"ti":[-2.428,-0.291,0]},{"i":{"x":0.833,"y":0.841},"o":{"x":0.167,"y":0.176},"t":21,"s":[332.191,419.883,0],"to":[2.428,0.291,0],"ti":[-2.206,-0.163,0]},{"i":{"x":0.833,"y":0.841},"o":{"x":0.167,"y":0.175},"t":22,"s":[339.125,420.527,0],"to":[2.206,0.163,0],"ti":[-2.007,-0.077,0]},{"i":{"x":0.833,"y":0.841},"o":{"x":0.167,"y":0.175},"t":23,"s":[345.429,420.863,0],"to":[2.007,0.077,0],"ti":[-1.827,-0.019,0]},{"i":{"x":0.833,"y":0.841},"o":{"x":0.167,"y":0.175},"t":24,"s":[351.165,420.991,0],"to":[1.827,0.019,0],"ti":[-1.665,0.021,0]},{"i":{"x":0.833,"y":0.841},"o":{"x":0.167,"y":0.175},"t":25,"s":[356.391,420.977,0],"to":[1.665,-0.021,0],"ti":[-1.519,0.049,0]},{"i":{"x":0.833,"y":0.841},"o":{"x":0.167,"y":0.175},"t":26,"s":[361.156,420.865,0],"to":[1.519,-0.049,0],"ti":[-1.386,0.069,0]},{"i":{"x":0.833,"y":0.841},"o":{"x":0.167,"y":0.175},"t":27,"s":[365.504,420.683,0],"to":[1.386,-0.069,0],"ti":[-1.264,0.084,0]},{"i":{"x":0.833,"y":0.841},"o":{"x":0.167,"y":0.175},"t":28,"s":[369.472,420.452,0],"to":[1.264,-0.084,0],"ti":[-1.151,0.096,0]},{"i":{"x":0.833,"y":0.841},"o":{"x":0.167,"y":0.175},"t":29,"s":[373.088,420.182,0],"to":[1.151,-0.096,0],"ti":[-1.046,0.107,0]},{"i":{"x":0.833,"y":0.841},"o":{"x":0.167,"y":0.175},"t":30,"s":[376.379,419.878,0],"to":[1.046,-0.107,0],"ti":[-0.947,0.118,0]},{"i":{"x":0.833,"y":0.841},"o":{"x":0.167,"y":0.175},"t":31,"s":[379.365,419.542,0],"to":[0.947,-0.118,0],"ti":[-0.856,0.124,0]},{"i":{"x":0.833,"y":0.841},"o":{"x":0.167,"y":0.175},"t":32,"s":[382.063,419.172,0],"to":[0.856,-0.124,0],"ti":[-0.773,0.118,0]},{"i":{"x":0.833,"y":0.842},"o":{"x":0.167,"y":0.176},"t":33,"s":[384.499,418.801,0],"to":[0.773,-0.118,0],"ti":[-0.697,0.107,0]},{"i":{"x":0.833,"y":0.842},"o":{"x":0.167,"y":0.176},"t":34,"s":[386.7,418.464,0],"to":[0.697,-0.107,0],"ti":[-0.626,0.097,0]},{"i":{"x":0.833,"y":0.842},"o":{"x":0.167,"y":0.176},"t":35,"s":[388.681,418.159,0],"to":[0.626,-0.097,0],"ti":[-0.561,0.088,0]},{"i":{"x":0.833,"y":0.843},"o":{"x":0.167,"y":0.177},"t":36,"s":[390.459,417.883,0],"to":[0.561,-0.088,0],"ti":[-0.5,0.079,0]},{"i":{"x":0.833,"y":0.843},"o":{"x":0.167,"y":0.177},"t":37,"s":[392.047,417.633,0],"to":[0.5,-0.079,0],"ti":[-0.443,0.072,0]},{"i":{"x":0.833,"y":0.844},"o":{"x":0.167,"y":0.178},"t":38,"s":[393.458,417.406,0],"to":[0.443,-0.072,0],"ti":[-0.39,0.066,0]},{"i":{"x":0.833,"y":0.844},"o":{"x":0.167,"y":0.178},"t":39,"s":[394.704,417.199,0],"to":[0.39,-0.066,0],"ti":[-0.34,0.061,0]},{"i":{"x":0.833,"y":0.845},"o":{"x":0.167,"y":0.179},"t":40,"s":[395.795,417.009,0],"to":[0.34,-0.061,0],"ti":[-0.293,0.057,0]},{"i":{"x":0.833,"y":0.846},"o":{"x":0.167,"y":0.18},"t":41,"s":[396.741,416.833,0],"to":[0.293,-0.057,0],"ti":[-0.249,0.053,0]},{"i":{"x":0.833,"y":0.847},"o":{"x":0.167,"y":0.182},"t":42,"s":[397.551,416.669,0],"to":[0.249,-0.053,0],"ti":[-0.207,0.05,0]},{"i":{"x":0.833,"y":0.849},"o":{"x":0.167,"y":0.184},"t":43,"s":[398.233,416.514,0],"to":[0.207,-0.05,0],"ti":[-0.168,0.048,0]},{"i":{"x":0.833,"y":0.851},"o":{"x":0.167,"y":0.186},"t":44,"s":[398.794,416.367,0],"to":[0.168,-0.048,0],"ti":[-0.132,0.046,0]},{"i":{"x":0.833,"y":0.854},"o":{"x":0.167,"y":0.19},"t":45,"s":[399.242,416.225,0],"to":[0.132,-0.046,0],"ti":[-0.097,0.045,0]},{"i":{"x":0.833,"y":0.857},"o":{"x":0.167,"y":0.195},"t":46,"s":[399.584,416.088,0],"to":[0.097,-0.045,0],"ti":[-0.064,0.044,0]},{"i":{"x":0.833,"y":0.857},"o":{"x":0.167,"y":0.2},"t":47,"s":[399.824,415.954,0],"to":[0.064,-0.044,0],"ti":[-0.034,0.043,0]},{"i":{"x":0.833,"y":0.835},"o":{"x":0.167,"y":0.2},"t":48,"s":[399.97,415.824,0],"to":[0.034,-0.043,0],"ti":[-0.012,0.044,0]},{"i":{"x":0.833,"y":0.82},"o":{"x":0.167,"y":0.168},"t":49,"s":[400.026,415.696,0],"to":[0.012,-0.044,0],"ti":[-0.006,0.049,0]},{"i":{"x":0.833,"y":0.823},"o":{"x":0.167,"y":0.156},"t":50,"s":[400.041,415.559,0],"to":[0.006,-0.049,0],"ti":[-0.008,0.056,0]},{"i":{"x":0.833,"y":0.825},"o":{"x":0.167,"y":0.158},"t":51,"s":[400.063,415.401,0],"to":[0.008,-0.056,0],"ti":[-0.011,0.062,0]},{"i":{"x":0.833,"y":0.827},"o":{"x":0.167,"y":0.159},"t":52,"s":[400.091,415.224,0],"to":[0.011,-0.062,0],"ti":[-0.014,0.066,0]},{"i":{"x":0.833,"y":0.829},"o":{"x":0.167,"y":0.161},"t":53,"s":[400.129,415.031,0],"to":[0.014,-0.066,0],"ti":[-0.017,0.07,0]},{"i":{"x":0.833,"y":0.83},"o":{"x":0.167,"y":0.162},"t":54,"s":[400.175,414.825,0],"to":[0.017,-0.07,0],"ti":[-0.021,0.073,0]},{"i":{"x":0.833,"y":0.832},"o":{"x":0.167,"y":0.164},"t":55,"s":[400.232,414.61,0],"to":[0.021,-0.073,0],"ti":[-0.025,0.074,0]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.165},"t":56,"s":[400.301,414.389,0],"to":[0.025,-0.074,0],"ti":[-0.029,0.073,0]},{"i":{"x":0.833,"y":0.834},"o":{"x":0.167,"y":0.166},"t":57,"s":[400.382,414.168,0],"to":[0.029,-0.073,0],"ti":[-0.034,0.071,0]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":58,"s":[400.476,413.95,0],"to":[0.034,-0.071,0],"ti":[-0.018,0.035,0]},{"t":59,"s":[400.585,413.741,0]}],"ix":2,"l":2},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":1,"k":[{"i":{"x":[0.833,0.833,0.833],"y":[2.237,1.014,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.167,0.167,0]},"t":9,"s":[60.433,69.807,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.948,0.354,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.078,0.012,0]},"t":10,"s":[57.035,63.909,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.869,0.914,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.136,0.096,0]},"t":11,"s":[110.865,70.792,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.993,2.539,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.23,2.807,0]},"t":12,"s":[90.384,117.236,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.615,1.021,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.008,0.079,0]},"t":13,"s":[78.755,118.657,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.957,0.878,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.106,0.017,0]},"t":14,"s":[89.349,90.995,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.759,1.314,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.09,0.263,0]},"t":15,"s":[127.76,125.63,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.85,0.949,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.128,0.066,0]},"t":16,"s":[109.298,141.669,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.93,1.01,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.187,-0.134,0]},"t":17,"s":[74.466,65.123,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.468,0.913,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.45,0.009,0]},"t":18,"s":[46.407,94.474,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.94,2.673,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.099,2.042,0]},"t":19,"s":[50.79,61.594,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.179,0.833,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.209,0.079,0]},"t":20,"s":[74.379,60.195,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.903,1.053,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.057,0.166,0]},"t":21,"s":[67.656,89.669,100]},{"i":{"x":[0.833,0.833,0.833],"y":[-0.318,0.963,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.577,0.032,0]},"t":22,"s":[88.817,119.34,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.032,0.863,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.089,-0.068,0]},"t":23,"s":[92.391,70.889,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.912,1.002,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.023,0.212,0]},"t":24,"s":[145.365,97.56,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.702,0.895,1]},"o":{"x":[0.167,0.167,0.167],"y":[1.509,0.002,0]},"t":25,"s":[71.948,114.853,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.886,1.201,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.074,0.407,0]},"t":26,"s":[67.657,97.077,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.837,0.928,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.312,0.059,0]},"t":27,"s":[108.077,92.496,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.302,1.255,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.171,-0.517,0]},"t":28,"s":[122.812,108.142,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.944,1.236,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.065,0.063,0]},"t":29,"s":[136.892,105.97,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.917,0.833,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.168,0.062,0]},"t":30,"s":[71.752,114.791,100]},{"i":{"x":[0.833,0.833,0.833],"y":[4.698,0.909,1]},"o":{"x":[0.167,0.167,0.167],"y":[-10.728,0.167,0]},"t":31,"s":[93.325,80.942,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.281,2.111,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.081,0.997,0]},"t":32,"s":[93.159,47.153,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.918,0.796,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.064,0.078,0]},"t":33,"s":[100.704,44.07,100]},{"i":{"x":[0.833,0.833,0.833],"y":[-6.847,0.936,1]},"o":{"x":[0.167,0.167,0.167],"y":[-7.898,0.141,0]},"t":34,"s":[67.68,88.243,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.94,0.681,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.084,-0.275,0]},"t":35,"s":[68.025,152.101,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.562,0.9,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.209,0.113,0]},"t":36,"s":[100.152,137.254,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.999,0.902,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.103,0.504,0]},"t":37,"s":[91.001,95.342,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.918,-2.094,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.001,0.554,0]},"t":38,"s":[52.026,87.036,100]},{"i":{"x":[0.833,0.833,0.833],"y":[-2.601,0.998,1]},"o":{"x":[0.167,0.167,0.167],"y":[-3.839,0.086,0]},"t":39,"s":[90.362,85.565,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.926,0.91,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.085,-0.003,0]},"t":40,"s":[89.547,32.423,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.446,1.058,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.669,1.085,0]},"t":41,"s":[55.168,83.983,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.934,0.021,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.07,0.034,0]},"t":42,"s":[58.978,88.274,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.109,0.913,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.325,0.091,0]},"t":43,"s":[34.79,80.994,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.922,1.213,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.047,1.844,0]},"t":44,"s":[39.732,2.714,100]},{"i":{"x":[0.833,0.833,0.833],"y":[4.228,1.121,1]},"o":{"x":[0.167,0.167,0.167],"y":[-1.292,0.06,0]},"t":45,"s":[28.352,-0.992,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.984,0.973,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.081,0.049,0]},"t":46,"s":[29.041,12.182,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.923,0.921,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.02,-0.04,0]},"t":47,"s":[1.635,-20.19,100]},{"i":{"x":[0.833,0.833,0.833],"y":[2.207,3.708,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.991,-1.388,0]},"t":48,"s":[23.803,1.627,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.933,0.909,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.078,0.081,0]},"t":49,"s":[22.083,0.392,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.444,0.353,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.338,0.943,0]},"t":50,"s":[48.704,41.779,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.843,1.011,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.098,0.096,0]},"t":51,"s":[43.437,45.791,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.042,0.741,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.178,0.01,0]},"t":52,"s":[13.575,72.931,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.846,0.91,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.028,0.123,0]},"t":53,"s":[-12.851,42.13,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.907,1.471,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.181,1.058,0]},"t":54,"s":[26.868,-22.827,100]},{"i":{"x":[0.833,0.833,0.833],"y":[2.014,0.916,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.832,0.071,0]},"t":55,"s":[60.696,-28.382,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.956,6.012,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.077,11.039,0]},"t":56,"s":[64.461,8.569,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.945,1.005,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.093,0.082,0]},"t":57,"s":[14.897,8.85,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.833,0.833,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.162,0.005,0]},"t":58,"s":[38.326,-8.337,100]},{"t":59,"s":[30.376,9.931,100]}],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"sr","sy":2,"d":1,"pt":{"a":0,"k":3,"ix":3},"p":{"a":0,"k":[6,32],"ix":4},"r":{"a":0,"k":0,"ix":5},"or":{"a":0,"k":6.599,"ix":7},"os":{"a":0,"k":0,"ix":9},"ix":1,"nm":"Polystar Path 1","mn":"ADBE Vector Shape - Star","hd":false},{"ty":"fl","c":{"a":0,"k":[0.167325382607,0.716982433843,1,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[-7.382,-25.677],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Polystar 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":9,"op":60,"st":9,"bm":0},{"ddd":0,"ind":16,"ty":4,"nm":"Shape Layer 36","sr":1,"ks":{"o":{"a":1,"k":[{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":5,"s":[0]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":10,"s":[100]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":32,"s":[100]},{"t":48,"s":[0]}],"ix":11},"r":{"a":0,"k":92,"ix":10,"x":"var $bm_rt;\n$bm_rt = $bm_mul(time, 300);"},"p":{"a":1,"k":[{"i":{"x":0.833,"y":0.604},"o":{"x":0.167,"y":0.167},"t":5,"s":[268.501,303.894,0],"to":[0.373,-0.267,0],"ti":[-1.332,1.601,0]},{"i":{"x":0.833,"y":0.775},"o":{"x":0.167,"y":0.106},"t":6,"s":[270.738,302.29,0],"to":[1.332,-1.601,0],"ti":[1.952,1.768,0]},{"i":{"x":0.833,"y":0.821},"o":{"x":0.167,"y":0.133},"t":7,"s":[276.491,294.286,0],"to":[-1.952,-1.768,0],"ti":[6.31,0.166,0]},{"i":{"x":0.833,"y":0.844},"o":{"x":0.167,"y":0.156},"t":8,"s":[259.026,291.685,0],"to":[-6.31,-0.166,0],"ti":[6.364,-0.502,0]},{"i":{"x":0.833,"y":0.847},"o":{"x":0.167,"y":0.179},"t":9,"s":[238.628,293.288,0],"to":[-6.364,0.502,0],"ti":[5.435,-0.368,0]},{"i":{"x":0.833,"y":0.847},"o":{"x":0.167,"y":0.183},"t":10,"s":[220.844,294.694,0],"to":[-5.435,0.368,0],"ti":[4.549,-0.177,0]},{"i":{"x":0.833,"y":0.845},"o":{"x":0.167,"y":0.183},"t":11,"s":[206.017,295.497,0],"to":[-4.549,0.177,0],"ti":[3.853,-0.014,0]},{"i":{"x":0.833,"y":0.844},"o":{"x":0.167,"y":0.181},"t":12,"s":[193.549,295.758,0],"to":[-3.853,0.014,0],"ti":[3.314,0.117,0]},{"i":{"x":0.833,"y":0.843},"o":{"x":0.167,"y":0.179},"t":13,"s":[182.897,295.583,0],"to":[-3.314,-0.117,0],"ti":[2.886,0.223,0]},{"i":{"x":0.833,"y":0.843},"o":{"x":0.167,"y":0.178},"t":14,"s":[173.668,295.056,0],"to":[-2.886,-0.223,0],"ti":[2.537,0.31,0]},{"i":{"x":0.833,"y":0.842},"o":{"x":0.167,"y":0.177},"t":15,"s":[165.584,294.243,0],"to":[-2.537,-0.31,0],"ti":[2.246,0.383,0]},{"i":{"x":0.833,"y":0.841},"o":{"x":0.167,"y":0.176},"t":16,"s":[158.446,293.194,0],"to":[-2.246,-0.383,0],"ti":[2.001,0.441,0]},{"i":{"x":0.833,"y":0.841},"o":{"x":0.167,"y":0.176},"t":17,"s":[152.105,291.947,0],"to":[-2.001,-0.441,0],"ti":[1.789,0.485,0]},{"i":{"x":0.833,"y":0.841},"o":{"x":0.167,"y":0.175},"t":18,"s":[146.441,290.551,0],"to":[-1.789,-0.485,0],"ti":[1.601,0.519,0]},{"i":{"x":0.833,"y":0.841},"o":{"x":0.167,"y":0.175},"t":19,"s":[141.373,289.038,0],"to":[-1.601,-0.519,0],"ti":[1.435,0.544,0]},{"i":{"x":0.833,"y":0.84},"o":{"x":0.167,"y":0.175},"t":20,"s":[136.833,287.437,0],"to":[-1.435,-0.544,0],"ti":[1.285,0.562,0]},{"i":{"x":0.833,"y":0.84},"o":{"x":0.167,"y":0.174},"t":21,"s":[132.765,285.771,0],"to":[-1.285,-0.562,0],"ti":[1.15,0.573,0]},{"i":{"x":0.833,"y":0.84},"o":{"x":0.167,"y":0.174},"t":22,"s":[129.121,284.063,0],"to":[-1.15,-0.573,0],"ti":[1.027,0.578,0]},{"i":{"x":0.833,"y":0.84},"o":{"x":0.167,"y":0.174},"t":23,"s":[125.863,282.331,0],"to":[-1.027,-0.578,0],"ti":[0.915,0.576,0]},{"i":{"x":0.833,"y":0.84},"o":{"x":0.167,"y":0.174},"t":24,"s":[122.957,280.597,0],"to":[-0.915,-0.576,0],"ti":[0.814,0.569,0]},{"i":{"x":0.833,"y":0.84},"o":{"x":0.167,"y":0.174},"t":25,"s":[120.371,278.877,0],"to":[-0.814,-0.569,0],"ti":[0.721,0.558,0]},{"i":{"x":0.833,"y":0.84},"o":{"x":0.167,"y":0.174},"t":26,"s":[118.075,277.184,0],"to":[-0.721,-0.558,0],"ti":[0.637,0.542,0]},{"i":{"x":0.833,"y":0.84},"o":{"x":0.167,"y":0.174},"t":27,"s":[116.044,275.532,0],"to":[-0.637,-0.542,0],"ti":[0.56,0.523,0]},{"i":{"x":0.833,"y":0.84},"o":{"x":0.167,"y":0.174},"t":28,"s":[114.254,273.931,0],"to":[-0.56,-0.523,0],"ti":[0.49,0.502,0]},{"i":{"x":0.833,"y":0.841},"o":{"x":0.167,"y":0.174},"t":29,"s":[112.684,272.392,0],"to":[-0.49,-0.502,0],"ti":[0.427,0.478,0]},{"i":{"x":0.833,"y":0.841},"o":{"x":0.167,"y":0.175},"t":30,"s":[111.312,270.921,0],"to":[-0.427,-0.478,0],"ti":[0.369,0.453,0]},{"i":{"x":0.833,"y":0.841},"o":{"x":0.167,"y":0.175},"t":31,"s":[110.122,269.523,0],"to":[-0.369,-0.453,0],"ti":[0.317,0.426,0]},{"i":{"x":0.833,"y":0.841},"o":{"x":0.167,"y":0.175},"t":32,"s":[109.095,268.204,0],"to":[-0.317,-0.426,0],"ti":[0.27,0.399,0]},{"i":{"x":0.833,"y":0.842},"o":{"x":0.167,"y":0.176},"t":33,"s":[108.219,266.966,0],"to":[-0.27,-0.399,0],"ti":[0.226,0.37,0]},{"i":{"x":0.833,"y":0.842},"o":{"x":0.167,"y":0.176},"t":34,"s":[107.478,265.812,0],"to":[-0.226,-0.37,0],"ti":[0.186,0.342,0]},{"i":{"x":0.833,"y":0.843},"o":{"x":0.167,"y":0.177},"t":35,"s":[106.862,264.743,0],"to":[-0.186,-0.342,0],"ti":[0.15,0.313,0]},{"i":{"x":0.833,"y":0.843},"o":{"x":0.167,"y":0.177},"t":36,"s":[106.36,263.762,0],"to":[-0.15,-0.313,0],"ti":[0.117,0.283,0]},{"i":{"x":0.833,"y":0.844},"o":{"x":0.167,"y":0.178},"t":37,"s":[105.962,262.868,0],"to":[-0.117,-0.283,0],"ti":[0.086,0.254,0]},{"i":{"x":0.833,"y":0.845},"o":{"x":0.167,"y":0.179},"t":38,"s":[105.66,262.062,0],"to":[-0.086,-0.254,0],"ti":[0.058,0.224,0]},{"i":{"x":0.833,"y":0.846},"o":{"x":0.167,"y":0.18},"t":39,"s":[105.445,261.346,0],"to":[-0.058,-0.224,0],"ti":[0.032,0.194,0]},{"i":{"x":0.833,"y":0.848},"o":{"x":0.167,"y":0.182},"t":40,"s":[105.312,260.718,0],"to":[-0.032,-0.194,0],"ti":[0.008,0.164,0]},{"i":{"x":0.833,"y":0.849},"o":{"x":0.167,"y":0.184},"t":41,"s":[105.253,260.18,0],"to":[-0.008,-0.164,0],"ti":[-0.014,0.135,0]},{"i":{"x":0.833,"y":0.849},"o":{"x":0.167,"y":0.185},"t":42,"s":[105.263,259.732,0],"to":[0.014,-0.135,0],"ti":[-0.034,0.104,0]},{"i":{"x":0.833,"y":0.845},"o":{"x":0.167,"y":0.185},"t":43,"s":[105.336,259.373,0],"to":[0.034,-0.104,0],"ti":[-0.052,0.074,0]},{"i":{"x":0.833,"y":0.838},"o":{"x":0.167,"y":0.181},"t":44,"s":[105.467,259.105,0],"to":[0.052,-0.074,0],"ti":[-0.065,0.05,0]},{"i":{"x":0.833,"y":0.838},"o":{"x":0.167,"y":0.172},"t":45,"s":[105.65,258.927,0],"to":[0.065,-0.05,0],"ti":[-0.069,0.036,0]},{"i":{"x":0.833,"y":0.849},"o":{"x":0.167,"y":0.172},"t":46,"s":[105.86,258.807,0],"to":[0.069,-0.036,0],"ti":[-0.062,0.029,0]},{"i":{"x":0.833,"y":0.856},"o":{"x":0.167,"y":0.186},"t":47,"s":[106.065,258.712,0],"to":[0.062,-0.029,0],"ti":[-0.047,0.024,0]},{"i":{"x":0.833,"y":0.863},"o":{"x":0.167,"y":0.197},"t":48,"s":[106.232,258.632,0],"to":[0.047,-0.024,0],"ti":[-0.031,0.02,0]},{"i":{"x":0.833,"y":0.876},"o":{"x":0.167,"y":0.214},"t":49,"s":[106.349,258.566,0],"to":[0.031,-0.02,0],"ti":[-0.015,0.014,0]},{"i":{"x":0.833,"y":0.85},"o":{"x":0.167,"y":0.252},"t":50,"s":[106.418,258.515,0],"to":[0.015,-0.014,0],"ti":[0.001,0.009,0]},{"i":{"x":0.833,"y":0.742},"o":{"x":0.167,"y":0.188},"t":51,"s":[106.44,258.479,0],"to":[-0.001,-0.009,0],"ti":[0.016,0.003,0]},{"i":{"x":0.833,"y":0.781},"o":{"x":0.167,"y":0.123},"t":52,"s":[106.414,258.46,0],"to":[-0.016,-0.003,0],"ti":[0.031,-0.003,0]},{"i":{"x":0.833,"y":0.801},"o":{"x":0.167,"y":0.134},"t":53,"s":[106.343,258.459,0],"to":[-0.031,0.003,0],"ti":[0.045,-0.008,0]},{"i":{"x":0.833,"y":0.811},"o":{"x":0.167,"y":0.144},"t":54,"s":[106.228,258.475,0],"to":[-0.045,0.008,0],"ti":[0.059,-0.014,0]},{"i":{"x":0.833,"y":0.817},"o":{"x":0.167,"y":0.149},"t":55,"s":[106.07,258.51,0],"to":[-0.059,0.014,0],"ti":[0.072,-0.02,0]},{"i":{"x":0.833,"y":0.82},"o":{"x":0.167,"y":0.153},"t":56,"s":[105.873,258.562,0],"to":[-0.072,0.02,0],"ti":[0.084,-0.026,0]},{"i":{"x":0.833,"y":0.823},"o":{"x":0.167,"y":0.155},"t":57,"s":[105.639,258.632,0],"to":[-0.084,0.026,0],"ti":[0.095,-0.032,0]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.157},"t":58,"s":[105.369,258.719,0],"to":[-0.095,0.032,0],"ti":[0.05,-0.017,0]},{"t":59,"s":[105.069,258.823,0]}],"ix":2,"l":2},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":1,"k":[{"i":{"x":[0.833,0.833,0.833],"y":[0.896,-0.931,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.167,0.167,0]},"t":5,"s":[111.822,109.374,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.087,0.955,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.414,0.087,0]},"t":6,"s":[100.055,106.422,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.951,0.812,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.092,-0.097,0]},"t":7,"s":[97.093,40.951,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.773,0.949,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.117,0.15,0]},"t":8,"s":[67.613,71.247,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.893,1.157,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.132,-0.129,0]},"t":9,"s":[79.872,109.348,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.215,0.987,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.381,0.054,0]},"t":10,"s":[101.054,94.428,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.929,0.945,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.06,-0.015,0]},"t":11,"s":[106.979,137.485,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.974,0.793,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.477,-0.164,0]},"t":12,"s":[85.798,101.174,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.674,1.013,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.039,0.139,0]},"t":13,"s":[88.947,113.411,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.924,0.677,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.074,0.011,0]},"t":14,"s":[86.793,131.596,100]},{"i":{"x":[0.833,0.833,0.833],"y":[-0.583,0.977,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.914,0.112,0]},"t":15,"s":[106.377,110.603,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.868,0.899,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.088,-0.033,0]},"t":16,"s":[104.74,50.192,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.873,0.902,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.225,0.471,0]},"t":17,"s":[75.287,93.627,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.914,0.963,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.244,0.557,0]},"t":18,"s":[57.971,102.957,100]},{"i":{"x":[0.833,0.833,0.833],"y":[6.07,1.272,1]},"o":{"x":[0.167,0.167,0.167],"y":[2.53,-0.068,0]},"t":19,"s":[48.983,104.599,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.801,1.91,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.082,0.064,0]},"t":20,"s":[48.677,103.694,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.786,1.017,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.143,0.076,0]},"t":21,"s":[67.607,107.546,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.999,0.968,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.136,0.014,0]},"t":22,"s":[93.956,61.633,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.898,0.924,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.001,-0.051,0]},"t":23,"s":[135.372,117.12,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.936,1.36,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.448,-0.923,0]},"t":24,"s":[94.654,82.761,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.146,1.103,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.276,0.068,0]},"t":25,"s":[85.351,85.606,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.778,0.911,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.092,0.046,0]},"t":26,"s":[87.506,70.455,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.973,0.043,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.134,1.425,0]},"t":27,"s":[107.432,104.297,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.792,1.155,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.04,0.091,0]},"t":28,"s":[140.473,106.399,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.967,0.932,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.139,0.054,0]},"t":29,"s":[118.147,128.441,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.974,1.121,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.054,-0.359,0]},"t":30,"s":[84.776,65.43,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.673,0.886,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.038,0.049,0]},"t":31,"s":[105.029,77.29,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.925,1.408,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.112,0.311,0]},"t":32,"s":[91.068,48.266,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.647,0.929,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.747,0.069,0]},"t":33,"s":[50.228,37.635,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.827,0.939,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.109,-0.474,0]},"t":34,"s":[54.329,100.336,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.244,1.036,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.161,-0.223,0]},"t":35,"s":[67.613,90.964,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.954,0.595,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.062,0.025,0]},"t":36,"s":[81.902,93.51,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.785,0.829,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.101,0.105,0]},"t":37,"s":[25.699,89.858,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.976,0.897,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.136,0.163,0]},"t":38,"s":[51.156,75.74,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.939,1.149,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.034,0.43,0]},"t":39,"s":[91.35,60.958,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.534,0.988,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.231,0.053,0]},"t":40,"s":[62.734,57.409,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.938,0.526,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.072,-0.014,0]},"t":41,"s":[70.32,67.319,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.896,0.923,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.24,0.101,0]},"t":42,"s":[14.17,58.838,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.32,0.689,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.419,-0.944,0]},"t":43,"s":[28.636,19.113,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.869,1.114,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.066,0.114,0]},"t":44,"s":[32.23,22.336,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.092,1.006,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.228,0.048,0]},"t":45,"s":[14.851,31.15,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.899,1.001,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.044,0.005,0]},"t":46,"s":[4.812,10.283,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.075,1.195,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.472,0.001,0]},"t":47,"s":[25.925,32.587,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.799,0.984,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.04,0.058,0]},"t":48,"s":[30.453,9.9,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.842,0.973,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.142,-0.02,0]},"t":49,"s":[21.826,85.625,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.674,0.929,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.177,-0.039,0]},"t":50,"s":[9.608,24.619,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.022,0.324,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.112,-0.462,0]},"t":51,"s":[-1.289,66.096,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.84,0.913,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.018,0.095,0]},"t":52,"s":[-33.054,59.758,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.926,1.101,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.173,1.876,0]},"t":53,"s":[7.239,14.644,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.273,1.364,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.683,0.046,0]},"t":54,"s":[44.549,12.547,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.913,0.998,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.094,0.068,0]},"t":55,"s":[40.492,17.184,100]},{"i":{"x":[0.833,0.833,0.833],"y":[2.637,1.065,1]},"o":{"x":[0.167,0.167,0.167],"y":[1.953,-0.003,0]},"t":56,"s":[9.161,-7.703,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.935,0.991,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.079,0.037,0]},"t":57,"s":[7.764,16.458,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.833,0.833,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.3,-0.01,0]},"t":58,"s":[36.594,-26.534,100]},{"t":59,"s":[30.332,11.798,100]}],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"sr","sy":2,"d":1,"pt":{"a":0,"k":3,"ix":3},"p":{"a":0,"k":[6,32],"ix":4},"r":{"a":0,"k":0,"ix":5},"or":{"a":0,"k":6.599,"ix":7},"os":{"a":0,"k":0,"ix":9},"ix":1,"nm":"Polystar Path 1","mn":"ADBE Vector Shape - Star","hd":false},{"ty":"fl","c":{"a":0,"k":[0.3137254901960784,0.8901960784313725,0.7607843137254902,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[-7.382,-25.677],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Polystar 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":5,"op":60,"st":5,"bm":0},{"ddd":0,"ind":17,"ty":4,"nm":"Shape Layer 35","sr":1,"ks":{"o":{"a":1,"k":[{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":4,"s":[0]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":9,"s":[100]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":31,"s":[100]},{"t":47,"s":[0]}],"ix":11},"r":{"a":0,"k":92,"ix":10,"x":"var $bm_rt;\n$bm_rt = $bm_mul(time, 300);"},"p":{"a":1,"k":[{"i":{"x":0.833,"y":0.58},"o":{"x":0.167,"y":0.167},"t":4,"s":[269.847,301.952,0],"to":[0.17,-0.147,0],"ti":[-0.855,0.741,0]},{"i":{"x":0.833,"y":0.763},"o":{"x":0.167,"y":0.104},"t":5,"s":[270.865,301.071,0],"to":[0.855,-0.741,0],"ti":[-1.897,1.74,0]},{"i":{"x":0.833,"y":0.824},"o":{"x":0.167,"y":0.129},"t":6,"s":[274.976,297.505,0],"to":[1.897,-1.74,0],"ti":[-2.487,2.492,0]},{"i":{"x":0.833,"y":0.844},"o":{"x":0.167,"y":0.158},"t":7,"s":[282.245,290.631,0],"to":[2.487,-2.492,0],"ti":[-2.333,2.568,0]},{"i":{"x":0.833,"y":0.847},"o":{"x":0.167,"y":0.179},"t":8,"s":[289.898,282.555,0],"to":[2.333,-2.568,0],"ti":[-1.909,2.26,0]},{"i":{"x":0.833,"y":0.847},"o":{"x":0.167,"y":0.184},"t":9,"s":[296.244,275.226,0],"to":[1.909,-2.26,0],"ti":[-1.56,1.916,0]},{"i":{"x":0.833,"y":0.845},"o":{"x":0.167,"y":0.183},"t":10,"s":[301.355,268.994,0],"to":[1.56,-1.916,0],"ti":[-1.325,1.619,0]},{"i":{"x":0.833,"y":0.844},"o":{"x":0.167,"y":0.181},"t":11,"s":[305.606,263.727,0],"to":[1.325,-1.619,0],"ti":[-1.185,1.356,0]},{"i":{"x":0.833,"y":0.843},"o":{"x":0.167,"y":0.179},"t":12,"s":[309.304,259.281,0],"to":[1.185,-1.356,0],"ti":[-1.147,1.074,0]},{"i":{"x":0.833,"y":0.844},"o":{"x":0.167,"y":0.177},"t":13,"s":[312.714,255.59,0],"to":[1.147,-1.074,0],"ti":[-1.205,0.459,0]},{"i":{"x":0.833,"y":0.837},"o":{"x":0.167,"y":0.179},"t":14,"s":[316.183,252.839,0],"to":[1.205,-0.459,0],"ti":[-0.955,-0.508,0]},{"i":{"x":0.833,"y":0.842},"o":{"x":0.167,"y":0.171},"t":15,"s":[319.947,252.839,0],"to":[0.955,0.508,0],"ti":[-0.499,-1.03,0]},{"i":{"x":0.833,"y":0.841},"o":{"x":0.167,"y":0.176},"t":16,"s":[321.913,255.888,0],"to":[0.499,1.03,0],"ti":[-0.292,-1.003,0]},{"i":{"x":0.833,"y":0.841},"o":{"x":0.167,"y":0.176},"t":17,"s":[322.944,259.018,0],"to":[0.292,1.003,0],"ti":[-0.216,-0.921,0]},{"i":{"x":0.833,"y":0.841},"o":{"x":0.167,"y":0.175},"t":18,"s":[323.663,261.908,0],"to":[0.216,0.921,0],"ti":[-0.18,-0.838,0]},{"i":{"x":0.833,"y":0.841},"o":{"x":0.167,"y":0.175},"t":19,"s":[324.24,264.543,0],"to":[0.18,0.838,0],"ti":[-0.16,-0.762,0]},{"i":{"x":0.833,"y":0.841},"o":{"x":0.167,"y":0.175},"t":20,"s":[324.742,266.939,0],"to":[0.16,0.762,0],"ti":[-0.149,-0.692,0]},{"i":{"x":0.833,"y":0.841},"o":{"x":0.167,"y":0.175},"t":21,"s":[325.202,269.115,0],"to":[0.149,0.692,0],"ti":[-0.143,-0.628,0]},{"i":{"x":0.833,"y":0.841},"o":{"x":0.167,"y":0.175},"t":22,"s":[325.638,271.093,0],"to":[0.143,0.628,0],"ti":[-0.139,-0.568,0]},{"i":{"x":0.833,"y":0.841},"o":{"x":0.167,"y":0.175},"t":23,"s":[326.06,272.884,0],"to":[0.139,0.568,0],"ti":[-0.136,-0.513,0]},{"i":{"x":0.833,"y":0.841},"o":{"x":0.167,"y":0.175},"t":24,"s":[326.472,274.503,0],"to":[0.136,0.513,0],"ti":[-0.134,-0.461,0]},{"i":{"x":0.833,"y":0.842},"o":{"x":0.167,"y":0.176},"t":25,"s":[326.877,275.962,0],"to":[0.134,0.461,0],"ti":[-0.131,-0.413,0]},{"i":{"x":0.833,"y":0.842},"o":{"x":0.167,"y":0.176},"t":26,"s":[327.274,277.271,0],"to":[0.131,0.413,0],"ti":[-0.128,-0.369,0]},{"i":{"x":0.833,"y":0.842},"o":{"x":0.167,"y":0.176},"t":27,"s":[327.663,278.442,0],"to":[0.128,0.369,0],"ti":[-0.123,-0.327,0]},{"i":{"x":0.833,"y":0.843},"o":{"x":0.167,"y":0.177},"t":28,"s":[328.039,279.483,0],"to":[0.123,0.327,0],"ti":[-0.117,-0.287,0]},{"i":{"x":0.833,"y":0.844},"o":{"x":0.167,"y":0.177},"t":29,"s":[328.401,280.402,0],"to":[0.117,0.287,0],"ti":[-0.109,-0.251,0]},{"i":{"x":0.833,"y":0.845},"o":{"x":0.167,"y":0.178},"t":30,"s":[328.742,281.208,0],"to":[0.109,0.251,0],"ti":[-0.095,-0.217,0]},{"i":{"x":0.833,"y":0.847},"o":{"x":0.167,"y":0.181},"t":31,"s":[329.057,281.906,0],"to":[0.095,0.217,0],"ti":[-0.065,-0.188,0]},{"i":{"x":0.833,"y":0.846},"o":{"x":0.167,"y":0.183},"t":32,"s":[329.315,282.507,0],"to":[0.065,0.188,0],"ti":[-0.024,-0.165,0]},{"i":{"x":0.833,"y":0.842},"o":{"x":0.167,"y":0.181},"t":33,"s":[329.447,283.036,0],"to":[0.024,0.165,0],"ti":[0.014,-0.145,0]},{"i":{"x":0.833,"y":0.836},"o":{"x":0.167,"y":0.176},"t":34,"s":[329.46,283.5,0],"to":[-0.014,0.145,0],"ti":[0.05,-0.126,0]},{"i":{"x":0.833,"y":0.83},"o":{"x":0.167,"y":0.169},"t":35,"s":[329.361,283.904,0],"to":[-0.05,0.126,0],"ti":[0.083,-0.109,0]},{"i":{"x":0.833,"y":0.826},"o":{"x":0.167,"y":0.163},"t":36,"s":[329.16,284.255,0],"to":[-0.083,0.109,0],"ti":[0.113,-0.092,0]},{"i":{"x":0.833,"y":0.825},"o":{"x":0.167,"y":0.16},"t":37,"s":[328.864,284.555,0],"to":[-0.113,0.092,0],"ti":[0.14,-0.078,0]},{"i":{"x":0.833,"y":0.825},"o":{"x":0.167,"y":0.159},"t":38,"s":[328.482,284.81,0],"to":[-0.14,0.078,0],"ti":[0.165,-0.064,0]},{"i":{"x":0.833,"y":0.826},"o":{"x":0.167,"y":0.159},"t":39,"s":[328.022,285.021,0],"to":[-0.165,0.064,0],"ti":[0.187,-0.05,0]},{"i":{"x":0.833,"y":0.827},"o":{"x":0.167,"y":0.16},"t":40,"s":[327.492,285.191,0],"to":[-0.187,0.05,0],"ti":[0.206,-0.038,0]},{"i":{"x":0.833,"y":0.828},"o":{"x":0.167,"y":0.161},"t":41,"s":[326.902,285.323,0],"to":[-0.206,0.038,0],"ti":[0.222,-0.026,0]},{"i":{"x":0.833,"y":0.829},"o":{"x":0.167,"y":0.162},"t":42,"s":[326.259,285.418,0],"to":[-0.222,0.026,0],"ti":[0.235,-0.015,0]},{"i":{"x":0.833,"y":0.83},"o":{"x":0.167,"y":0.163},"t":43,"s":[325.573,285.479,0],"to":[-0.235,0.015,0],"ti":[0.245,-0.007,0]},{"i":{"x":0.833,"y":0.832},"o":{"x":0.167,"y":0.164},"t":44,"s":[324.851,285.506,0],"to":[-0.245,0.007,0],"ti":[0.251,-0.008,0]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.165},"t":45,"s":[324.104,285.522,0],"to":[-0.251,0.008,0],"ti":[0.255,-0.011,0]},{"i":{"x":0.833,"y":0.834},"o":{"x":0.167,"y":0.166},"t":46,"s":[323.342,285.551,0],"to":[-0.255,0.011,0],"ti":[0.256,-0.015,0]},{"i":{"x":0.833,"y":0.834},"o":{"x":0.167,"y":0.167},"t":47,"s":[322.574,285.591,0],"to":[-0.256,0.015,0],"ti":[0.254,-0.018,0]},{"i":{"x":0.833,"y":0.835},"o":{"x":0.167,"y":0.168},"t":48,"s":[321.808,285.64,0],"to":[-0.254,0.018,0],"ti":[0.248,-0.02,0]},{"i":{"x":0.833,"y":0.837},"o":{"x":0.167,"y":0.169},"t":49,"s":[321.053,285.698,0],"to":[-0.248,0.02,0],"ti":[0.24,-0.022,0]},{"i":{"x":0.833,"y":0.838},"o":{"x":0.167,"y":0.17},"t":50,"s":[320.318,285.762,0],"to":[-0.24,0.022,0],"ti":[0.229,-0.023,0]},{"i":{"x":0.833,"y":0.839},"o":{"x":0.167,"y":0.171},"t":51,"s":[319.612,285.831,0],"to":[-0.229,0.023,0],"ti":[0.214,-0.024,0]},{"i":{"x":0.833,"y":0.841},"o":{"x":0.167,"y":0.173},"t":52,"s":[318.945,285.902,0],"to":[-0.214,0.024,0],"ti":[0.197,-0.024,0]},{"i":{"x":0.833,"y":0.843},"o":{"x":0.167,"y":0.175},"t":53,"s":[318.326,285.973,0],"to":[-0.197,0.024,0],"ti":[0.176,-0.022,0]},{"i":{"x":0.833,"y":0.846},"o":{"x":0.167,"y":0.178},"t":54,"s":[317.764,286.043,0],"to":[-0.176,0.022,0],"ti":[0.153,-0.02,0]},{"i":{"x":0.833,"y":0.85},"o":{"x":0.167,"y":0.182},"t":55,"s":[317.268,286.108,0],"to":[-0.153,0.02,0],"ti":[0.126,-0.017,0]},{"i":{"x":0.833,"y":0.857},"o":{"x":0.167,"y":0.188},"t":56,"s":[316.848,286.165,0],"to":[-0.126,0.017,0],"ti":[0.096,-0.013,0]},{"i":{"x":0.833,"y":0.87},"o":{"x":0.167,"y":0.2},"t":57,"s":[316.513,286.212,0],"to":[-0.096,0.013,0],"ti":[0.062,-0.008,0]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.232},"t":58,"s":[316.273,286.245,0],"to":[-0.062,0.008,0],"ti":[0.022,-0.003,0]},{"t":59,"s":[316.139,286.261,0]}],"ix":2,"l":2},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":1,"k":[{"i":{"x":[0.833,0.833,0.833],"y":[1.112,0.057,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.167,0.167,0]},"t":4,"s":[102.583,113.906,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.982,0.964,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.048,0.091,0]},"t":5,"s":[89.34,107.749,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.826,0.789,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.023,-0.063,0]},"t":6,"s":[120.4,44.201,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.974,0.965,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.16,0.138,0]},"t":7,"s":[96.055,80.415,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.007,0.953,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.038,-0.061,0]},"t":8,"s":[69.666,135.77,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.054,1.124,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.007,-0.107,0]},"t":9,"s":[87.726,103.756,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.958,0.931,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.033,0.05,0]},"t":10,"s":[68.046,117.794,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.847,0.448,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.085,-0.418,0]},"t":11,"s":[100.548,82.836,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.903,0.976,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.184,0.098,0]},"t":12,"s":[84.441,88.646,100]},{"i":{"x":[0.833,0.833,0.833],"y":[2.653,0.719,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.581,-0.033,0]},"t":13,"s":[71.053,121.348,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.928,0.925,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.079,0.118,0]},"t":14,"s":[68.813,98.016,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.876,-0.382,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.52,-0.749,0]},"t":15,"s":[115.484,42.548,100]},{"i":{"x":[0.833,0.833,0.833],"y":[-0.015,0.928,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.255,0.089,0]},"t":16,"s":[109.038,48.103,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.942,0.908,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.091,-0.516,0]},"t":17,"s":[105.912,134.682,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.984,-0.015,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.189,0.903,0]},"t":18,"s":[70.98,122.635,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.191,0.613,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.021,0.091,0]},"t":19,"s":[81.664,121.409,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.88,0.999,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.058,0.106,0]},"t":20,"s":[73.094,107.712,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.105,1.007,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.271,-0.001,0]},"t":21,"s":[101.314,57.875,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.034,0.913,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.046,0.006,0]},"t":22,"s":[113.878,107.014,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.956,0.958,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.024,1.786,0]},"t":23,"s":[85.547,53.745,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.812,-0.974,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.094,-0.086,0]},"t":24,"s":[125.423,51.139,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.021,0.814,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.149,0.087,0]},"t":25,"s":[106.714,52.421,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.926,0.902,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.017,0.151,0]},"t":26,"s":[83.118,81.524,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.571,1.517,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.689,0.542,0]},"t":27,"s":[112.603,117.515,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.176,0.931,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.103,0.072,0]},"t":28,"s":[109.423,124.06,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.971,0.91,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.057,-0.391,0]},"t":29,"s":[96.227,76.928,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.758,5.317,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.044,1.133,0]},"t":30,"s":[137.222,85.21,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.901,0.966,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.127,0.082,0]},"t":31,"s":[110.401,85.868,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.016,0.988,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.527,-0.056,0]},"t":32,"s":[59.435,51.153,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.802,1.05,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.013,-0.013,0]},"t":33,"s":[49.857,71.912,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.11,0.966,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.144,0.031,0]},"t":34,"s":[61.259,54.038,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.954,0.88,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.047,-0.059,0]},"t":35,"s":[76.982,82.665,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.849,1.149,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.103,0.274,0]},"t":36,"s":[40.562,65.859,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.016,1.039,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.187,0.053,0]},"t":37,"s":[56.885,58.517,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.025,0.918,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.013,0.027,0]},"t":38,"s":[70.044,78.995,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.03,2.634,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.019,-7.024,0]},"t":39,"s":[54.364,48.853,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.85,0.699,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.022,0.079,0]},"t":40,"s":[74.752,49.207,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.102,1.073,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.188,0.115,0]},"t":41,"s":[46.926,41.924,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.008,0.973,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.046,0.039,0]},"t":42,"s":[24.667,22.936,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.923,0.941,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.008,-0.041,0]},"t":43,"s":[74.072,58.487,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.782,0.823,1]},"o":{"x":[0.167,0.167,0.167],"y":[-1.08,-0.204,0]},"t":44,"s":[19.734,34.651,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.772,1.167,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.135,0.158,0]},"t":45,"s":[23.626,41.571,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.128,1.109,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.131,0.056,0]},"t":46,"s":[29.896,49.314,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.837,1.026,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.05,0.047,0]},"t":47,"s":[40.776,26.063,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.946,0.975,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.171,0.02,0]},"t":48,"s":[13.174,79.764,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.934,0.928,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.153,-0.036,0]},"t":49,"s":[-13.177,9.252,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.831,0.665,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.32,-0.508,0]},"t":50,"s":[-3.882,58.452,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.879,0.883,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.076,0.111,0]},"t":51,"s":[-5.803,51.522,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.861,0.931,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.266,0.29,0]},"t":52,"s":[15.263,30.594,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.125,2.138,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.208,-0.398,0]},"t":53,"s":[24.861,22.138,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.882,0.968,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.05,0.078,0]},"t":54,"s":[31.285,23.603,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.155,1.18,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.282,-0.051,0]},"t":55,"s":[15.205,2.136,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.018,0.906,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.054,0.057,0]},"t":56,"s":[8.46,15.435,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.07,2.225,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.015,0.737,0]},"t":57,"s":[27.717,-26.636,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.833,0.833,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.038,0.078,0]},"t":58,"s":[4.398,-31.997,100]},{"t":59,"s":[47.278,52.184,100]}],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"sr","sy":2,"d":1,"pt":{"a":0,"k":3,"ix":3},"p":{"a":0,"k":[6,32],"ix":4},"r":{"a":0,"k":0,"ix":5},"or":{"a":0,"k":6.599,"ix":7},"os":{"a":0,"k":0,"ix":9},"ix":1,"nm":"Polystar Path 1","mn":"ADBE Vector Shape - Star","hd":false},{"ty":"fl","c":{"a":0,"k":[0.167325382607,0.716982433843,1,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[-7.382,-25.677],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Polystar 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":4,"op":60,"st":4,"bm":0},{"ddd":0,"ind":18,"ty":4,"nm":"Shape Layer 34","sr":1,"ks":{"o":{"a":1,"k":[{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":10,"s":[0]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":15,"s":[100]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":37,"s":[100]},{"t":53,"s":[0]}],"ix":11},"r":{"a":0,"k":92,"ix":10,"x":"var $bm_rt;\n$bm_rt = $bm_mul(time, 300);"},"p":{"a":1,"k":[{"i":{"x":0.833,"y":0.622},"o":{"x":0.167,"y":0.167},"t":10,"s":[271.252,303.813,0],"to":[0.284,-0.193,0],"ti":[-1.149,1.055,0]},{"i":{"x":0.833,"y":0.767},"o":{"x":0.167,"y":0.107},"t":11,"s":[272.957,302.654,0],"to":[1.149,-1.055,0],"ti":[-2.022,2.73,0]},{"i":{"x":0.833,"y":0.824},"o":{"x":0.167,"y":0.13},"t":12,"s":[278.144,297.481,0],"to":[2.022,-2.73,0],"ti":[-1.91,4.187,0]},{"i":{"x":0.833,"y":0.844},"o":{"x":0.167,"y":0.158},"t":13,"s":[285.092,286.273,0],"to":[1.91,-4.187,0],"ti":[-1.055,4.438,0]},{"i":{"x":0.833,"y":0.847},"o":{"x":0.167,"y":0.178},"t":14,"s":[289.604,272.358,0],"to":[1.055,-4.438,0],"ti":[-0.359,3.914,0]},{"i":{"x":0.833,"y":0.846},"o":{"x":0.167,"y":0.183},"t":15,"s":[291.42,259.644,0],"to":[0.359,-3.914,0],"ti":[0.022,3.308,0]},{"i":{"x":0.833,"y":0.846},"o":{"x":0.167,"y":0.182},"t":16,"s":[291.76,248.874,0],"to":[-0.022,-3.308,0],"ti":[0.239,2.795,0]},{"i":{"x":0.833,"y":0.845},"o":{"x":0.167,"y":0.181},"t":17,"s":[291.291,239.797,0],"to":[-0.239,-2.795,0],"ti":[0.374,2.38,0]},{"i":{"x":0.833,"y":0.844},"o":{"x":0.167,"y":0.18},"t":18,"s":[290.325,232.102,0],"to":[-0.374,-2.38,0],"ti":[0.463,2.046,0]},{"i":{"x":0.833,"y":0.843},"o":{"x":0.167,"y":0.178},"t":19,"s":[289.046,225.517,0],"to":[-0.463,-2.046,0],"ti":[0.527,1.772,0]},{"i":{"x":0.833,"y":0.842},"o":{"x":0.167,"y":0.177},"t":20,"s":[287.548,219.827,0],"to":[-0.527,-1.772,0],"ti":[0.577,1.542,0]},{"i":{"x":0.833,"y":0.841},"o":{"x":0.167,"y":0.176},"t":21,"s":[285.886,214.884,0],"to":[-0.577,-1.542,0],"ti":[0.62,1.344,0]},{"i":{"x":0.833,"y":0.841},"o":{"x":0.167,"y":0.176},"t":22,"s":[284.087,210.573,0],"to":[-0.62,-1.344,0],"ti":[0.662,1.168,0]},{"i":{"x":0.833,"y":0.84},"o":{"x":0.167,"y":0.175},"t":23,"s":[282.165,206.818,0],"to":[-0.662,-1.168,0],"ti":[0.709,1.006,0]},{"i":{"x":0.833,"y":0.839},"o":{"x":0.167,"y":0.174},"t":24,"s":[280.115,203.564,0],"to":[-0.709,-1.006,0],"ti":[0.773,0.841,0]},{"i":{"x":0.833,"y":0.836},"o":{"x":0.167,"y":0.172},"t":25,"s":[277.909,200.78,0],"to":[-0.773,-0.841,0],"ti":[0.882,0.613,0]},{"i":{"x":0.833,"y":0.836},"o":{"x":0.167,"y":0.169},"t":26,"s":[275.475,198.518,0],"to":[-0.882,-0.613,0],"ti":[0.968,0.094,0]},{"i":{"x":0.833,"y":0.839},"o":{"x":0.167,"y":0.169},"t":27,"s":[272.616,197.104,0],"to":[-0.968,-0.094,0],"ti":[0.794,-0.512,0]},{"i":{"x":0.833,"y":0.842},"o":{"x":0.167,"y":0.173},"t":28,"s":[269.666,197.956,0],"to":[-0.794,0.512,0],"ti":[0.517,-0.748,0]},{"i":{"x":0.833,"y":0.84},"o":{"x":0.167,"y":0.176},"t":29,"s":[267.851,200.177,0],"to":[-0.517,0.748,0],"ti":[0.389,-0.735,0]},{"i":{"x":0.833,"y":0.84},"o":{"x":0.167,"y":0.174},"t":30,"s":[266.566,202.443,0],"to":[-0.389,0.735,0],"ti":[0.329,-0.691,0]},{"i":{"x":0.833,"y":0.84},"o":{"x":0.167,"y":0.174},"t":31,"s":[265.514,204.588,0],"to":[-0.329,0.691,0],"ti":[0.293,-0.642,0]},{"i":{"x":0.833,"y":0.84},"o":{"x":0.167,"y":0.174},"t":32,"s":[264.591,206.588,0],"to":[-0.293,0.642,0],"ti":[0.267,-0.593,0]},{"i":{"x":0.833,"y":0.84},"o":{"x":0.167,"y":0.174},"t":33,"s":[263.756,208.44,0],"to":[-0.267,0.593,0],"ti":[0.245,-0.545,0]},{"i":{"x":0.833,"y":0.841},"o":{"x":0.167,"y":0.174},"t":34,"s":[262.99,210.145,0],"to":[-0.245,0.545,0],"ti":[0.226,-0.497,0]},{"i":{"x":0.833,"y":0.841},"o":{"x":0.167,"y":0.175},"t":35,"s":[262.283,211.707,0],"to":[-0.226,0.497,0],"ti":[0.206,-0.451,0]},{"i":{"x":0.833,"y":0.842},"o":{"x":0.167,"y":0.175},"t":36,"s":[261.635,213.129,0],"to":[-0.206,0.451,0],"ti":[0.186,-0.406,0]},{"i":{"x":0.833,"y":0.843},"o":{"x":0.167,"y":0.177},"t":37,"s":[261.046,214.413,0],"to":[-0.186,0.406,0],"ti":[0.164,-0.36,0]},{"i":{"x":0.833,"y":0.845},"o":{"x":0.167,"y":0.178},"t":38,"s":[260.52,215.562,0],"to":[-0.164,0.36,0],"ti":[0.14,-0.315,0]},{"i":{"x":0.833,"y":0.847},"o":{"x":0.167,"y":0.18},"t":39,"s":[260.062,216.575,0],"to":[-0.14,0.315,0],"ti":[0.114,-0.269,0]},{"i":{"x":0.833,"y":0.85},"o":{"x":0.167,"y":0.183},"t":40,"s":[259.679,217.45,0],"to":[-0.114,0.269,0],"ti":[0.086,-0.224,0]},{"i":{"x":0.833,"y":0.854},"o":{"x":0.167,"y":0.187},"t":41,"s":[259.376,218.188,0],"to":[-0.086,0.224,0],"ti":[0.055,-0.178,0]},{"i":{"x":0.833,"y":0.86},"o":{"x":0.167,"y":0.194},"t":42,"s":[259.162,218.791,0],"to":[-0.055,0.178,0],"ti":[0.022,-0.132,0]},{"i":{"x":0.833,"y":0.862},"o":{"x":0.167,"y":0.205},"t":43,"s":[259.045,219.258,0],"to":[-0.022,0.132,0],"ti":[-0.015,-0.086,0]},{"i":{"x":0.833,"y":0.829},"o":{"x":0.167,"y":0.21},"t":44,"s":[259.032,219.585,0],"to":[0.015,0.086,0],"ti":[-0.054,-0.038,0]},{"i":{"x":0.833,"y":0.802},"o":{"x":0.167,"y":0.162},"t":45,"s":[259.133,219.772,0],"to":[0.054,0.038,0],"ti":[-0.089,-0.002,0]},{"i":{"x":0.833,"y":0.818},"o":{"x":0.167,"y":0.144},"t":46,"s":[259.355,219.813,0],"to":[0.089,0.002,0],"ti":[-0.114,0.012,0]},{"i":{"x":0.833,"y":0.823},"o":{"x":0.167,"y":0.153},"t":47,"s":[259.669,219.787,0],"to":[0.114,-0.012,0],"ti":[-0.131,0.018,0]},{"i":{"x":0.833,"y":0.826},"o":{"x":0.167,"y":0.157},"t":48,"s":[260.041,219.743,0],"to":[0.131,-0.018,0],"ti":[-0.144,0.024,0]},{"i":{"x":0.833,"y":0.83},"o":{"x":0.167,"y":0.16},"t":49,"s":[260.458,219.681,0],"to":[0.144,-0.024,0],"ti":[-0.153,0.025,0]},{"i":{"x":0.833,"y":0.832},"o":{"x":0.167,"y":0.163},"t":50,"s":[260.907,219.601,0],"to":[0.153,-0.025,0],"ti":[-0.159,0.017,0]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.165},"t":51,"s":[261.377,219.531,0],"to":[0.159,-0.017,0],"ti":[-0.161,0.006,0]},{"i":{"x":0.833,"y":0.834},"o":{"x":0.167,"y":0.166},"t":52,"s":[261.86,219.498,0],"to":[0.161,-0.006,0],"ti":[-0.161,-0.005,0]},{"i":{"x":0.833,"y":0.836},"o":{"x":0.167,"y":0.168},"t":53,"s":[262.346,219.497,0],"to":[0.161,0.005,0],"ti":[-0.157,-0.014,0]},{"i":{"x":0.833,"y":0.837},"o":{"x":0.167,"y":0.169},"t":54,"s":[262.825,219.527,0],"to":[0.157,0.014,0],"ti":[-0.151,-0.023,0]},{"i":{"x":0.833,"y":0.838},"o":{"x":0.167,"y":0.17},"t":55,"s":[263.289,219.583,0],"to":[0.151,0.023,0],"ti":[-0.142,-0.03,0]},{"i":{"x":0.833,"y":0.839},"o":{"x":0.167,"y":0.171},"t":56,"s":[263.73,219.663,0],"to":[0.142,0.03,0],"ti":[-0.132,-0.036,0]},{"i":{"x":0.833,"y":0.84},"o":{"x":0.167,"y":0.173},"t":57,"s":[264.142,219.763,0],"to":[0.132,0.036,0],"ti":[-0.119,-0.041,0]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.174},"t":58,"s":[264.519,219.88,0],"to":[0.119,0.041,0],"ti":[-0.056,-0.022,0]},{"t":59,"s":[264.857,220.011,0]}],"ix":2,"l":2},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":1,"k":[{"i":{"x":[0.833,0.833,0.833],"y":[1.023,-0.027,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.167,0.167,0]},"t":10,"s":[92.348,80.588,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.063,0.897,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.018,0.091,0]},"t":11,"s":[63.239,83.148,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.953,1.468,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.036,0.435,0]},"t":12,"s":[100.528,112.151,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.799,0.83,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.11,0.071,0]},"t":13,"s":[34.84,119.029,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.908,1.046,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.142,0.163,0]},"t":14,"s":[63.172,73.511,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.12,0.916,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.917,0.03,0]},"t":15,"s":[103.227,26.126,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.04,-2.324,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.049,10.207,0]},"t":16,"s":[107.23,99.638,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.987,0.97,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.027,0.085,0]},"t":17,"s":[97.456,100.244,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.007,0.792,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.016,-0.047,0]},"t":18,"s":[111.909,123.776,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.079,0.895,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.006,0.139,0]},"t":19,"s":[99.739,108.689,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.885,0.438,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.04,0.401,0]},"t":20,"s":[112.92,86.203,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.27,0.904,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.304,0.098,0]},"t":21,"s":[87.298,80.298,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.89,1.161,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.064,0.638,0]},"t":22,"s":[77.606,46.38,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.303,0.905,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.348,0.055,0]},"t":23,"s":[118.751,41.284,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.938,-2.246,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.065,0.688,0]},"t":24,"s":[131.696,56.225,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.997,0.922,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.244,0.086,0]},"t":25,"s":[71.627,58.284,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.977,0.56,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.003,-1.183,0]},"t":26,"s":[86.93,136.413,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.554,0.858,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.032,0.103,0]},"t":27,"s":[72.183,131.272,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.904,0.976,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.102,0.202,0]},"t":28,"s":[82.81,109.279,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.416,0.998,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.64,-0.034,0]},"t":29,"s":[129.066,93.807,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.845,0.765,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.069,-0.002,0]},"t":30,"s":[135.997,104.839,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.974,0.903,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.18,0.129,0]},"t":31,"s":[94.456,94.071,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.873,1.617,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.038,0.599,0]},"t":32,"s":[58.778,74.421,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.889,0.949,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.243,0.073,0]},"t":33,"s":[83.328,71.244,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.695,0.802,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.338,-0.134,0]},"t":34,"s":[96.122,97.929,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.062,1.055,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.115,0.144,0]},"t":35,"s":[100.314,87.693,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.795,0.999,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.036,0.033,0]},"t":36,"s":[111.459,73.549,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.993,0.898,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.141,-0.002,0]},"t":37,"s":[91.979,97.003,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.87,1.251,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.007,0.447,0]},"t":38,"s":[63.62,73.964,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.09,1.06,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.232,0.063,0]},"t":39,"s":[89.728,68.683,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.977,1,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.043,0.035,0]},"t":40,"s":[104.347,89.888,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.984,1.034,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.032,0,0]},"t":41,"s":[74.014,53.405,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.81,0.943,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.02,0.024,0]},"t":42,"s":[95.84,90.08,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.816,0.763,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.148,-0.184,0]},"t":43,"s":[78.32,38.425,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.899,0.993,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.152,0.129,0]},"t":44,"s":[55.867,54.538,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.701,0.99,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.487,-0.007,0]},"t":45,"s":[28.81,84.258,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.904,1.154,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.115,-0.011,0]},"t":46,"s":[23.23,56.921,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.701,0.959,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.611,0.054,0]},"t":47,"s":[8.759,81.032,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.089,0.935,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.116,-0.083,0]},"t":48,"s":[6.473,12.233,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.758,1.033,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.043,-0.293,0]},"t":49,"s":[0.558,46.8,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.984,1.273,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.127,0.023,0]},"t":50,"s":[12.808,39.141,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.938,0.953,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.02,0.064,0]},"t":51,"s":[36.176,49.79,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.867,0.954,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.242,-0.11,0]},"t":52,"s":[17.41,4.217,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.67,0.939,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.224,-0.102,0]},"t":53,"s":[22.21,23.867,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.825,1.394,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.112,-0.223,0]},"t":54,"s":[25.048,15.014,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.216,0.678,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.159,0.069,0]},"t":55,"s":[33.433,17.425,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.985,1.08,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.06,0.112,0]},"t":56,"s":[42.612,3.611,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.012,0.926,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.019,0.041,0]},"t":57,"s":[9.673,-36.008,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.833,0.833,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.01,-0.68,0]},"t":58,"s":[36.625,41.5,100]},{"t":59,"s":[5.886,33.044,100]}],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[0.012,10.621],[-5.695,5.34],[-2.557,-7.377],[3.15,-2.096]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[1,0.8941176470588236,0.11372549019607843,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[-3.421,4.043],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Rectangle 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":10,"op":60,"st":10,"bm":0},{"ddd":0,"ind":19,"ty":4,"nm":"Shape Layer 33","sr":1,"ks":{"o":{"a":1,"k":[{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":1,"s":[0]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":6,"s":[100]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":28,"s":[100]},{"t":44,"s":[0]}],"ix":11},"r":{"a":0,"k":92,"ix":10,"x":"var $bm_rt;\n$bm_rt = $bm_mul(time, 300);"},"p":{"a":1,"k":[{"i":{"x":0.833,"y":0.635},"o":{"x":0.167,"y":0.167},"t":1,"s":[270.415,303.961,0],"to":[0.303,-0.268,0],"ti":[-1.389,1.063,0]},{"i":{"x":0.833,"y":0.798},"o":{"x":0.167,"y":0.108},"t":2,"s":[272.233,302.354,0],"to":[1.389,-1.063,0],"ti":[-2.915,0.504,0]},{"i":{"x":0.833,"y":0.803},"o":{"x":0.167,"y":0.142},"t":3,"s":[278.751,297.585,0],"to":[2.915,-0.504,0],"ti":[-1.552,-2.903,0]},{"i":{"x":0.833,"y":0.844},"o":{"x":0.167,"y":0.144},"t":4,"s":[289.724,299.332,0],"to":[1.552,2.903,0],"ti":[0.807,-4.867,0]},{"i":{"x":0.833,"y":0.847},"o":{"x":0.167,"y":0.179},"t":5,"s":[288.065,315.004,0],"to":[-0.807,4.867,0],"ti":[1.017,-4.14,0]},{"i":{"x":0.833,"y":0.846},"o":{"x":0.167,"y":0.183},"t":6,"s":[284.884,328.537,0],"to":[-1.017,4.14,0],"ti":[0.905,-3.485,0]},{"i":{"x":0.833,"y":0.845},"o":{"x":0.167,"y":0.181},"t":7,"s":[281.965,339.842,0],"to":[-0.905,3.485,0],"ti":[0.776,-2.987,0]},{"i":{"x":0.833,"y":0.843},"o":{"x":0.167,"y":0.18},"t":8,"s":[279.455,349.449,0],"to":[-0.776,2.987,0],"ti":[0.667,-2.607,0]},{"i":{"x":0.833,"y":0.842},"o":{"x":0.167,"y":0.178},"t":9,"s":[277.308,357.764,0],"to":[-0.667,2.607,0],"ti":[0.578,-2.312,0]},{"i":{"x":0.833,"y":0.842},"o":{"x":0.167,"y":0.177},"t":10,"s":[275.455,365.094,0],"to":[-0.578,2.312,0],"ti":[0.505,-2.074,0]},{"i":{"x":0.833,"y":0.841},"o":{"x":0.167,"y":0.176},"t":11,"s":[273.842,371.638,0],"to":[-0.505,2.074,0],"ti":[0.444,-1.876,0]},{"i":{"x":0.833,"y":0.841},"o":{"x":0.167,"y":0.175},"t":12,"s":[272.427,377.536,0],"to":[-0.444,1.876,0],"ti":[0.393,-1.708,0]},{"i":{"x":0.833,"y":0.84},"o":{"x":0.167,"y":0.175},"t":13,"s":[271.178,382.891,0],"to":[-0.393,1.708,0],"ti":[0.349,-1.562,0]},{"i":{"x":0.833,"y":0.84},"o":{"x":0.167,"y":0.174},"t":14,"s":[270.071,387.781,0],"to":[-0.349,1.562,0],"ti":[0.31,-1.434,0]},{"i":{"x":0.833,"y":0.84},"o":{"x":0.167,"y":0.174},"t":15,"s":[269.086,392.264,0],"to":[-0.31,1.434,0],"ti":[0.275,-1.32,0]},{"i":{"x":0.833,"y":0.84},"o":{"x":0.167,"y":0.174},"t":16,"s":[268.211,396.386,0],"to":[-0.275,1.32,0],"ti":[0.243,-1.215,0]},{"i":{"x":0.833,"y":0.84},"o":{"x":0.167,"y":0.174},"t":17,"s":[267.436,400.181,0],"to":[-0.243,1.215,0],"ti":[0.213,-1.118,0]},{"i":{"x":0.833,"y":0.84},"o":{"x":0.167,"y":0.174},"t":18,"s":[266.752,403.676,0],"to":[-0.213,1.118,0],"ti":[0.185,-1.028,0]},{"i":{"x":0.833,"y":0.841},"o":{"x":0.167,"y":0.174},"t":19,"s":[266.156,406.891,0],"to":[-0.185,1.028,0],"ti":[0.158,-0.941,0]},{"i":{"x":0.833,"y":0.841},"o":{"x":0.167,"y":0.175},"t":20,"s":[265.642,409.842,0],"to":[-0.158,0.941,0],"ti":[0.131,-0.859,0]},{"i":{"x":0.833,"y":0.842},"o":{"x":0.167,"y":0.175},"t":21,"s":[265.209,412.54,0],"to":[-0.131,0.859,0],"ti":[0.105,-0.779,0]},{"i":{"x":0.833,"y":0.842},"o":{"x":0.167,"y":0.176},"t":22,"s":[264.855,414.996,0],"to":[-0.105,0.779,0],"ti":[0.078,-0.702,0]},{"i":{"x":0.833,"y":0.843},"o":{"x":0.167,"y":0.177},"t":23,"s":[264.581,417.216,0],"to":[-0.078,0.702,0],"ti":[0.051,-0.625,0]},{"i":{"x":0.833,"y":0.844},"o":{"x":0.167,"y":0.178},"t":24,"s":[264.386,419.205,0],"to":[-0.051,0.625,0],"ti":[0.024,-0.55,0]},{"i":{"x":0.833,"y":0.845},"o":{"x":0.167,"y":0.179},"t":25,"s":[264.272,420.968,0],"to":[-0.024,0.55,0],"ti":[-0.004,-0.475,0]},{"i":{"x":0.833,"y":0.847},"o":{"x":0.167,"y":0.181},"t":26,"s":[264.241,422.506,0],"to":[0.004,0.475,0],"ti":[-0.032,-0.401,0]},{"i":{"x":0.833,"y":0.849},"o":{"x":0.167,"y":0.183},"t":27,"s":[264.295,423.82,0],"to":[0.032,0.401,0],"ti":[-0.062,-0.326,0]},{"i":{"x":0.833,"y":0.85},"o":{"x":0.167,"y":0.185},"t":28,"s":[264.435,424.912,0],"to":[0.062,0.326,0],"ti":[-0.092,-0.251,0]},{"i":{"x":0.833,"y":0.848},"o":{"x":0.167,"y":0.187},"t":29,"s":[264.666,425.779,0],"to":[0.092,0.251,0],"ti":[-0.123,-0.178,0]},{"i":{"x":0.833,"y":0.839},"o":{"x":0.167,"y":0.184},"t":30,"s":[264.99,426.419,0],"to":[0.123,0.178,0],"ti":[-0.144,-0.125,0]},{"i":{"x":0.833,"y":0.837},"o":{"x":0.167,"y":0.173},"t":31,"s":[265.403,426.848,0],"to":[0.144,0.125,0],"ti":[-0.154,-0.094,0]},{"i":{"x":0.833,"y":0.837},"o":{"x":0.167,"y":0.171},"t":32,"s":[265.856,427.168,0],"to":[0.154,0.094,0],"ti":[-0.158,-0.068,0]},{"i":{"x":0.833,"y":0.837},"o":{"x":0.167,"y":0.17},"t":33,"s":[266.328,427.409,0],"to":[0.158,0.068,0],"ti":[-0.158,-0.046,0]},{"i":{"x":0.833,"y":0.838},"o":{"x":0.167,"y":0.171},"t":34,"s":[266.806,427.579,0],"to":[0.158,0.046,0],"ti":[-0.155,-0.026,0]},{"i":{"x":0.833,"y":0.838},"o":{"x":0.167,"y":0.171},"t":35,"s":[267.278,427.686,0],"to":[0.155,0.026,0],"ti":[-0.148,-0.008,0]},{"i":{"x":0.833,"y":0.839},"o":{"x":0.167,"y":0.172},"t":36,"s":[267.736,427.736,0],"to":[0.148,0.008,0],"ti":[-0.139,0.007,0]},{"i":{"x":0.833,"y":0.84},"o":{"x":0.167,"y":0.173},"t":37,"s":[268.169,427.736,0],"to":[0.139,-0.007,0],"ti":[-0.127,0.022,0]},{"i":{"x":0.833,"y":0.84},"o":{"x":0.167,"y":0.174},"t":38,"s":[268.571,427.692,0],"to":[0.127,-0.022,0],"ti":[-0.114,0.034,0]},{"i":{"x":0.833,"y":0.841},"o":{"x":0.167,"y":0.174},"t":39,"s":[268.934,427.607,0],"to":[0.114,-0.034,0],"ti":[-0.098,0.046,0]},{"i":{"x":0.833,"y":0.845},"o":{"x":0.167,"y":0.175},"t":40,"s":[269.252,427.486,0],"to":[0.098,-0.046,0],"ti":[-0.081,0.051,0]},{"i":{"x":0.833,"y":0.852},"o":{"x":0.167,"y":0.18},"t":41,"s":[269.522,427.333,0],"to":[0.081,-0.051,0],"ti":[-0.064,0.046,0]},{"i":{"x":0.833,"y":0.858},"o":{"x":0.167,"y":0.191},"t":42,"s":[269.74,427.18,0],"to":[0.064,-0.046,0],"ti":[-0.047,0.035,0]},{"i":{"x":0.833,"y":0.868},"o":{"x":0.167,"y":0.202},"t":43,"s":[269.907,427.059,0],"to":[0.047,-0.035,0],"ti":[-0.029,0.025,0]},{"i":{"x":0.833,"y":0.882},"o":{"x":0.167,"y":0.227},"t":44,"s":[270.02,426.97,0],"to":[0.029,-0.025,0],"ti":[-0.011,0.015,0]},{"i":{"x":0.833,"y":0.799},"o":{"x":0.167,"y":0.281},"t":45,"s":[270.079,426.911,0],"to":[0.011,-0.015,0],"ti":[0.007,0.006,0]},{"i":{"x":0.833,"y":0.748},"o":{"x":0.167,"y":0.142},"t":46,"s":[270.083,426.879,0],"to":[-0.007,-0.006,0],"ti":[0.025,-0.002,0]},{"i":{"x":0.833,"y":0.791},"o":{"x":0.167,"y":0.125},"t":47,"s":[270.035,426.873,0],"to":[-0.025,0.002,0],"ti":[0.041,-0.009,0]},{"i":{"x":0.833,"y":0.808},"o":{"x":0.167,"y":0.139},"t":48,"s":[269.935,426.889,0],"to":[-0.041,0.009,0],"ti":[0.056,-0.014,0]},{"i":{"x":0.833,"y":0.816},"o":{"x":0.167,"y":0.147},"t":49,"s":[269.788,426.925,0],"to":[-0.056,0.014,0],"ti":[0.07,-0.019,0]},{"i":{"x":0.833,"y":0.822},"o":{"x":0.167,"y":0.153},"t":50,"s":[269.597,426.976,0],"to":[-0.07,0.019,0],"ti":[0.082,-0.022,0]},{"i":{"x":0.833,"y":0.826},"o":{"x":0.167,"y":0.156},"t":51,"s":[269.367,427.038,0],"to":[-0.082,0.022,0],"ti":[0.092,-0.023,0]},{"i":{"x":0.833,"y":0.829},"o":{"x":0.167,"y":0.16},"t":52,"s":[269.104,427.106,0],"to":[-0.092,0.023,0],"ti":[0.099,-0.022,0]},{"i":{"x":0.833,"y":0.831},"o":{"x":0.167,"y":0.162},"t":53,"s":[268.815,427.175,0],"to":[-0.099,0.022,0],"ti":[0.104,-0.02,0]},{"i":{"x":0.833,"y":0.834},"o":{"x":0.167,"y":0.165},"t":54,"s":[268.508,427.24,0],"to":[-0.104,0.02,0],"ti":[0.105,-0.015,0]},{"i":{"x":0.833,"y":0.837},"o":{"x":0.167,"y":0.167},"t":55,"s":[268.192,427.293,0],"to":[-0.105,0.015,0],"ti":[0.103,-0.008,0]},{"i":{"x":0.833,"y":0.839},"o":{"x":0.167,"y":0.17},"t":56,"s":[267.876,427.329,0],"to":[-0.103,0.008,0],"ti":[0.098,0.002,0]},{"i":{"x":0.833,"y":0.842},"o":{"x":0.167,"y":0.173},"t":57,"s":[267.571,427.341,0],"to":[-0.098,-0.002,0],"ti":[0.088,0.014,0]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.176},"t":58,"s":[267.289,427.32,0],"to":[-0.088,-0.014,0],"ti":[0.041,0.01,0]},{"t":59,"s":[267.042,427.258,0]}],"ix":2,"l":2},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":1,"k":[{"i":{"x":[0.833,0.833,0.833],"y":[1.161,0.992,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.167,0.167,0]},"t":1,"s":[88.706,125.309,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.987,0.969,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.055,-0.009,0]},"t":2,"s":[101.05,65.647,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.822,0.862,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.015,-0.05,0]},"t":3,"s":[64.822,119.625,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.03,1.194,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.156,0.21,0]},"t":4,"s":[95.37,85.974,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.939,0.943,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.022,0.058,0]},"t":5,"s":[130.225,63.782,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.866,0.793,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.229,-0.185,0]},"t":6,"s":[82.767,137.754,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.05,0.961,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.219,0.139,0]},"t":7,"s":[95.438,114.769,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.544,0.867,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.031,-0.072,0]},"t":8,"s":[103.2,80.591,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.032,0.895,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.102,0.224,0]},"t":9,"s":[90.818,98.938,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.967,0.336,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.023,0.402,0]},"t":10,"s":[35.459,109.792,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.842,1.105,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.056,0.095,0]},"t":11,"s":[111.828,112.627,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.085,0.874,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.176,0.046,0]},"t":12,"s":[66.113,132.396,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.934,0.924,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.042,0.248,0]},"t":13,"s":[25.062,87.733,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.884,1.494,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.328,-0.831,0]},"t":14,"s":[107.997,65.085,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.34,1.282,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.298,0.071,0]},"t":15,"s":[91.212,67.15,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.92,0.947,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.067,0.064,0]},"t":16,"s":[84.699,52.844,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.04,0.928,1]},"o":{"x":[0.167,0.167,0.167],"y":[-2.065,-0.147,0]},"t":17,"s":[117.811,115.589,100]},{"i":{"x":[0.833,0.833,0.833],"y":[2.695,1.984,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.027,-0.553,0]},"t":18,"s":[116.527,92.87,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.902,0.923,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.079,0.077,0]},"t":19,"s":[118.433,95.846,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.407,0.784,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.556,-0.959,0]},"t":20,"s":[77.764,57.746,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.863,0.619,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.069,0.136,0]},"t":21,"s":[70.59,60.792,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.073,1.124,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.213,0.107,0]},"t":22,"s":[112.827,65.643,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.871,1.062,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.039,0.05,0]},"t":23,"s":[139.959,82.972,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.858,0.887,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.235,0.036,0]},"t":24,"s":[89.044,39.953,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.03,1.025,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.202,0.321,0]},"t":25,"s":[61.106,115.024,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.926,0.861,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.022,0.019,0]},"t":26,"s":[41.52,141.346,100]},{"i":{"x":[0.833,0.833,0.833],"y":[2.086,0.818,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.674,0.209,0]},"t":27,"s":[68.262,107.246,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.999,0.94,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.077,0.154,0]},"t":28,"s":[65.317,84.613,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.919,1.127,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.001,-0.216,0]},"t":29,"s":[106.651,57.805,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.947,0.938,1]},"o":{"x":[0.167,0.167,0.167],"y":[-3.288,0.05,0]},"t":30,"s":[66.005,65.265,100]},{"i":{"x":[0.833,0.833,0.833],"y":[-1.723,0.574,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.143,-0.241,0]},"t":31,"s":[67.009,46.429,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.991,1.067,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.086,0.104,0]},"t":32,"s":[66.639,51.274,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.778,0.921,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.01,0.037,0]},"t":33,"s":[54.923,71.216,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.071,0.741,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.133,-1.444,0]},"t":34,"s":[65.427,35.22,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.878,1.091,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.038,0.123,0]},"t":35,"s":[82.893,37.183,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.906,0.828,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.264,0.043,0]},"t":36,"s":[50.525,41.333,100]},{"i":{"x":[0.833,0.833,0.833],"y":[2.063,1.344,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.747,0.162,0]},"t":37,"s":[35.563,32.66,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.889,1.002,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.077,0.067,0]},"t":38,"s":[33.685,23.422,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.055,0.924,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.331,0.002,0]},"t":39,"s":[59.517,70.825,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.967,1.608,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.033,-0.9,0]},"t":40,"s":[68.226,22.181,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.215,0.945,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.055,0.073,0]},"t":41,"s":[53.746,26.302,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.832,0.514,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.06,-0.163,0]},"t":42,"s":[62.499,-7.882,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.925,0.968,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.166,0.101,0]},"t":43,"s":[31.128,3.701,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.261,0.975,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.72,-0.052,0]},"t":44,"s":[-0.586,59.702,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.131,1.044,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.063,-0.035,0]},"t":45,"s":[2.703,25.328,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.938,0.936,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.051,0.029,0]},"t":46,"s":[-10.892,49.534,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.851,0.962,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.242,-0.278,0]},"t":47,"s":[24.109,12.637,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.541,1.12,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.189,-0.068,0]},"t":48,"s":[15.151,21.155,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.973,1.225,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.072,0.049,0]},"t":49,"s":[8.06,16.479,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.881,0.962,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.04,0.061,0]},"t":50,"s":[61.168,27.875,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.037,0.929,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.278,-0.07,0]},"t":51,"s":[25.413,-14.304,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.927,1.673,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.026,-0.462,0]},"t":52,"s":[10.106,8.552,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.809,0.934,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.597,0.074,0]},"t":53,"s":[32.24,5.058,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.896,0.766,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.148,-0.321,0]},"t":54,"s":[29.528,36.767,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.876,1.171,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.422,0.129,0]},"t":55,"s":[26.015,30.226,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.716,0.961,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.252,0.056,0]},"t":56,"s":[25.151,18.362,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.719,0.812,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.118,-0.074,0]},"t":57,"s":[24.725,54.585,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.833,0.833,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.075,0.15,0]},"t":58,"s":[23.696,35.402,100]},{"t":59,"s":[33.602,11.392,100]}],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[0.012,10.621],[-5.695,5.34],[-2.557,-7.377],[3.15,-2.096]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0.3137254901960784,0.8901960784313725,0.7607843137254902,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[-3.421,4.043],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Rectangle 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":1,"op":60,"st":1,"bm":0},{"ddd":0,"ind":20,"ty":4,"nm":"Shape Layer 32","sr":1,"ks":{"o":{"a":1,"k":[{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":4,"s":[0]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":9,"s":[100]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":31,"s":[100]},{"t":47,"s":[0]}],"ix":11},"r":{"a":0,"k":92,"ix":10,"x":"var $bm_rt;\n$bm_rt = $bm_mul(time, 300);"},"p":{"a":1,"k":[{"i":{"x":0.833,"y":0.569},"o":{"x":0.167,"y":0.167},"t":4,"s":[267.904,308.221,0],"to":[-0.062,-0.384,0],"ti":[-0.406,2.013,0]},{"i":{"x":0.833,"y":0.761},"o":{"x":0.167,"y":0.103},"t":5,"s":[267.529,305.917,0],"to":[0.406,-2.013,0],"ti":[-2.229,4.277,0]},{"i":{"x":0.833,"y":0.824},"o":{"x":0.167,"y":0.128},"t":6,"s":[270.337,296.143,0],"to":[2.229,-4.277,0],"ti":[-4.056,5.365,0]},{"i":{"x":0.833,"y":0.844},"o":{"x":0.167,"y":0.158},"t":7,"s":[280.902,280.252,0],"to":[4.056,-5.365,0],"ti":[-4.394,5.019,0]},{"i":{"x":0.833,"y":0.847},"o":{"x":0.167,"y":0.178},"t":8,"s":[294.676,263.955,0],"to":[4.394,-5.019,0],"ti":[-3.868,4.214,0]},{"i":{"x":0.833,"y":0.846},"o":{"x":0.167,"y":0.183},"t":9,"s":[307.264,250.141,0],"to":[3.868,-4.214,0],"ti":[-3.257,3.538,0]},{"i":{"x":0.833,"y":0.845},"o":{"x":0.167,"y":0.182},"t":10,"s":[317.883,238.67,0],"to":[3.257,-3.538,0],"ti":[-2.752,3.047,0]},{"i":{"x":0.833,"y":0.843},"o":{"x":0.167,"y":0.18},"t":11,"s":[326.809,228.915,0],"to":[2.752,-3.047,0],"ti":[-2.349,2.69,0]},{"i":{"x":0.833,"y":0.842},"o":{"x":0.167,"y":0.178},"t":12,"s":[334.394,220.39,0],"to":[2.349,-2.69,0],"ti":[-2.023,2.427,0]},{"i":{"x":0.833,"y":0.841},"o":{"x":0.167,"y":0.177},"t":13,"s":[340.902,212.773,0],"to":[2.023,-2.427,0],"ti":[-1.753,2.23,0]},{"i":{"x":0.833,"y":0.841},"o":{"x":0.167,"y":0.176},"t":14,"s":[346.531,205.826,0],"to":[1.753,-2.23,0],"ti":[-1.519,2.072,0]},{"i":{"x":0.833,"y":0.841},"o":{"x":0.167,"y":0.175},"t":15,"s":[351.417,199.393,0],"to":[1.519,-2.072,0],"ti":[-1.306,1.928,0]},{"i":{"x":0.833,"y":0.841},"o":{"x":0.167,"y":0.175},"t":16,"s":[355.644,193.397,0],"to":[1.306,-1.928,0],"ti":[-1.106,1.797,0]},{"i":{"x":0.833,"y":0.841},"o":{"x":0.167,"y":0.175},"t":17,"s":[359.255,187.824,0],"to":[1.106,-1.797,0],"ti":[-0.913,1.682,0]},{"i":{"x":0.833,"y":0.841},"o":{"x":0.167,"y":0.175},"t":18,"s":[362.28,182.616,0],"to":[0.913,-1.682,0],"ti":[-0.72,1.579,0]},{"i":{"x":0.833,"y":0.841},"o":{"x":0.167,"y":0.175},"t":19,"s":[364.731,177.73,0],"to":[0.72,-1.579,0],"ti":[-0.524,1.479,0]},{"i":{"x":0.833,"y":0.842},"o":{"x":0.167,"y":0.176},"t":20,"s":[366.601,173.143,0],"to":[0.524,-1.479,0],"ti":[-0.321,1.378,0]},{"i":{"x":0.833,"y":0.842},"o":{"x":0.167,"y":0.176},"t":21,"s":[367.872,168.855,0],"to":[0.321,-1.378,0],"ti":[-0.115,1.264,0]},{"i":{"x":0.833,"y":0.842},"o":{"x":0.167,"y":0.176},"t":22,"s":[368.526,164.876,0],"to":[0.115,-1.264,0],"ti":[0.083,1.13,0]},{"i":{"x":0.833,"y":0.842},"o":{"x":0.167,"y":0.177},"t":23,"s":[368.561,161.269,0],"to":[-0.083,-1.13,0],"ti":[0.254,0.978,0]},{"i":{"x":0.833,"y":0.842},"o":{"x":0.167,"y":0.177},"t":24,"s":[368.028,158.096,0],"to":[-0.254,-0.978,0],"ti":[0.382,0.82,0]},{"i":{"x":0.833,"y":0.841},"o":{"x":0.167,"y":0.176},"t":25,"s":[367.038,155.4,0],"to":[-0.382,-0.82,0],"ti":[0.464,0.67,0]},{"i":{"x":0.833,"y":0.841},"o":{"x":0.167,"y":0.176},"t":26,"s":[365.735,153.176,0],"to":[-0.464,-0.67,0],"ti":[0.506,0.539,0]},{"i":{"x":0.833,"y":0.84},"o":{"x":0.167,"y":0.175},"t":27,"s":[364.254,151.378,0],"to":[-0.506,-0.539,0],"ti":[0.52,0.43,0]},{"i":{"x":0.833,"y":0.84},"o":{"x":0.167,"y":0.174},"t":28,"s":[362.7,149.94,0],"to":[-0.52,-0.43,0],"ti":[0.517,0.343,0]},{"i":{"x":0.833,"y":0.839},"o":{"x":0.167,"y":0.174},"t":29,"s":[361.136,148.795,0],"to":[-0.517,-0.343,0],"ti":[0.505,0.273,0]},{"i":{"x":0.833,"y":0.839},"o":{"x":0.167,"y":0.173},"t":30,"s":[359.597,147.883,0],"to":[-0.505,-0.273,0],"ti":[0.487,0.216,0]},{"i":{"x":0.833,"y":0.839},"o":{"x":0.167,"y":0.173},"t":31,"s":[358.105,147.16,0],"to":[-0.487,-0.216,0],"ti":[0.465,0.169,0]},{"i":{"x":0.833,"y":0.839},"o":{"x":0.167,"y":0.173},"t":32,"s":[356.676,146.589,0],"to":[-0.465,-0.169,0],"ti":[0.441,0.132,0]},{"i":{"x":0.833,"y":0.839},"o":{"x":0.167,"y":0.173},"t":33,"s":[355.317,146.143,0],"to":[-0.441,-0.132,0],"ti":[0.415,0.101,0]},{"i":{"x":0.833,"y":0.84},"o":{"x":0.167,"y":0.173},"t":34,"s":[354.033,145.799,0],"to":[-0.415,-0.101,0],"ti":[0.388,0.076,0]},{"i":{"x":0.833,"y":0.84},"o":{"x":0.167,"y":0.174},"t":35,"s":[352.827,145.538,0],"to":[-0.388,-0.076,0],"ti":[0.36,0.056,0]},{"i":{"x":0.833,"y":0.841},"o":{"x":0.167,"y":0.174},"t":36,"s":[351.704,145.343,0],"to":[-0.36,-0.056,0],"ti":[0.33,0.04,0]},{"i":{"x":0.833,"y":0.842},"o":{"x":0.167,"y":0.175},"t":37,"s":[350.668,145.203,0],"to":[-0.33,-0.04,0],"ti":[0.298,0.027,0]},{"i":{"x":0.833,"y":0.844},"o":{"x":0.167,"y":0.177},"t":38,"s":[349.724,145.104,0],"to":[-0.298,-0.027,0],"ti":[0.263,0.019,0]},{"i":{"x":0.833,"y":0.846},"o":{"x":0.167,"y":0.179},"t":39,"s":[348.882,145.038,0],"to":[-0.263,-0.019,0],"ti":[0.227,0.013,0]},{"i":{"x":0.833,"y":0.849},"o":{"x":0.167,"y":0.181},"t":40,"s":[348.144,144.993,0],"to":[-0.227,-0.013,0],"ti":[0.189,0.011,0]},{"i":{"x":0.833,"y":0.855},"o":{"x":0.167,"y":0.187},"t":41,"s":[347.517,144.959,0],"to":[-0.189,-0.011,0],"ti":[0.146,0.011,0]},{"i":{"x":0.833,"y":0.865},"o":{"x":0.167,"y":0.196},"t":42,"s":[347.011,144.929,0],"to":[-0.146,-0.011,0],"ti":[0.1,0.014,0]},{"i":{"x":0.833,"y":0.867},"o":{"x":0.167,"y":0.218},"t":43,"s":[346.639,144.893,0],"to":[-0.1,-0.014,0],"ti":[0.056,0.022,0]},{"i":{"x":0.833,"y":0.836},"o":{"x":0.167,"y":0.224},"t":44,"s":[346.413,144.843,0],"to":[-0.056,-0.022,0],"ti":[0.03,0.033,0]},{"i":{"x":0.833,"y":0.816},"o":{"x":0.167,"y":0.169},"t":45,"s":[346.304,144.761,0],"to":[-0.03,-0.033,0],"ti":[0.031,0.038,0]},{"i":{"x":0.833,"y":0.817},"o":{"x":0.167,"y":0.152},"t":46,"s":[346.234,144.648,0],"to":[-0.031,-0.038,0],"ti":[0.045,0.038,0]},{"i":{"x":0.833,"y":0.82},"o":{"x":0.167,"y":0.153},"t":47,"s":[346.12,144.534,0],"to":[-0.045,-0.038,0],"ti":[0.059,0.037,0]},{"i":{"x":0.833,"y":0.822},"o":{"x":0.167,"y":0.155},"t":48,"s":[345.962,144.422,0],"to":[-0.059,-0.037,0],"ti":[0.072,0.036,0]},{"i":{"x":0.833,"y":0.824},"o":{"x":0.167,"y":0.157},"t":49,"s":[345.764,144.313,0],"to":[-0.072,-0.036,0],"ti":[0.083,0.034,0]},{"i":{"x":0.833,"y":0.826},"o":{"x":0.167,"y":0.158},"t":50,"s":[345.53,144.207,0],"to":[-0.083,-0.034,0],"ti":[0.093,0.033,0]},{"i":{"x":0.833,"y":0.827},"o":{"x":0.167,"y":0.16},"t":51,"s":[345.264,144.107,0],"to":[-0.093,-0.033,0],"ti":[0.102,0.031,0]},{"i":{"x":0.833,"y":0.828},"o":{"x":0.167,"y":0.161},"t":52,"s":[344.97,144.011,0],"to":[-0.102,-0.031,0],"ti":[0.11,0.029,0]},{"i":{"x":0.833,"y":0.829},"o":{"x":0.167,"y":0.162},"t":53,"s":[344.65,143.922,0],"to":[-0.11,-0.029,0],"ti":[0.117,0.026,0]},{"i":{"x":0.833,"y":0.83},"o":{"x":0.167,"y":0.163},"t":54,"s":[344.308,143.839,0],"to":[-0.117,-0.026,0],"ti":[0.123,0.024,0]},{"i":{"x":0.833,"y":0.831},"o":{"x":0.167,"y":0.164},"t":55,"s":[343.947,143.764,0],"to":[-0.123,-0.024,0],"ti":[0.127,0.022,0]},{"i":{"x":0.833,"y":0.832},"o":{"x":0.167,"y":0.164},"t":56,"s":[343.572,143.695,0],"to":[-0.127,-0.022,0],"ti":[0.13,0.019,0]},{"i":{"x":0.833,"y":0.832},"o":{"x":0.167,"y":0.165},"t":57,"s":[343.185,143.634,0],"to":[-0.13,-0.019,0],"ti":[0.133,0.017,0]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.166},"t":58,"s":[342.79,143.581,0],"to":[-0.133,-0.017,0],"ti":[0.067,0.008,0]},{"t":59,"s":[342.389,143.535,0]}],"ix":2,"l":2},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":1,"k":[{"i":{"x":[0.833,0.833,0.833],"y":[0.916,0.916,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.167,0.167,0]},"t":4,"s":[146.545,56.981,100]},{"i":{"x":[0.833,0.833,0.833],"y":[2.984,6.136,1]},"o":{"x":[0.167,0.167,0.167],"y":[7.109,6.416,0]},"t":5,"s":[102.654,113.791,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.082,0.982,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.08,0.082,0]},"t":6,"s":[102.133,114.538,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.769,0.88,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.041,-0.024,0]},"t":7,"s":[115.051,67.711,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.979,0.924,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.13,0.272,0]},"t":8,"s":[89.461,104.174,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.843,0.968,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.029,-0.825,0]},"t":9,"s":[44.049,120.312,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.068,5.668,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.177,-0.052,0]},"t":10,"s":[77.823,118.832,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.009,0.967,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.037,0.082,0]},"t":11,"s":[107.747,119.743,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.92,0.945,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.009,-0.054,0]},"t":12,"s":[53.479,67.782,100]},{"i":{"x":[0.833,0.833,0.833],"y":[-1.061,0.515,1]},"o":{"x":[0.167,0.167,0.167],"y":[-2.064,-0.16,0]},"t":13,"s":[113.921,99.393,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.981,1.019,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.087,0.101,0]},"t":14,"s":[111.576,88.565,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.95,0.906,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.025,0.015,0]},"t":15,"s":[55.899,36.375,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.094,1.148,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.128,0.737,0]},"t":16,"s":[98.768,100.4,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.97,0.816,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.044,0.053,0]},"t":17,"s":[81.873,108.568,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.853,0.986,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.047,0.153,0]},"t":18,"s":[117.879,85.877,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.034,0.862,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.192,-0.017,0]},"t":19,"s":[94.849,58.578,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.781,0.836,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.024,0.211,0]},"t":20,"s":[77.175,81.139,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.98,1.271,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.135,0.169,0]},"t":21,"s":[102.134,95.906,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.835,0.976,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.026,0.064,0]},"t":22,"s":[142.654,110.218,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.797,0.841,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.168,-0.034,0]},"t":23,"s":[111.887,49.352,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.009,1.01,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.141,0.175,0]},"t":24,"s":[81.638,92.763,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.947,0.894,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.008,0.009,0]},"t":25,"s":[38.028,132.048,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.012,0.82,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.143,0.392,0]},"t":26,"s":[86.128,88.194,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.088,0.901,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.011,0.155,0]},"t":27,"s":[68.431,76.37,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.042,1.263,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.043,0.518,0]},"t":28,"s":[88.699,62.632,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.935,1.037,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.028,0.063,0]},"t":29,"s":[46.98,59.998,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.851,1.103,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.298,0.026,0]},"t":30,"s":[109.874,70.958,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.881,1.047,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.189,0.046,0]},"t":31,"s":[96.114,55.137,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.231,0.962,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.278,0.03,0]},"t":32,"s":[85.214,90.54,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.189,0.931,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.061,-0.071,0]},"t":33,"s":[80.545,35.132,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.94,0.796,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.058,-0.412,0]},"t":34,"s":[98.178,65.031,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.966,0.606,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.209,0.141,0]},"t":35,"s":[40.533,60.003,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.938,1.021,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.059,0.106,0]},"t":36,"s":[56.966,52.732,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.747,1.016,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.244,0.016,0]},"t":37,"s":[47.335,25.638,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.818,0.906,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.124,0.014,0]},"t":38,"s":[49.783,59.415,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.061,0.813,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.154,0.718,0]},"t":39,"s":[54.761,18.968,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.894,1.058,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.035,0.15,0]},"t":40,"s":[60.644,13.657,100]},{"i":{"x":[0.833,0.833,0.833],"y":[-0.945,0.618,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.384,0.034,0]},"t":41,"s":[50.471,7.064,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.973,0.982,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.087,0.107,0]},"t":42,"s":[47.647,18.253,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.927,0.88,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.04,-0.023,0]},"t":43,"s":[-15.426,58.31,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.52,0.892,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.604,0.275,0]},"t":44,"s":[27.159,27.049,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.928,0.901,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.072,0.364,0]},"t":45,"s":[21.997,13.425,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.588,1.135,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.54,0.54,0]},"t":46,"s":[59.347,9.373,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.055,1.741,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.104,0.051,0]},"t":47,"s":[54.352,8.634,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.999,0.829,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.033,0.075,0]},"t":48,"s":[34.643,10.569,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.881,0.973,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.001,0.163,0]},"t":49,"s":[67.47,-8.566,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.92,0.842,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.276,-0.04,0]},"t":50,"s":[34.991,-28.664,100]},{"i":{"x":[0.833,0.833,0.833],"y":[3.321,0.639,1]},"o":{"x":[0.167,0.167,0.167],"y":[-1.801,0.176,0]},"t":51,"s":[20.908,-15.133,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.876,0.894,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.08,0.108,0]},"t":52,"s":[21.531,-2.998,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.331,0.787,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.256,0.39,0]},"t":53,"s":[3.556,37.39,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.912,0.945,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.067,0.137,0]},"t":54,"s":[-5.112,48.357,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.837,0.83,1]},"o":{"x":[0.167,0.167,0.167],"y":[1.482,-0.165,0]},"t":55,"s":[37.958,65.386,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.163,0.146,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.171,0.163,0]},"t":56,"s":[40.525,59.682,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.082,0.927,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.093,0.092,0]},"t":57,"s":[42.972,53.735,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.833,0.833,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.041,-0.57,0]},"t":58,"s":[65.106,-1.291,100]},{"t":59,"s":[21.097,5.723,100]}],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[0.012,10.621],[-5.695,5.34],[-2.557,-7.377],[3.15,-2.096]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[1,0.8941176470588236,0.11372549019607843,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[-3.421,4.043],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Rectangle 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":4,"op":60,"st":4,"bm":0},{"ddd":0,"ind":21,"ty":4,"nm":"Shape Layer 31","sr":1,"ks":{"o":{"a":1,"k":[{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":5,"s":[0]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":10,"s":[100]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":32,"s":[100]},{"t":48,"s":[0]}],"ix":11},"r":{"a":0,"k":92,"ix":10,"x":"var $bm_rt;\n$bm_rt = $bm_mul(time, 300);"},"p":{"a":1,"k":[{"i":{"x":0.833,"y":0.6},"o":{"x":0.167,"y":0.167},"t":5,"s":[274.616,306.243,0],"to":[-0.115,-0.59,0],"ti":[0.824,2.77,0]},{"i":{"x":0.833,"y":0.765},"o":{"x":0.167,"y":0.105},"t":6,"s":[273.925,302.703,0],"to":[-0.824,-2.77,0],"ti":[2.181,6.093,0]},{"i":{"x":0.833,"y":0.824},"o":{"x":0.167,"y":0.129},"t":7,"s":[269.674,289.624,0],"to":[-2.181,-6.093,0],"ti":[3.267,8.196,0]},{"i":{"x":0.833,"y":0.844},"o":{"x":0.167,"y":0.158},"t":8,"s":[260.838,266.142,0],"to":[-3.267,-8.196,0],"ti":[3.485,7.964,0]},{"i":{"x":0.833,"y":0.847},"o":{"x":0.167,"y":0.179},"t":9,"s":[250.074,240.449,0],"to":[-3.485,-7.964,0],"ti":[3.198,6.707,0]},{"i":{"x":0.833,"y":0.847},"o":{"x":0.167,"y":0.183},"t":10,"s":[239.929,218.357,0],"to":[-3.198,-6.707,0],"ti":[2.853,5.529,0]},{"i":{"x":0.833,"y":0.845},"o":{"x":0.167,"y":0.182},"t":11,"s":[230.887,200.209,0],"to":[-2.853,-5.529,0],"ti":[2.563,4.612,0]},{"i":{"x":0.833,"y":0.844},"o":{"x":0.167,"y":0.181},"t":12,"s":[222.813,185.182,0],"to":[-2.563,-4.612,0],"ti":[2.332,3.906,0]},{"i":{"x":0.833,"y":0.843},"o":{"x":0.167,"y":0.179},"t":13,"s":[215.51,172.535,0],"to":[-2.332,-3.906,0],"ti":[2.146,3.349,0]},{"i":{"x":0.833,"y":0.843},"o":{"x":0.167,"y":0.178},"t":14,"s":[208.822,161.746,0],"to":[-2.146,-3.349,0],"ti":[1.992,2.898,0]},{"i":{"x":0.833,"y":0.842},"o":{"x":0.167,"y":0.177},"t":15,"s":[202.636,152.442,0],"to":[-1.992,-2.898,0],"ti":[1.863,2.525,0]},{"i":{"x":0.833,"y":0.842},"o":{"x":0.167,"y":0.176},"t":16,"s":[196.868,144.357,0],"to":[-1.863,-2.525,0],"ti":[1.751,2.21,0]},{"i":{"x":0.833,"y":0.841},"o":{"x":0.167,"y":0.176},"t":17,"s":[191.458,137.291,0],"to":[-1.751,-2.21,0],"ti":[1.653,1.941,0]},{"i":{"x":0.833,"y":0.841},"o":{"x":0.167,"y":0.175},"t":18,"s":[186.36,131.094,0],"to":[-1.653,-1.941,0],"ti":[1.563,1.706,0]},{"i":{"x":0.833,"y":0.841},"o":{"x":0.167,"y":0.175},"t":19,"s":[181.542,125.648,0],"to":[-1.563,-1.706,0],"ti":[1.481,1.501,0]},{"i":{"x":0.833,"y":0.84},"o":{"x":0.167,"y":0.175},"t":20,"s":[176.98,120.856,0],"to":[-1.481,-1.501,0],"ti":[1.403,1.32,0]},{"i":{"x":0.833,"y":0.84},"o":{"x":0.167,"y":0.174},"t":21,"s":[172.656,116.64,0],"to":[-1.403,-1.32,0],"ti":[1.328,1.158,0]},{"i":{"x":0.833,"y":0.84},"o":{"x":0.167,"y":0.174},"t":22,"s":[168.561,112.936,0],"to":[-1.328,-1.158,0],"ti":[1.255,1.014,0]},{"i":{"x":0.833,"y":0.84},"o":{"x":0.167,"y":0.174},"t":23,"s":[164.687,109.689,0],"to":[-1.255,-1.014,0],"ti":[1.183,0.886,0]},{"i":{"x":0.833,"y":0.84},"o":{"x":0.167,"y":0.174},"t":24,"s":[161.03,106.849,0],"to":[-1.183,-0.886,0],"ti":[1.111,0.771,0]},{"i":{"x":0.833,"y":0.841},"o":{"x":0.167,"y":0.174},"t":25,"s":[157.59,104.374,0],"to":[-1.111,-0.771,0],"ti":[1.038,0.668,0]},{"i":{"x":0.833,"y":0.841},"o":{"x":0.167,"y":0.175},"t":26,"s":[154.367,102.223,0],"to":[-1.038,-0.668,0],"ti":[0.964,0.577,0]},{"i":{"x":0.833,"y":0.842},"o":{"x":0.167,"y":0.175},"t":27,"s":[151.363,100.364,0],"to":[-0.964,-0.577,0],"ti":[0.888,0.495,0]},{"i":{"x":0.833,"y":0.842},"o":{"x":0.167,"y":0.176},"t":28,"s":[148.583,98.763,0],"to":[-0.888,-0.495,0],"ti":[0.81,0.422,0]},{"i":{"x":0.833,"y":0.841},"o":{"x":0.167,"y":0.176},"t":29,"s":[146.035,97.394,0],"to":[-0.81,-0.422,0],"ti":[0.741,0.358,0]},{"i":{"x":0.833,"y":0.84},"o":{"x":0.167,"y":0.175},"t":30,"s":[143.723,96.23,0],"to":[-0.741,-0.358,0],"ti":[0.687,0.303,0]},{"i":{"x":0.833,"y":0.84},"o":{"x":0.167,"y":0.174},"t":31,"s":[141.59,95.244,0],"to":[-0.687,-0.303,0],"ti":[0.639,0.255,0]},{"i":{"x":0.833,"y":0.84},"o":{"x":0.167,"y":0.174},"t":32,"s":[139.604,94.413,0],"to":[-0.639,-0.255,0],"ti":[0.592,0.214,0]},{"i":{"x":0.833,"y":0.841},"o":{"x":0.167,"y":0.174},"t":33,"s":[137.759,93.715,0],"to":[-0.592,-0.214,0],"ti":[0.547,0.179,0]},{"i":{"x":0.833,"y":0.841},"o":{"x":0.167,"y":0.175},"t":34,"s":[136.052,93.13,0],"to":[-0.547,-0.179,0],"ti":[0.502,0.149,0]},{"i":{"x":0.833,"y":0.841},"o":{"x":0.167,"y":0.175},"t":35,"s":[134.48,92.643,0],"to":[-0.502,-0.149,0],"ti":[0.46,0.124,0]},{"i":{"x":0.833,"y":0.841},"o":{"x":0.167,"y":0.175},"t":36,"s":[133.038,92.238,0],"to":[-0.46,-0.124,0],"ti":[0.418,0.103,0]},{"i":{"x":0.833,"y":0.842},"o":{"x":0.167,"y":0.176},"t":37,"s":[131.723,91.901,0],"to":[-0.418,-0.103,0],"ti":[0.378,0.085,0]},{"i":{"x":0.833,"y":0.843},"o":{"x":0.167,"y":0.176},"t":38,"s":[130.53,91.622,0],"to":[-0.378,-0.085,0],"ti":[0.339,0.071,0]},{"i":{"x":0.833,"y":0.843},"o":{"x":0.167,"y":0.177},"t":39,"s":[129.457,91.39,0],"to":[-0.339,-0.071,0],"ti":[0.301,0.059,0]},{"i":{"x":0.833,"y":0.844},"o":{"x":0.167,"y":0.178},"t":40,"s":[128.499,91.197,0],"to":[-0.301,-0.059,0],"ti":[0.264,0.049,0]},{"i":{"x":0.833,"y":0.845},"o":{"x":0.167,"y":0.179},"t":41,"s":[127.654,91.037,0],"to":[-0.264,-0.049,0],"ti":[0.228,0.041,0]},{"i":{"x":0.833,"y":0.847},"o":{"x":0.167,"y":0.181},"t":42,"s":[126.917,90.903,0],"to":[-0.228,-0.041,0],"ti":[0.193,0.034,0]},{"i":{"x":0.833,"y":0.849},"o":{"x":0.167,"y":0.183},"t":43,"s":[126.287,90.791,0],"to":[-0.193,-0.034,0],"ti":[0.158,0.028,0]},{"i":{"x":0.833,"y":0.842},"o":{"x":0.167,"y":0.187},"t":44,"s":[125.761,90.698,0],"to":[-0.158,-0.028,0],"ti":[0.134,0.025,0]},{"i":{"x":0.833,"y":0.832},"o":{"x":0.167,"y":0.177},"t":45,"s":[125.337,90.622,0],"to":[-0.134,-0.025,0],"ti":[0.126,0.027,0]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.165},"t":46,"s":[124.96,90.547,0],"to":[-0.126,-0.027,0],"ti":[0.127,0.03,0]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.166},"t":47,"s":[124.579,90.461,0],"to":[-0.127,-0.03,0],"ti":[0.127,0.034,0]},{"i":{"x":0.833,"y":0.834},"o":{"x":0.167,"y":0.166},"t":48,"s":[124.196,90.364,0],"to":[-0.127,-0.034,0],"ti":[0.126,0.037,0]},{"i":{"x":0.833,"y":0.834},"o":{"x":0.167,"y":0.167},"t":49,"s":[123.816,90.257,0],"to":[-0.126,-0.037,0],"ti":[0.124,0.04,0]},{"i":{"x":0.833,"y":0.835},"o":{"x":0.167,"y":0.168},"t":50,"s":[123.44,90.141,0],"to":[-0.124,-0.04,0],"ti":[0.121,0.042,0]},{"i":{"x":0.833,"y":0.836},"o":{"x":0.167,"y":0.169},"t":51,"s":[123.073,90.017,0],"to":[-0.121,-0.042,0],"ti":[0.116,0.044,0]},{"i":{"x":0.833,"y":0.837},"o":{"x":0.167,"y":0.17},"t":52,"s":[122.717,89.887,0],"to":[-0.116,-0.044,0],"ti":[0.11,0.046,0]},{"i":{"x":0.833,"y":0.838},"o":{"x":0.167,"y":0.171},"t":53,"s":[122.376,89.751,0],"to":[-0.11,-0.046,0],"ti":[0.103,0.046,0]},{"i":{"x":0.833,"y":0.84},"o":{"x":0.167,"y":0.172},"t":54,"s":[122.055,89.613,0],"to":[-0.103,-0.046,0],"ti":[0.095,0.046,0]},{"i":{"x":0.833,"y":0.841},"o":{"x":0.167,"y":0.173},"t":55,"s":[121.755,89.474,0],"to":[-0.095,-0.046,0],"ti":[0.086,0.045,0]},{"i":{"x":0.833,"y":0.843},"o":{"x":0.167,"y":0.175},"t":56,"s":[121.483,89.337,0],"to":[-0.086,-0.045,0],"ti":[0.075,0.043,0]},{"i":{"x":0.833,"y":0.847},"o":{"x":0.167,"y":0.178},"t":57,"s":[121.241,89.204,0],"to":[-0.075,-0.043,0],"ti":[0.062,0.041,0]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.182},"t":58,"s":[121.035,89.077,0],"to":[-0.062,-0.041,0],"ti":[0.028,0.019,0]},{"t":59,"s":[120.867,88.96,0]}],"ix":2,"l":2},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":1,"k":[{"i":{"x":[0.833,0.833,0.833],"y":[1.272,0.954,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.167,0.167,0]},"t":5,"s":[88.213,98.042,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.933,0.787,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.064,-0.101,0]},"t":6,"s":[98.671,49.922,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.952,0.883,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.333,0.137,0]},"t":7,"s":[54.098,71.655,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.856,1.001,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.111,0.287,0]},"t":8,"s":[63.017,105.433,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.962,0.719,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.076,0.001,0]},"t":9,"s":[59.185,119.254,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.999,1.027,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.071,0.118,0]},"t":10,"s":[102.36,105.209,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.903,0.921,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.001,0.02,0]},"t":11,"s":[79.066,71.811,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.893,-1.794,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.579,-1.652,0]},"t":12,"s":[101.985,115.908,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.972,0.937,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.076,0.086,0]},"t":13,"s":[105.84,113.791,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.033,0.698,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.042,-0.255,0]},"t":14,"s":[60.688,44.915,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.04,0.92,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.024,0.115,0]},"t":15,"s":[90.735,61.894,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.924,0.271,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.027,-2.022,0]},"t":16,"s":[48.636,106.533,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.029,0.986,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.867,0.094,0]},"t":17,"s":[110.804,104.766,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.309,0.797,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.022,-0.018,0]},"t":18,"s":[105.35,91.067,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.026,0.865,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.066,0.141,0]},"t":19,"s":[112.726,102.388,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.929,1.297,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.02,0.217,0]},"t":20,"s":[78.021,118.647,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.73,0.94,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.493,0.065,0]},"t":21,"s":[123.499,128.748,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.684,0.681,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.12,-0.217,0]},"t":22,"s":[116.924,82.593,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.991,1.037,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.113,0.113,0]},"t":23,"s":[102.179,95.418,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.886,0.872,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.01,0.026,0]},"t":24,"s":[61.093,131.691,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.995,1.018,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.309,0.239,0]},"t":25,"s":[97.755,79.275,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.561,0.959,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.006,0.015,0]},"t":26,"s":[111.269,51.174,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.977,0.909,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.103,-0.081,0]},"t":27,"s":[98.597,85.374,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.896,0.259,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.031,0.937,0]},"t":28,"s":[44.555,68.006,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.03,1.073,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.425,0.094,0]},"t":29,"s":[83.792,66.311,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.079,1.021,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.022,0.039,0]},"t":30,"s":[93.366,52.935,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.872,0.97,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.041,0.017,0]},"t":31,"s":[80.351,77.978,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.459,0.91,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.24,-0.047,0]},"t":32,"s":[105.736,46.617,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.918,1.164,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.071,1.147,0]},"t":33,"s":[119.231,66.597,100]},{"i":{"x":[0.833,0.833,0.833],"y":[-0.338,0.408,1]},"o":{"x":[0.167,0.167,0.167],"y":[-4.839,0.055,0]},"t":34,"s":[31.333,68.163,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.923,0.942,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.089,0.097,0]},"t":35,"s":[32.821,63.516,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.316,1.245,1]},"o":{"x":[0.167,0.167,0.167],"y":[-1.049,-0.192,0]},"t":36,"s":[55.229,35.157,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.082,0.955,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.095,0.062,0]},"t":37,"s":[53.579,43.75,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.008,0.863,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.041,-0.1,0]},"t":38,"s":[41.681,9.938,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.021,1.024,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.008,0.213,0]},"t":39,"s":[65.338,25.314,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.135,1.097,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.017,0.019,0]},"t":40,"s":[39.293,35.228,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.942,0.902,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.052,0.045,0]},"t":41,"s":[71.909,22.453,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.84,1.746,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.192,0.562,0]},"t":42,"s":[-13.722,50.113,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.78,0.885,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.174,0.075,0]},"t":43,"s":[12.165,54.928,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.932,1.049,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.134,0.304,0]},"t":44,"s":[36.047,7.012,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.981,0.875,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.369,0.031,0]},"t":45,"s":[75.273,-11.082,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.976,1.192,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.024,0.248,0]},"t":46,"s":[68.05,17.747,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.142,0.902,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.033,0.058,0]},"t":47,"s":[73.648,32.314,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.915,0.932,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.092,0.553,0]},"t":48,"s":[69.641,-15.771,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.919,-0.285,1]},"o":{"x":[0.167,0.167,0.167],"y":[4.378,-0.361,0]},"t":49,"s":[32.387,-24.312,100]},{"i":{"x":[0.833,0.833,0.833],"y":[216.656,0.822,1]},"o":{"x":[0.167,0.167,0.167],"y":[-3.24,0.089,0]},"t":50,"s":[31.664,-22.708,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.938,0.876,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.083,0.156,0]},"t":51,"s":[31.682,0.419,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.616,0.585,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.235,0.252,0]},"t":52,"s":[-15.245,26.807,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.903,0.986,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.106,0.104,0]},"t":53,"s":[-2.957,39.841,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.946,0.871,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.581,-0.016,0]},"t":54,"s":[41.396,91.664,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.754,0.858,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.15,0.236,0]},"t":55,"s":[48.828,48.327,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.071,0.928,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.075,0.203,0]},"t":56,"s":[46.178,24.721,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.871,0.408,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.038,-0.524,0]},"t":57,"s":[72.828,8.231,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.833,0.833,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.235,0.097,0]},"t":58,"s":[23.424,10.493,100]},{"t":59,"s":[-3.769,24.296,100]}],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[0.012,10.621],[-5.695,5.34],[-2.557,-7.377],[3.15,-2.096]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0.3137254901960784,0.8901960784313725,0.7607843137254902,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[-3.421,4.043],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Rectangle 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":5,"op":60,"st":5,"bm":0},{"ddd":0,"ind":22,"ty":4,"nm":"Shape Layer 30","sr":1,"ks":{"o":{"a":1,"k":[{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":8,"s":[0]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":13,"s":[100]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":35,"s":[100]},{"t":51,"s":[0]}],"ix":11},"r":{"a":0,"k":92,"ix":10,"x":"var $bm_rt;\n$bm_rt = $bm_mul(time, 300);"},"p":{"a":1,"k":[{"i":{"x":0.833,"y":0.613},"o":{"x":0.167,"y":0.167},"t":8,"s":[273.484,300.616,0],"to":[-0.073,-0.268,0],"ti":[0.394,1.23,0]},{"i":{"x":0.833,"y":0.766},"o":{"x":0.167,"y":0.106},"t":9,"s":[273.044,299.005,0],"to":[-0.394,-1.23,0],"ti":[0.97,2.68,0]},{"i":{"x":0.833,"y":0.824},"o":{"x":0.167,"y":0.129},"t":10,"s":[271.118,293.234,0],"to":[-0.97,-2.68,0],"ti":[1.519,3.557,0]},{"i":{"x":0.833,"y":0.844},"o":{"x":0.167,"y":0.159},"t":11,"s":[267.222,282.925,0],"to":[-1.519,-3.557,0],"ti":[1.983,3.196,0]},{"i":{"x":0.833,"y":0.852},"o":{"x":0.167,"y":0.179},"t":12,"s":[262.002,271.894,0],"to":[-1.983,-3.196,0],"ti":[2.392,1.007,0]},{"i":{"x":0.833,"y":0.84},"o":{"x":0.167,"y":0.191},"t":13,"s":[255.326,263.748,0],"to":[-2.392,-1.007,0],"ti":[1.906,-1.439,0]},{"i":{"x":0.833,"y":0.845},"o":{"x":0.167,"y":0.174},"t":14,"s":[247.647,265.854,0],"to":[-1.906,1.439,0],"ti":[1.029,-2.094,0]},{"i":{"x":0.833,"y":0.844},"o":{"x":0.167,"y":0.181},"t":15,"s":[243.888,272.381,0],"to":[-1.029,2.094,0],"ti":[0.709,-1.9,0]},{"i":{"x":0.833,"y":0.843},"o":{"x":0.167,"y":0.179},"t":16,"s":[241.473,278.416,0],"to":[-0.709,1.9,0],"ti":[0.56,-1.691,0]},{"i":{"x":0.833,"y":0.842},"o":{"x":0.167,"y":0.177},"t":17,"s":[239.636,283.781,0],"to":[-0.56,1.691,0],"ti":[0.476,-1.51,0]},{"i":{"x":0.833,"y":0.842},"o":{"x":0.167,"y":0.177},"t":18,"s":[238.111,288.564,0],"to":[-0.476,1.51,0],"ti":[0.423,-1.353,0]},{"i":{"x":0.833,"y":0.841},"o":{"x":0.167,"y":0.176},"t":19,"s":[236.781,292.841,0],"to":[-0.423,1.353,0],"ti":[0.389,-1.218,0]},{"i":{"x":0.833,"y":0.841},"o":{"x":0.167,"y":0.175},"t":20,"s":[235.575,296.684,0],"to":[-0.389,1.218,0],"ti":[0.367,-1.099,0]},{"i":{"x":0.833,"y":0.841},"o":{"x":0.167,"y":0.175},"t":21,"s":[234.45,300.149,0],"to":[-0.367,1.099,0],"ti":[0.353,-0.992,0]},{"i":{"x":0.833,"y":0.841},"o":{"x":0.167,"y":0.175},"t":22,"s":[233.375,303.275,0],"to":[-0.353,0.992,0],"ti":[0.346,-0.895,0]},{"i":{"x":0.833,"y":0.84},"o":{"x":0.167,"y":0.175},"t":23,"s":[232.33,306.098,0],"to":[-0.346,0.895,0],"ti":[0.345,-0.807,0]},{"i":{"x":0.833,"y":0.84},"o":{"x":0.167,"y":0.174},"t":24,"s":[231.297,308.646,0],"to":[-0.345,0.807,0],"ti":[0.347,-0.725,0]},{"i":{"x":0.833,"y":0.84},"o":{"x":0.167,"y":0.174},"t":25,"s":[230.261,310.941,0],"to":[-0.347,0.725,0],"ti":[0.353,-0.648,0]},{"i":{"x":0.833,"y":0.84},"o":{"x":0.167,"y":0.174},"t":26,"s":[229.214,312.998,0],"to":[-0.353,0.648,0],"ti":[0.361,-0.575,0]},{"i":{"x":0.833,"y":0.84},"o":{"x":0.167,"y":0.174},"t":27,"s":[228.146,314.83,0],"to":[-0.361,0.575,0],"ti":[0.371,-0.504,0]},{"i":{"x":0.833,"y":0.84},"o":{"x":0.167,"y":0.174},"t":28,"s":[227.049,316.449,0],"to":[-0.371,0.504,0],"ti":[0.381,-0.434,0]},{"i":{"x":0.833,"y":0.839},"o":{"x":0.167,"y":0.173},"t":29,"s":[225.922,317.856,0],"to":[-0.381,0.434,0],"ti":[0.391,-0.366,0]},{"i":{"x":0.833,"y":0.839},"o":{"x":0.167,"y":0.173},"t":30,"s":[224.764,319.055,0],"to":[-0.391,0.366,0],"ti":[0.401,-0.298,0]},{"i":{"x":0.833,"y":0.838},"o":{"x":0.167,"y":0.172},"t":31,"s":[223.576,320.049,0],"to":[-0.401,0.298,0],"ti":[0.41,-0.232,0]},{"i":{"x":0.833,"y":0.837},"o":{"x":0.167,"y":0.171},"t":32,"s":[222.359,320.843,0],"to":[-0.41,0.232,0],"ti":[0.415,-0.168,0]},{"i":{"x":0.833,"y":0.837},"o":{"x":0.167,"y":0.171},"t":33,"s":[221.118,321.443,0],"to":[-0.415,0.168,0],"ti":[0.412,-0.106,0]},{"i":{"x":0.833,"y":0.837},"o":{"x":0.167,"y":0.171},"t":34,"s":[219.872,321.85,0],"to":[-0.412,0.106,0],"ti":[0.401,-0.05,0]},{"i":{"x":0.833,"y":0.837},"o":{"x":0.167,"y":0.171},"t":35,"s":[218.647,322.076,0],"to":[-0.401,0.05,0],"ti":[0.385,-0.003,0]},{"i":{"x":0.833,"y":0.838},"o":{"x":0.167,"y":0.171},"t":36,"s":[217.463,322.147,0],"to":[-0.385,0.003,0],"ti":[0.364,0.034,0]},{"i":{"x":0.833,"y":0.838},"o":{"x":0.167,"y":0.171},"t":37,"s":[216.337,322.093,0],"to":[-0.364,-0.034,0],"ti":[0.341,0.06,0]},{"i":{"x":0.833,"y":0.839},"o":{"x":0.167,"y":0.172},"t":38,"s":[215.276,321.946,0],"to":[-0.341,-0.06,0],"ti":[0.316,0.077,0]},{"i":{"x":0.833,"y":0.839},"o":{"x":0.167,"y":0.173},"t":39,"s":[214.288,321.735,0],"to":[-0.316,-0.077,0],"ti":[0.29,0.086,0]},{"i":{"x":0.833,"y":0.84},"o":{"x":0.167,"y":0.173},"t":40,"s":[213.378,321.486,0],"to":[-0.29,-0.086,0],"ti":[0.265,0.09,0]},{"i":{"x":0.833,"y":0.841},"o":{"x":0.167,"y":0.174},"t":41,"s":[212.546,321.219,0],"to":[-0.265,-0.09,0],"ti":[0.239,0.088,0]},{"i":{"x":0.833,"y":0.843},"o":{"x":0.167,"y":0.176},"t":42,"s":[211.79,320.949,0],"to":[-0.239,-0.088,0],"ti":[0.212,0.083,0]},{"i":{"x":0.833,"y":0.843},"o":{"x":0.167,"y":0.178},"t":43,"s":[211.114,320.689,0],"to":[-0.212,-0.083,0],"ti":[0.186,0.075,0]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.178},"t":44,"s":[210.521,320.45,0],"to":[-0.186,-0.075,0],"ti":[0.176,0.067,0]},{"i":{"x":0.833,"y":0.834},"o":{"x":0.167,"y":0.167},"t":45,"s":[209.997,320.238,0],"to":[-0.176,-0.067,0],"ti":[0.178,0.059,0]},{"i":{"x":0.833,"y":0.835},"o":{"x":0.167,"y":0.167},"t":46,"s":[209.465,320.047,0],"to":[-0.178,-0.059,0],"ti":[0.179,0.05,0]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.168},"t":47,"s":[208.927,319.881,0],"to":[-0.179,-0.05,0],"ti":[0.179,0.042,0]},{"i":{"x":0.833,"y":0.831},"o":{"x":0.167,"y":0.166},"t":48,"s":[208.392,319.745,0],"to":[-0.179,-0.042,0],"ti":[0.184,0.036,0]},{"i":{"x":0.833,"y":0.832},"o":{"x":0.167,"y":0.164},"t":49,"s":[207.85,319.63,0],"to":[-0.184,-0.036,0],"ti":[0.189,0.031,0]},{"i":{"x":0.833,"y":0.834},"o":{"x":0.167,"y":0.166},"t":50,"s":[207.287,319.528,0],"to":[-0.189,-0.031,0],"ti":[0.191,0.026,0]},{"i":{"x":0.833,"y":0.835},"o":{"x":0.167,"y":0.167},"t":51,"s":[206.714,319.442,0],"to":[-0.191,-0.026,0],"ti":[0.19,0.019,0]},{"i":{"x":0.833,"y":0.836},"o":{"x":0.167,"y":0.168},"t":52,"s":[206.14,319.374,0],"to":[-0.19,-0.019,0],"ti":[0.186,0.013,0]},{"i":{"x":0.833,"y":0.837},"o":{"x":0.167,"y":0.169},"t":53,"s":[205.574,319.326,0],"to":[-0.186,-0.013,0],"ti":[0.18,0.006,0]},{"i":{"x":0.833,"y":0.838},"o":{"x":0.167,"y":0.17},"t":54,"s":[205.024,319.298,0],"to":[-0.18,-0.006,0],"ti":[0.171,-0.002,0]},{"i":{"x":0.833,"y":0.839},"o":{"x":0.167,"y":0.171},"t":55,"s":[204.495,319.292,0],"to":[-0.171,0.002,0],"ti":[0.161,-0.009,0]},{"i":{"x":0.833,"y":0.84},"o":{"x":0.167,"y":0.172},"t":56,"s":[203.995,319.308,0],"to":[-0.161,0.009,0],"ti":[0.149,-0.017,0]},{"i":{"x":0.833,"y":0.84},"o":{"x":0.167,"y":0.173},"t":57,"s":[203.528,319.347,0],"to":[-0.149,0.017,0],"ti":[0.136,-0.024,0]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.174},"t":58,"s":[203.099,319.409,0],"to":[-0.136,0.024,0],"ti":[0.065,-0.014,0]},{"t":59,"s":[202.712,319.493,0]}],"ix":2,"l":2},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":1,"k":[{"i":{"x":[0.833,0.833,0.833],"y":[0.949,1.131,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.167,0.167,0]},"t":8,"s":[62.161,96.543,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.907,0.999,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.13,0.051,0]},"t":9,"s":[100.156,112.306,100]},{"i":{"x":[0.833,0.833,0.833],"y":[-0.255,0.94,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.78,-0.001,0]},"t":10,"s":[85.337,71.714,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.914,0.737,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.089,-0.212,0]},"t":11,"s":[83.564,111.805,100]},{"i":{"x":[0.833,0.833,0.833],"y":[3.317,0.894,1]},"o":{"x":[0.167,0.167,0.167],"y":[2.881,0.122,0]},"t":12,"s":[58.626,100.499,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.968,0.759,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.08,0.397,0]},"t":13,"s":[57.883,76.161,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.128,1.176,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.051,0.127,0]},"t":14,"s":[79.277,69.687,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.956,0.877,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.051,0.057,0]},"t":15,"s":[66.041,57.442,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.189,0.928,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.092,0.26,0]},"t":16,"s":[99.685,95.603,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.016,1.082,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.058,-0.51,0]},"t":17,"s":[83.661,113.563,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.924,1.121,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.013,0.041,0]},"t":18,"s":[136.015,111.04,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.746,0.893,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.873,0.049,0]},"t":19,"s":[73.781,116.06,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.967,0.529,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.124,0.375,0]},"t":20,"s":[79.202,103.731,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.027,1.092,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.055,0.101,0]},"t":21,"s":[90.283,100.207,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.852,0.985,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.02,0.044,0]},"t":22,"s":[83.612,83.82,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.603,0.729,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.191,-0.018,0]},"t":23,"s":[92.451,118.313,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.909,0.999,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.106,0.12,0]},"t":24,"s":[99.265,89.881,100]},{"i":{"x":[0.833,0.833,0.833],"y":[3.481,0.93,1]},"o":{"x":[0.167,0.167,0.167],"y":[1.02,-0.001,0]},"t":25,"s":[124.872,26,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.92,0.628,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.081,-0.442,0]},"t":26,"s":[127.15,89.446,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.841,0.938,1]},"o":{"x":[0.167,0.167,0.167],"y":[-2.332,0.107,0]},"t":27,"s":[57.069,79.381,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.572,0.791,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.175,-0.245,0]},"t":28,"s":[59.486,44.49,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.638,0.895,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.103,0.139,0]},"t":29,"s":[61.689,53.354,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.822,1.105,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.108,0.401,0]},"t":30,"s":[70.8,66.698,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.094,0.926,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.157,0.047,0]},"t":31,"s":[101.268,70.196,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.89,-1.504,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.044,-0.649,0]},"t":32,"s":[135.914,62.272,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.08,0.944,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.345,0.086,0]},"t":33,"s":[62.097,63.174,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.94,0.915,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.041,-0.167,0]},"t":34,"s":[38.578,89.373,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.898,-33.804,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.213,5.1,0]},"t":35,"s":[84.734,80.668,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.645,0.976,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.457,0.084,0]},"t":36,"s":[71.735,80.523,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.967,0.902,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.074,-0.034,0]},"t":37,"s":[68.832,20.271,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.963,1.005,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.053,0.563,0]},"t":38,"s":[94.213,63.191,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.499,0.724,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.065,0.004,0]},"t":39,"s":[78.745,70.653,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.912,1.012,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.071,0.119,0]},"t":40,"s":[87.422,62.786,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.903,0.984,1]},"o":{"x":[0.167,0.167,0.167],"y":[1.658,0.011,0]},"t":41,"s":[26.733,44.597,100]},{"i":{"x":[0.833,0.833,0.833],"y":[8.954,0.747,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.609,-0.019,0]},"t":42,"s":[23.521,65.429,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.931,0.997,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.082,0.124,0]},"t":43,"s":[23.011,48.501,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.153,0.822,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.407,-0.003,0]},"t":44,"s":[72.162,14.125,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.994,1.075,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.054,0.156,0]},"t":45,"s":[63.801,47.128,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.742,0.92,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.006,0.039,0]},"t":46,"s":[87.498,84.73,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.929,1.295,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.123,-1.924,0]},"t":47,"s":[65.378,13.453,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.536,1.079,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.497,0.065,0]},"t":48,"s":[19.027,16.413,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.076,0.957,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.102,0.041,0]},"t":49,"s":[25.686,2.971,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.924,0.87,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.04,-0.09,0]},"t":50,"s":[56.116,29.236,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.444,1.634,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.906,0.231,0]},"t":51,"s":[-1.889,16.583,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.986,0.961,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.098,0.074,0]},"t":52,"s":[2.996,9.45,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.952,0.802,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.017,-0.074,0]},"t":53,"s":[30.717,70.822,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.484,0.967,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.111,0.144,0]},"t":54,"s":[7.584,38.216,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.947,0.944,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.099,-0.054,0]},"t":55,"s":[17.496,-6.813,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.773,1.079,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.143,-0.17,0]},"t":56,"s":[68.936,20.442,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.98,0.725,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.132,0.041,0]},"t":57,"s":[50.024,11.466,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.833,0.833,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.027,0.12,0]},"t":58,"s":[17.481,29.004,100]},{"t":59,"s":[42.14,69.391,100]}],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[0.012,10.621],[-5.695,5.34],[-2.557,-7.377],[3.15,-2.096]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[1,0.8941176470588236,0.11372549019607843,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[-3.421,4.043],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Rectangle 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":8,"op":60,"st":8,"bm":0},{"ddd":0,"ind":23,"ty":4,"nm":"Shape Layer 29","sr":1,"ks":{"o":{"a":1,"k":[{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":12,"s":[0]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":17,"s":[100]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":39,"s":[100]},{"t":55,"s":[0]}],"ix":11},"r":{"a":0,"k":92,"ix":10,"x":"var $bm_rt;\n$bm_rt = $bm_mul(time, 300);"},"p":{"a":1,"k":[{"i":{"x":0.833,"y":0.577},"o":{"x":0.167,"y":0.167},"t":12,"s":[270.833,305.959,0],"to":[-0.088,-0.333,0],"ti":[0.479,1.681,0]},{"i":{"x":0.833,"y":0.762},"o":{"x":0.167,"y":0.104},"t":13,"s":[270.305,303.959,0],"to":[-0.479,-1.681,0],"ti":[0.843,3.89,0]},{"i":{"x":0.833,"y":0.842},"o":{"x":0.167,"y":0.128},"t":14,"s":[267.957,295.871,0],"to":[-0.843,-3.89,0],"ti":[-1.367,3.831,0]},{"i":{"x":0.833,"y":0.826},"o":{"x":0.167,"y":0.177},"t":15,"s":[265.25,280.619,0],"to":[1.367,-3.831,0],"ti":[-4.031,0.131,0]},{"i":{"x":0.833,"y":0.848},"o":{"x":0.167,"y":0.16},"t":16,"s":[276.159,272.888,0],"to":[4.031,-0.131,0],"ti":[-3.947,-2.293,0]},{"i":{"x":0.833,"y":0.847},"o":{"x":0.167,"y":0.185},"t":17,"s":[289.438,279.833,0],"to":[3.947,2.293,0],"ti":[-3.163,-2.107,0]},{"i":{"x":0.833,"y":0.846},"o":{"x":0.167,"y":0.183},"t":18,"s":[299.839,286.644,0],"to":[3.163,2.107,0],"ti":[-2.643,-1.789,0]},{"i":{"x":0.833,"y":0.845},"o":{"x":0.167,"y":0.182},"t":19,"s":[308.416,292.475,0],"to":[2.643,1.789,0],"ti":[-2.265,-1.502,0]},{"i":{"x":0.833,"y":0.844},"o":{"x":0.167,"y":0.181},"t":20,"s":[315.699,297.38,0],"to":[2.265,1.502,0],"ti":[-1.975,-1.258,0]},{"i":{"x":0.833,"y":0.844},"o":{"x":0.167,"y":0.179},"t":21,"s":[322.005,301.489,0],"to":[1.975,1.258,0],"ti":[-1.746,-1.051,0]},{"i":{"x":0.833,"y":0.843},"o":{"x":0.167,"y":0.179},"t":22,"s":[327.55,304.928,0],"to":[1.746,1.051,0],"ti":[-1.558,-0.872,0]},{"i":{"x":0.833,"y":0.843},"o":{"x":0.167,"y":0.178},"t":23,"s":[332.48,307.794,0],"to":[1.558,0.872,0],"ti":[-1.4,-0.715,0]},{"i":{"x":0.833,"y":0.843},"o":{"x":0.167,"y":0.178},"t":24,"s":[336.899,310.159,0],"to":[1.4,0.715,0],"ti":[-1.265,-0.574,0]},{"i":{"x":0.833,"y":0.843},"o":{"x":0.167,"y":0.177},"t":25,"s":[340.882,312.081,0],"to":[1.265,0.574,0],"ti":[-1.148,-0.448,0]},{"i":{"x":0.833,"y":0.842},"o":{"x":0.167,"y":0.177},"t":26,"s":[344.489,313.605,0],"to":[1.148,0.448,0],"ti":[-1.045,-0.333,0]},{"i":{"x":0.833,"y":0.842},"o":{"x":0.167,"y":0.177},"t":27,"s":[347.771,314.769,0],"to":[1.045,0.333,0],"ti":[-0.954,-0.228,0]},{"i":{"x":0.833,"y":0.842},"o":{"x":0.167,"y":0.176},"t":28,"s":[350.762,315.603,0],"to":[0.954,0.228,0],"ti":[-0.867,-0.129,0]},{"i":{"x":0.833,"y":0.843},"o":{"x":0.167,"y":0.177},"t":29,"s":[353.494,316.134,0],"to":[0.867,0.129,0],"ti":[-0.779,-0.034,0]},{"i":{"x":0.833,"y":0.842},"o":{"x":0.167,"y":0.177},"t":30,"s":[355.966,316.376,0],"to":[0.779,0.034,0],"ti":[-0.692,0.054,0]},{"i":{"x":0.833,"y":0.842},"o":{"x":0.167,"y":0.177},"t":31,"s":[358.169,316.339,0],"to":[0.692,-0.054,0],"ti":[-0.607,0.132,0]},{"i":{"x":0.833,"y":0.841},"o":{"x":0.167,"y":0.176},"t":32,"s":[360.118,316.051,0],"to":[0.607,-0.132,0],"ti":[-0.525,0.197,0]},{"i":{"x":0.833,"y":0.84},"o":{"x":0.167,"y":0.175},"t":33,"s":[361.814,315.546,0],"to":[0.525,-0.197,0],"ti":[-0.446,0.247,0]},{"i":{"x":0.833,"y":0.84},"o":{"x":0.167,"y":0.174},"t":34,"s":[363.265,314.868,0],"to":[0.446,-0.247,0],"ti":[-0.374,0.281,0]},{"i":{"x":0.833,"y":0.84},"o":{"x":0.167,"y":0.174},"t":35,"s":[364.49,314.065,0],"to":[0.374,-0.281,0],"ti":[-0.309,0.299,0]},{"i":{"x":0.833,"y":0.84},"o":{"x":0.167,"y":0.174},"t":36,"s":[365.508,313.183,0],"to":[0.309,-0.299,0],"ti":[-0.254,0.304,0]},{"i":{"x":0.833,"y":0.84},"o":{"x":0.167,"y":0.174},"t":37,"s":[366.345,312.269,0],"to":[0.254,-0.304,0],"ti":[-0.208,0.299,0]},{"i":{"x":0.833,"y":0.841},"o":{"x":0.167,"y":0.174},"t":38,"s":[367.029,311.356,0],"to":[0.208,-0.299,0],"ti":[-0.171,0.285,0]},{"i":{"x":0.833,"y":0.841},"o":{"x":0.167,"y":0.175},"t":39,"s":[367.591,310.475,0],"to":[0.171,-0.285,0],"ti":[-0.143,0.266,0]},{"i":{"x":0.833,"y":0.842},"o":{"x":0.167,"y":0.175},"t":40,"s":[368.057,309.644,0],"to":[0.143,-0.266,0],"ti":[-0.123,0.244,0]},{"i":{"x":0.833,"y":0.843},"o":{"x":0.167,"y":0.176},"t":41,"s":[368.451,308.877,0],"to":[0.123,-0.244,0],"ti":[-0.108,0.219,0]},{"i":{"x":0.833,"y":0.843},"o":{"x":0.167,"y":0.177},"t":42,"s":[368.793,308.182,0],"to":[0.108,-0.219,0],"ti":[-0.097,0.192,0]},{"i":{"x":0.833,"y":0.844},"o":{"x":0.167,"y":0.178},"t":43,"s":[369.097,307.564,0],"to":[0.097,-0.192,0],"ti":[-0.091,0.166,0]},{"i":{"x":0.833,"y":0.845},"o":{"x":0.167,"y":0.179},"t":44,"s":[369.377,307.027,0],"to":[0.091,-0.166,0],"ti":[-0.087,0.139,0]},{"i":{"x":0.833,"y":0.846},"o":{"x":0.167,"y":0.18},"t":45,"s":[369.642,306.571,0],"to":[0.087,-0.139,0],"ti":[-0.085,0.112,0]},{"i":{"x":0.833,"y":0.845},"o":{"x":0.167,"y":0.181},"t":46,"s":[369.899,306.194,0],"to":[0.085,-0.112,0],"ti":[-0.083,0.087,0]},{"i":{"x":0.833,"y":0.844},"o":{"x":0.167,"y":0.181},"t":47,"s":[370.15,305.896,0],"to":[0.083,-0.087,0],"ti":[-0.082,0.062,0]},{"i":{"x":0.833,"y":0.843},"o":{"x":0.167,"y":0.179},"t":48,"s":[370.398,305.674,0],"to":[0.082,-0.062,0],"ti":[-0.081,0.039,0]},{"i":{"x":0.833,"y":0.841},"o":{"x":0.167,"y":0.178},"t":49,"s":[370.644,305.522,0],"to":[0.081,-0.039,0],"ti":[-0.079,0.017,0]},{"i":{"x":0.833,"y":0.838},"o":{"x":0.167,"y":0.175},"t":50,"s":[370.885,305.438,0],"to":[0.079,-0.017,0],"ti":[-0.075,-0.004,0]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.171},"t":51,"s":[371.117,305.419,0],"to":[0.075,0.004,0],"ti":[-0.071,-0.019,0]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.166},"t":52,"s":[371.334,305.462,0],"to":[0.071,0.019,0],"ti":[-0.071,-0.023,0]},{"i":{"x":0.833,"y":0.837},"o":{"x":0.167,"y":0.167},"t":53,"s":[371.546,305.533,0],"to":[0.071,0.023,0],"ti":[-0.069,-0.021,0]},{"i":{"x":0.833,"y":0.842},"o":{"x":0.167,"y":0.171},"t":54,"s":[371.758,305.598,0],"to":[0.069,0.021,0],"ti":[-0.064,-0.018,0]},{"i":{"x":0.833,"y":0.849},"o":{"x":0.167,"y":0.176},"t":55,"s":[371.962,305.656,0],"to":[0.064,0.018,0],"ti":[-0.056,-0.014,0]},{"i":{"x":0.833,"y":0.859},"o":{"x":0.167,"y":0.185},"t":56,"s":[372.145,305.704,0],"to":[0.056,0.014,0],"ti":[-0.042,-0.01,0]},{"i":{"x":0.833,"y":0.882},"o":{"x":0.167,"y":0.204},"t":57,"s":[372.295,305.741,0],"to":[0.042,0.01,0],"ti":[-0.025,-0.005,0]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.285},"t":58,"s":[372.399,305.764,0],"to":[0.025,0.005,0],"ti":[-0.007,-0.001,0]},{"t":59,"s":[372.443,305.77,0]}],"ix":2,"l":2},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":1,"k":[{"i":{"x":[0.833,0.833,0.833],"y":[0.332,1.186,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.167,0.167,0]},"t":12,"s":[64.57,85.432,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.875,0.947,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.095,0.058,0]},"t":13,"s":[69.942,108.953,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.206,0.842,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.249,-0.144,0]},"t":14,"s":[107.607,32.831,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.969,0.836,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.059,0.176,0]},"t":15,"s":[126.531,60.735,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1,0.867,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.048,0.17,0]},"t":16,"s":[60.769,85.755,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.908,1.153,1]},"o":{"x":[0.167,0.167,0.167],"y":[0,0.222,0]},"t":17,"s":[102.39,109.836,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.333,0.945,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.877,0.054,0]},"t":18,"s":[60.887,124.258,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.949,1.054,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.067,-0.162,0]},"t":19,"s":[56.529,83.394,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.322,0.969,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.133,0.033,0]},"t":20,"s":[78.284,97.261,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.976,0.786,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.066,-0.05,0]},"t":21,"s":[69.892,74.45,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.923,1.181,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.033,0.137,0]},"t":22,"s":[110.724,88.677,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.066,0.942,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.984,0.057,0]},"t":23,"s":[81.454,110.927,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.003,0.937,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.091,-0.193,0]},"t":24,"s":[83.739,40.242,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.86,0.536,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.002,-0.266,0]},"t":25,"s":[107.061,61.553,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.865,1.004,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.206,0.102,0]},"t":26,"s":[83.024,56.466,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.851,0.868,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.217,0.004,0]},"t":27,"s":[66.63,33.237,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.158,0.859,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.189,0.226,0]},"t":28,"s":[56.437,57.54,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.865,1.267,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.055,0.203,0]},"t":29,"s":[48.398,71.747,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.754,0.941,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.218,0.064,0]},"t":30,"s":[71.71,81.652,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.096,0.683,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.126,-0.203,0]},"t":31,"s":[86.087,39.983,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.99,0.804,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.045,0.113,0]},"t":32,"s":[114.075,52.122,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.96,0.963,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.012,0.145,0]},"t":33,"s":[53.751,86.117,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.928,0.79,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.077,-0.066,0]},"t":34,"s":[106.75,131.89,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.204,0.982,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.532,0.138,0]},"t":35,"s":[79.216,106.312,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.915,0.889,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.093,-0.024,0]},"t":36,"s":[82.944,67.378,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.454,0.941,1]},"o":{"x":[0.167,0.167,0.167],"y":[3.696,0.337,0]},"t":37,"s":[114.826,97.73,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.872,0.088,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.098,-0.207,0]},"t":38,"s":[115.562,107.689,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.944,0.906,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.076,0.092,0]},"t":39,"s":[119.647,104.826,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.157,0.579,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.17,0.758,0]},"t":40,"s":[72.833,76.34,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.962,0.969,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.054,0.104,0]},"t":41,"s":[88.225,72.82,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.853,0.888,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.07,-0.05,0]},"t":42,"s":[43.922,58.537,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.885,-0.216,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.193,0.325,0]},"t":43,"s":[67.927,67.475,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.471,1.047,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.3,0.089,0]},"t":44,"s":[86.125,70.552,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.904,0.934,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.071,0.03,0]},"t":45,"s":[93.113,112.383,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.526,1.014,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.633,-0.31,0]},"t":46,"s":[46.616,46.845,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.045,1.106,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.101,0.012,0]},"t":47,"s":[39.563,60.724,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.969,1.027,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.029,0.047,0]},"t":48,"s":[6.525,44.501,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.929,0.915,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.05,0.021,0]},"t":49,"s":[57.474,81.367,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.854,1.348,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.48,3.659,0]},"t":50,"s":[25.518,32.394,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.966,0.815,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.195,0.067,0]},"t":51,"s":[30.245,31.253,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.921,1.317,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.077,0.152,0]},"t":52,"s":[33.783,37.163,100]},{"i":{"x":[0.833,0.833,0.833],"y":[-0.996,0.978,1]},"o":{"x":[0.167,0.167,0.167],"y":[-1.48,0.066,0]},"t":53,"s":[-10.763,44.348,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.91,0.989,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.087,-0.029,0]},"t":54,"s":[-8.389,9.855,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.194,0.978,1]},"o":{"x":[0.167,0.167,0.167],"y":[1.177,-0.012,0]},"t":55,"s":[46.091,35.382,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.01,0.743,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.058,-0.03,0]},"t":56,"s":[50.24,13.115,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.876,0.981,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.009,0.123,0]},"t":57,"s":[36.428,29.519,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.833,0.833,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.252,-0.025,0]},"t":58,"s":[51.9,63.715,100]},{"t":59,"s":[59.535,37.52,100]}],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[0.012,10.621],[-5.695,5.34],[-2.557,-7.377],[3.15,-2.096]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0.3137254901960784,0.8901960784313725,0.7607843137254902,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[-3.421,4.043],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Rectangle 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":12,"op":60,"st":12,"bm":0},{"ddd":0,"ind":24,"ty":4,"nm":"Shape Layer 28","sr":1,"ks":{"o":{"a":1,"k":[{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":3,"s":[0]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":8,"s":[100]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":30,"s":[100]},{"t":46,"s":[0]}],"ix":11},"r":{"a":0,"k":92,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.833,"y":0.613},"o":{"x":0.167,"y":0.167},"t":3,"s":[270.565,305.387,0],"to":[-0.175,-0.467,0],"ti":[0.928,2.118,0]},{"i":{"x":0.833,"y":0.766},"o":{"x":0.167,"y":0.106},"t":4,"s":[269.518,302.585,0],"to":[-0.928,-2.118,0],"ti":[2.866,4.132,0]},{"i":{"x":0.833,"y":0.825},"o":{"x":0.167,"y":0.13},"t":5,"s":[264.999,292.679,0],"to":[-2.866,-4.132,0],"ti":[5.573,3.332,0]},{"i":{"x":0.833,"y":0.843},"o":{"x":0.167,"y":0.159},"t":6,"s":[252.323,277.795,0],"to":[-5.573,-3.332,0],"ti":[6.454,-0.145,0]},{"i":{"x":0.833,"y":0.847},"o":{"x":0.167,"y":0.178},"t":7,"s":[231.564,272.688,0],"to":[-6.454,0.145,0],"ti":[5.175,-2.485,0]},{"i":{"x":0.833,"y":0.847},"o":{"x":0.167,"y":0.183},"t":8,"s":[213.599,278.665,0],"to":[-5.175,2.485,0],"ti":[3.799,-3.017,0]},{"i":{"x":0.833,"y":0.845},"o":{"x":0.167,"y":0.182},"t":9,"s":[200.516,287.597,0],"to":[-3.799,3.017,0],"ti":[2.848,-2.986,0]},{"i":{"x":0.833,"y":0.844},"o":{"x":0.167,"y":0.181},"t":10,"s":[190.807,296.767,0],"to":[-2.848,2.986,0],"ti":[2.177,-2.823,0]},{"i":{"x":0.833,"y":0.843},"o":{"x":0.167,"y":0.179},"t":11,"s":[183.426,305.511,0],"to":[-2.177,2.823,0],"ti":[1.672,-2.64,0]},{"i":{"x":0.833,"y":0.842},"o":{"x":0.167,"y":0.178},"t":12,"s":[177.747,313.704,0],"to":[-1.672,2.64,0],"ti":[1.269,-2.465,0]},{"i":{"x":0.833,"y":0.842},"o":{"x":0.167,"y":0.177},"t":13,"s":[173.394,321.352,0],"to":[-1.269,2.465,0],"ti":[0.931,-2.298,0]},{"i":{"x":0.833,"y":0.842},"o":{"x":0.167,"y":0.176},"t":14,"s":[170.134,328.494,0],"to":[-0.931,2.298,0],"ti":[0.638,-2.135,0]},{"i":{"x":0.833,"y":0.842},"o":{"x":0.167,"y":0.176},"t":15,"s":[167.806,335.14,0],"to":[-0.638,2.135,0],"ti":[0.374,-1.972,0]},{"i":{"x":0.833,"y":0.842},"o":{"x":0.167,"y":0.176},"t":16,"s":[166.306,341.307,0],"to":[-0.374,1.972,0],"ti":[0.132,-1.802,0]},{"i":{"x":0.833,"y":0.841},"o":{"x":0.167,"y":0.176},"t":17,"s":[165.565,346.975,0],"to":[-0.132,1.802,0],"ti":[-0.087,-1.628,0]},{"i":{"x":0.833,"y":0.841},"o":{"x":0.167,"y":0.175},"t":18,"s":[165.516,352.122,0],"to":[0.087,1.628,0],"ti":[-0.279,-1.446,0]},{"i":{"x":0.833,"y":0.842},"o":{"x":0.167,"y":0.176},"t":19,"s":[166.088,356.74,0],"to":[0.279,1.446,0],"ti":[-0.435,-1.255,0]},{"i":{"x":0.833,"y":0.841},"o":{"x":0.167,"y":0.176},"t":20,"s":[167.189,360.798,0],"to":[0.435,1.255,0],"ti":[-0.549,-1.065,0]},{"i":{"x":0.833,"y":0.841},"o":{"x":0.167,"y":0.176},"t":21,"s":[168.697,364.273,0],"to":[0.549,1.065,0],"ti":[-0.622,-0.888,0]},{"i":{"x":0.833,"y":0.841},"o":{"x":0.167,"y":0.175},"t":22,"s":[170.482,367.189,0],"to":[0.622,0.888,0],"ti":[-0.661,-0.731,0]},{"i":{"x":0.833,"y":0.841},"o":{"x":0.167,"y":0.175},"t":23,"s":[172.43,369.6,0],"to":[0.661,0.731,0],"ti":[-0.672,-0.596,0]},{"i":{"x":0.833,"y":0.84},"o":{"x":0.167,"y":0.175},"t":24,"s":[174.447,371.574,0],"to":[0.672,0.596,0],"ti":[-0.665,-0.483,0]},{"i":{"x":0.833,"y":0.84},"o":{"x":0.167,"y":0.174},"t":25,"s":[176.464,373.178,0],"to":[0.665,0.483,0],"ti":[-0.646,-0.39,0]},{"i":{"x":0.833,"y":0.84},"o":{"x":0.167,"y":0.174},"t":26,"s":[178.436,374.475,0],"to":[0.646,0.39,0],"ti":[-0.62,-0.314,0]},{"i":{"x":0.833,"y":0.84},"o":{"x":0.167,"y":0.174},"t":27,"s":[180.337,375.52,0],"to":[0.62,0.314,0],"ti":[-0.589,-0.251,0]},{"i":{"x":0.833,"y":0.84},"o":{"x":0.167,"y":0.174},"t":28,"s":[182.153,376.359,0],"to":[0.589,0.251,0],"ti":[-0.557,-0.198,0]},{"i":{"x":0.833,"y":0.84},"o":{"x":0.167,"y":0.174},"t":29,"s":[183.874,377.025,0],"to":[0.557,0.198,0],"ti":[-0.524,-0.154,0]},{"i":{"x":0.833,"y":0.84},"o":{"x":0.167,"y":0.174},"t":30,"s":[185.496,377.549,0],"to":[0.524,0.154,0],"ti":[-0.49,-0.117,0]},{"i":{"x":0.833,"y":0.84},"o":{"x":0.167,"y":0.174},"t":31,"s":[187.016,377.951,0],"to":[0.49,0.117,0],"ti":[-0.456,-0.086,0]},{"i":{"x":0.833,"y":0.84},"o":{"x":0.167,"y":0.174},"t":32,"s":[188.435,378.252,0],"to":[0.456,0.086,0],"ti":[-0.422,-0.059,0]},{"i":{"x":0.833,"y":0.841},"o":{"x":0.167,"y":0.174},"t":33,"s":[189.752,378.465,0],"to":[0.422,0.059,0],"ti":[-0.387,-0.035,0]},{"i":{"x":0.833,"y":0.842},"o":{"x":0.167,"y":0.175},"t":34,"s":[190.966,378.603,0],"to":[0.387,0.035,0],"ti":[-0.352,-0.015,0]},{"i":{"x":0.833,"y":0.843},"o":{"x":0.167,"y":0.176},"t":35,"s":[192.076,378.677,0],"to":[0.352,0.015,0],"ti":[-0.316,0.002,0]},{"i":{"x":0.833,"y":0.844},"o":{"x":0.167,"y":0.177},"t":36,"s":[193.079,378.696,0],"to":[0.316,-0.002,0],"ti":[-0.278,0.017,0]},{"i":{"x":0.833,"y":0.845},"o":{"x":0.167,"y":0.179},"t":37,"s":[193.971,378.666,0],"to":[0.278,-0.017,0],"ti":[-0.239,0.029,0]},{"i":{"x":0.833,"y":0.848},"o":{"x":0.167,"y":0.181},"t":38,"s":[194.747,378.596,0],"to":[0.239,-0.029,0],"ti":[-0.199,0.039,0]},{"i":{"x":0.833,"y":0.851},"o":{"x":0.167,"y":0.184},"t":39,"s":[195.405,378.492,0],"to":[0.199,-0.039,0],"ti":[-0.156,0.047,0]},{"i":{"x":0.833,"y":0.856},"o":{"x":0.167,"y":0.19},"t":40,"s":[195.939,378.361,0],"to":[0.156,-0.047,0],"ti":[-0.11,0.054,0]},{"i":{"x":0.833,"y":0.86},"o":{"x":0.167,"y":0.199},"t":41,"s":[196.341,378.209,0],"to":[0.11,-0.054,0],"ti":[-0.061,0.058,0]},{"i":{"x":0.833,"y":0.845},"o":{"x":0.167,"y":0.206},"t":42,"s":[196.601,378.04,0],"to":[0.061,-0.058,0],"ti":[-0.017,0.06,0]},{"i":{"x":0.833,"y":0.817},"o":{"x":0.167,"y":0.18},"t":43,"s":[196.709,377.862,0],"to":[0.017,-0.06,0],"ti":[0.003,0.066,0]},{"i":{"x":0.833,"y":0.816},"o":{"x":0.167,"y":0.153},"t":44,"s":[196.702,377.682,0],"to":[-0.003,-0.066,0],"ti":[-0.001,0.08,0]},{"i":{"x":0.833,"y":0.821},"o":{"x":0.167,"y":0.153},"t":45,"s":[196.693,377.464,0],"to":[0.001,-0.08,0],"ti":[-0.01,0.093,0]},{"i":{"x":0.833,"y":0.824},"o":{"x":0.167,"y":0.156},"t":46,"s":[196.709,377.203,0],"to":[0.01,-0.093,0],"ti":[-0.019,0.104,0]},{"i":{"x":0.833,"y":0.826},"o":{"x":0.167,"y":0.158},"t":47,"s":[196.752,376.906,0],"to":[0.019,-0.104,0],"ti":[-0.028,0.112,0]},{"i":{"x":0.833,"y":0.828},"o":{"x":0.167,"y":0.16},"t":48,"s":[196.823,376.58,0],"to":[0.028,-0.112,0],"ti":[-0.038,0.118,0]},{"i":{"x":0.833,"y":0.829},"o":{"x":0.167,"y":0.162},"t":49,"s":[196.923,376.233,0],"to":[0.038,-0.118,0],"ti":[-0.049,0.122,0]},{"i":{"x":0.833,"y":0.83},"o":{"x":0.167,"y":0.163},"t":50,"s":[197.053,375.871,0],"to":[0.049,-0.122,0],"ti":[-0.059,0.124,0]},{"i":{"x":0.833,"y":0.831},"o":{"x":0.167,"y":0.164},"t":51,"s":[197.215,375.501,0],"to":[0.059,-0.124,0],"ti":[-0.069,0.124,0]},{"i":{"x":0.833,"y":0.831},"o":{"x":0.167,"y":0.164},"t":52,"s":[197.407,375.127,0],"to":[0.069,-0.124,0],"ti":[-0.079,0.122,0]},{"i":{"x":0.833,"y":0.832},"o":{"x":0.167,"y":0.165},"t":53,"s":[197.63,374.756,0],"to":[0.079,-0.122,0],"ti":[-0.089,0.119,0]},{"i":{"x":0.833,"y":0.832},"o":{"x":0.167,"y":0.165},"t":54,"s":[197.883,374.392,0],"to":[0.089,-0.119,0],"ti":[-0.099,0.115,0]},{"i":{"x":0.833,"y":0.832},"o":{"x":0.167,"y":0.165},"t":55,"s":[198.166,374.04,0],"to":[0.099,-0.115,0],"ti":[-0.108,0.109,0]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.166},"t":56,"s":[198.477,373.703,0],"to":[0.108,-0.109,0],"ti":[-0.116,0.102,0]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.166},"t":57,"s":[198.814,373.385,0],"to":[0.116,-0.102,0],"ti":[-0.124,0.095,0]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.166},"t":58,"s":[199.175,373.088,0],"to":[0.124,-0.095,0],"ti":[-0.064,0.045,0]},{"t":59,"s":[199.559,372.816,0]}],"ix":2,"l":2},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":1,"k":[{"i":{"x":[0.833,0.833,0.833],"y":[0.903,1.422,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.167,0.167,0]},"t":3,"s":[94.702,122.655,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.272,0.85,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.6,0.07,0]},"t":4,"s":[127.289,129.977,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.732,1.117,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.064,0.187,0]},"t":5,"s":[132.545,85.581,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.963,0.916,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.121,0.049,0]},"t":6,"s":[110.128,50.029,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.861,2.842,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.066,6.415,0]},"t":7,"s":[60.526,135.359,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.066,0.745,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.209,0.08,0]},"t":8,"s":[88.167,136.482,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.966,0.932,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.037,0.124,0]},"t":9,"s":[106.49,110.542,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.936,0.426,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.056,-0.36,0]},"t":10,"s":[73.621,57.227,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.484,1.02,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.27,0.097,0]},"t":11,"s":[93.234,67.259,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.866,0.935,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.071,0.016,0]},"t":12,"s":[88.608,126.279,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.185,0.861,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.221,-0.293,0]},"t":13,"s":[120.076,52.9,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.916,0.706,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.057,0.207,0]},"t":14,"s":[139.099,69.169,100]},{"i":{"x":[0.833,0.833,0.833],"y":[-1.441,0.819,1]},"o":{"x":[0.167,0.167,0.167],"y":[6.732,0.116,0]},"t":15,"s":[77.908,80.095,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.941,1.013,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.086,0.155,0]},"t":16,"s":[77.141,107.685,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.598,0.859,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.198,0.011,0]},"t":17,"s":[55.437,139.887,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.889,0.971,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.105,0.204,0]},"t":18,"s":[61.857,102.633,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.557,0.988,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.337,-0.045,0]},"t":19,"s":[86.378,76.933,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.048,1.193,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.103,-0.014,0]},"t":20,"s":[94.434,93.656,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.894,0.969,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.03,0.058,0]},"t":21,"s":[129.176,79.379,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.003,0.846,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.385,-0.049,0]},"t":22,"s":[74.62,126.637,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.783,0.843,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.003,0.182,0]},"t":23,"s":[59.528,96.902,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.987,0.929,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.135,0.178,0]},"t":24,"s":[75.163,71.778,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.98,0.157,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.016,-0.467,0]},"t":25,"s":[100.236,49.638,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.196,0.894,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.026,0.092,0]},"t":26,"s":[79.183,52.988,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.937,1.011,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.058,0.384,0]},"t":27,"s":[95.26,83.538,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.998,0.441,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.251,0.01,0]},"t":28,"s":[41.321,92.009,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.333,0.953,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.002,0.098,0]},"t":29,"s":[54.762,82.447,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.996,0.891,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.067,-0.107,0]},"t":30,"s":[41.586,27.852,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.941,0.229,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.004,0.35,0]},"t":31,"s":[107.415,51.724,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.942,0.994,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.201,0.093,0]},"t":32,"s":[44.413,59.172,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.637,0.897,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.19,-0.007,0]},"t":33,"s":[62.871,120.641,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.914,1.033,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.108,0.443,0]},"t":34,"s":[57.246,63.863,100]},{"i":{"x":[0.833,0.833,0.833],"y":[3.767,0.841,1]},"o":{"x":[0.167,0.167,0.167],"y":[2.63,0.024,0]},"t":35,"s":[38.372,50.708,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.822,1.066,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.081,0.175,0]},"t":36,"s":[37.754,69.059,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.994,0.844,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.156,0.037,0]},"t":37,"s":[58.88,85.746,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.943,1.009,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.007,0.179,0]},"t":38,"s":[83,55.846,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.235,1.083,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.184,0.008,0]},"t":39,"s":[60.74,29.847,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.881,0.964,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.061,0.042,0]},"t":40,"s":[67.668,58.5,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.257,0.898,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.279,-0.062,0]},"t":41,"s":[41.227,1.342,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.914,0.99,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.063,0.452,0]},"t":42,"s":[29.972,34.049,100]},{"i":{"x":[0.833,0.833,0.833],"y":[3.558,0.889,1]},"o":{"x":[0.167,0.167,0.167],"y":[2.356,-0.011,0]},"t":43,"s":[75.997,41.447,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.923,0.757,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.081,0.337,0]},"t":44,"s":[77.684,34.902,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.665,1.132,1]},"o":{"x":[0.167,0.167,0.167],"y":[-1.086,0.127,0]},"t":45,"s":[24.202,32.753,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.007,0.637,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.074,0.051,0]},"t":46,"s":[28.012,28.648,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.981,0.974,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.006,0.108,0]},"t":47,"s":[-6.22,39.267,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1,0.981,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.024,-0.038,0]},"t":48,"s":[30.742,74.855,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.934,1.113,1]},"o":{"x":[0.167,0.167,0.167],"y":[0,-0.024,0]},"t":49,"s":[2.023,50.337,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.727,0.883,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.326,0.048,0]},"t":50,"s":[30.77,69.402,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.902,1.045,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.12,0.292,0]},"t":51,"s":[24.92,24.502,100]},{"i":{"x":[0.833,0.833,0.833],"y":[2.108,0.909,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.55,0.029,0]},"t":52,"s":[11.611,6.547,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.948,0.904,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.078,0.962,0]},"t":53,"s":[9.235,34.159,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.825,-0.409,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.138,0.611,0]},"t":54,"s":[43.201,36.778,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.973,0.815,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.159,0.089,0]},"t":55,"s":[30.429,37.192,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.903,1.034,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.039,0.152,0]},"t":56,"s":[16.318,43.768,100]},{"i":{"x":[0.833,0.833,0.833],"y":[-0.408,0.765,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.6,0.024,0]},"t":57,"s":[25.931,51.757,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.833,0.833,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.089,0.129,0]},"t":58,"s":[27.481,40.555,100]},{"t":59,"s":[52.128,20.168,100]}],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"d":1,"ty":"el","s":{"a":0,"k":[10,10],"ix":2},"p":{"a":0,"k":[0,0],"ix":3},"nm":"Ellipse Path 1","mn":"ADBE Vector Shape - Ellipse","hd":false},{"ty":"fl","c":{"a":0,"k":[1,0.10196078431372549,0.3803921568627451,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Ellipse 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":3,"op":60,"st":3,"bm":0},{"ddd":0,"ind":25,"ty":4,"nm":"Shape Layer 27","sr":1,"ks":{"o":{"a":1,"k":[{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":1,"s":[0]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":6,"s":[100]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":28,"s":[100]},{"t":44,"s":[0]}],"ix":11},"r":{"a":0,"k":92,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.833,"y":0.551},"o":{"x":0.167,"y":0.167},"t":1,"s":[264.583,303.394,0],"to":[0.152,-0.309,0],"ti":[-0.732,1.703,0]},{"i":{"x":0.833,"y":0.762},"o":{"x":0.167,"y":0.102},"t":2,"s":[265.494,301.541,0],"to":[0.732,-1.703,0],"ti":[-1.362,4.094,0]},{"i":{"x":0.833,"y":0.824},"o":{"x":0.167,"y":0.128},"t":3,"s":[268.975,293.174,0],"to":[1.362,-4.094,0],"ti":[-1.445,5.76,0]},{"i":{"x":0.833,"y":0.844},"o":{"x":0.167,"y":0.158},"t":4,"s":[273.666,276.977,0],"to":[1.445,-5.76,0],"ti":[-1.23,5.729,0]},{"i":{"x":0.833,"y":0.847},"o":{"x":0.167,"y":0.179},"t":5,"s":[277.646,258.613,0],"to":[1.23,-5.729,0],"ti":[-1.123,4.869,0]},{"i":{"x":0.833,"y":0.847},"o":{"x":0.167,"y":0.183},"t":6,"s":[281.044,242.601,0],"to":[1.123,-4.869,0],"ti":[-1.146,4.013,0]},{"i":{"x":0.833,"y":0.845},"o":{"x":0.167,"y":0.183},"t":7,"s":[284.385,229.397,0],"to":[1.146,-4.013,0],"ti":[-1.227,3.315,0]},{"i":{"x":0.833,"y":0.844},"o":{"x":0.167,"y":0.181},"t":8,"s":[287.919,218.524,0],"to":[1.227,-3.315,0],"ti":[-1.326,2.748,0]},{"i":{"x":0.833,"y":0.843},"o":{"x":0.167,"y":0.179},"t":9,"s":[291.745,209.508,0],"to":[1.326,-2.748,0],"ti":[-1.42,2.27,0]},{"i":{"x":0.833,"y":0.842},"o":{"x":0.167,"y":0.177},"t":10,"s":[295.873,202.037,0],"to":[1.42,-2.27,0],"ti":[-1.493,1.856,0]},{"i":{"x":0.833,"y":0.841},"o":{"x":0.167,"y":0.176},"t":11,"s":[300.264,195.89,0],"to":[1.493,-1.856,0],"ti":[-1.535,1.495,0]},{"i":{"x":0.833,"y":0.841},"o":{"x":0.167,"y":0.175},"t":12,"s":[304.832,190.902,0],"to":[1.535,-1.495,0],"ti":[-1.541,1.186,0]},{"i":{"x":0.833,"y":0.84},"o":{"x":0.167,"y":0.175},"t":13,"s":[309.473,186.917,0],"to":[1.541,-1.186,0],"ti":[-1.513,0.928,0]},{"i":{"x":0.833,"y":0.84},"o":{"x":0.167,"y":0.174},"t":14,"s":[314.076,183.786,0],"to":[1.513,-0.928,0],"ti":[-1.459,0.719,0]},{"i":{"x":0.833,"y":0.84},"o":{"x":0.167,"y":0.174},"t":15,"s":[318.551,181.352,0],"to":[1.459,-0.719,0],"ti":[-1.388,0.557,0]},{"i":{"x":0.833,"y":0.84},"o":{"x":0.167,"y":0.174},"t":16,"s":[322.832,179.47,0],"to":[1.388,-0.557,0],"ti":[-1.306,0.432,0]},{"i":{"x":0.833,"y":0.84},"o":{"x":0.167,"y":0.174},"t":17,"s":[326.879,178.013,0],"to":[1.306,-0.432,0],"ti":[-1.218,0.338,0]},{"i":{"x":0.833,"y":0.84},"o":{"x":0.167,"y":0.174},"t":18,"s":[330.667,176.878,0],"to":[1.218,-0.338,0],"ti":[-1.129,0.269,0]},{"i":{"x":0.833,"y":0.84},"o":{"x":0.167,"y":0.174},"t":19,"s":[334.187,175.983,0],"to":[1.129,-0.269,0],"ti":[-1.043,0.219,0]},{"i":{"x":0.833,"y":0.84},"o":{"x":0.167,"y":0.174},"t":20,"s":[337.444,175.264,0],"to":[1.043,-0.219,0],"ti":[-0.959,0.182,0]},{"i":{"x":0.833,"y":0.841},"o":{"x":0.167,"y":0.174},"t":21,"s":[340.444,174.672,0],"to":[0.959,-0.182,0],"ti":[-0.877,0.155,0]},{"i":{"x":0.833,"y":0.841},"o":{"x":0.167,"y":0.175},"t":22,"s":[343.196,174.174,0],"to":[0.877,-0.155,0],"ti":[-0.798,0.134,0]},{"i":{"x":0.833,"y":0.842},"o":{"x":0.167,"y":0.175},"t":23,"s":[345.706,173.744,0],"to":[0.798,-0.134,0],"ti":[-0.72,0.119,0]},{"i":{"x":0.833,"y":0.842},"o":{"x":0.167,"y":0.176},"t":24,"s":[347.981,173.367,0],"to":[0.72,-0.119,0],"ti":[-0.644,0.106,0]},{"i":{"x":0.833,"y":0.843},"o":{"x":0.167,"y":0.177},"t":25,"s":[350.026,173.032,0],"to":[0.644,-0.106,0],"ti":[-0.57,0.094,0]},{"i":{"x":0.833,"y":0.845},"o":{"x":0.167,"y":0.178},"t":26,"s":[351.847,172.732,0],"to":[0.57,-0.094,0],"ti":[-0.497,0.083,0]},{"i":{"x":0.833,"y":0.845},"o":{"x":0.167,"y":0.18},"t":27,"s":[353.446,172.467,0],"to":[0.497,-0.083,0],"ti":[-0.429,0.075,0]},{"i":{"x":0.833,"y":0.839},"o":{"x":0.167,"y":0.18},"t":28,"s":[354.827,172.237,0],"to":[0.429,-0.075,0],"ti":[-0.38,0.083,0]},{"i":{"x":0.833,"y":0.839},"o":{"x":0.167,"y":0.173},"t":29,"s":[356.018,172.02,0],"to":[0.38,-0.083,0],"ti":[-0.347,0.103,0]},{"i":{"x":0.833,"y":0.838},"o":{"x":0.167,"y":0.172},"t":30,"s":[357.106,171.74,0],"to":[0.347,-0.103,0],"ti":[-0.318,0.12,0]},{"i":{"x":0.833,"y":0.838},"o":{"x":0.167,"y":0.172},"t":31,"s":[358.103,171.404,0],"to":[0.318,-0.12,0],"ti":[-0.291,0.134,0]},{"i":{"x":0.833,"y":0.838},"o":{"x":0.167,"y":0.172},"t":32,"s":[359.015,171.022,0],"to":[0.291,-0.134,0],"ti":[-0.265,0.144,0]},{"i":{"x":0.833,"y":0.838},"o":{"x":0.167,"y":0.172},"t":33,"s":[359.846,170.603,0],"to":[0.265,-0.144,0],"ti":[-0.24,0.151,0]},{"i":{"x":0.833,"y":0.839},"o":{"x":0.167,"y":0.172},"t":34,"s":[360.602,170.159,0],"to":[0.24,-0.151,0],"ti":[-0.216,0.155,0]},{"i":{"x":0.833,"y":0.839},"o":{"x":0.167,"y":0.172},"t":35,"s":[361.284,169.698,0],"to":[0.216,-0.155,0],"ti":[-0.193,0.156,0]},{"i":{"x":0.833,"y":0.84},"o":{"x":0.167,"y":0.173},"t":36,"s":[361.896,169.23,0],"to":[0.193,-0.156,0],"ti":[-0.172,0.154,0]},{"i":{"x":0.833,"y":0.84},"o":{"x":0.167,"y":0.173},"t":37,"s":[362.443,168.763,0],"to":[0.172,-0.154,0],"ti":[-0.151,0.15,0]},{"i":{"x":0.833,"y":0.841},"o":{"x":0.167,"y":0.174},"t":38,"s":[362.927,168.306,0],"to":[0.151,-0.15,0],"ti":[-0.13,0.143,0]},{"i":{"x":0.833,"y":0.843},"o":{"x":0.167,"y":0.176},"t":39,"s":[363.348,167.866,0],"to":[0.13,-0.143,0],"ti":[-0.109,0.134,0]},{"i":{"x":0.833,"y":0.841},"o":{"x":0.167,"y":0.177},"t":40,"s":[363.707,167.448,0],"to":[0.109,-0.134,0],"ti":[-0.095,0.123,0]},{"i":{"x":0.833,"y":0.839},"o":{"x":0.167,"y":0.175},"t":41,"s":[364.004,167.059,0],"to":[0.095,-0.123,0],"ti":[-0.093,0.109,0]},{"i":{"x":0.833,"y":0.839},"o":{"x":0.167,"y":0.172},"t":42,"s":[364.278,166.709,0],"to":[0.093,-0.109,0],"ti":[-0.096,0.093,0]},{"i":{"x":0.833,"y":0.839},"o":{"x":0.167,"y":0.173},"t":43,"s":[364.562,166.405,0],"to":[0.096,-0.093,0],"ti":[-0.099,0.076,0]},{"i":{"x":0.833,"y":0.839},"o":{"x":0.167,"y":0.173},"t":44,"s":[364.856,166.152,0],"to":[0.099,-0.076,0],"ti":[-0.1,0.058,0]},{"i":{"x":0.833,"y":0.839},"o":{"x":0.167,"y":0.173},"t":45,"s":[365.154,165.951,0],"to":[0.1,-0.058,0],"ti":[-0.1,0.041,0]},{"i":{"x":0.833,"y":0.838},"o":{"x":0.167,"y":0.172},"t":46,"s":[365.455,165.803,0],"to":[0.1,-0.041,0],"ti":[-0.099,0.023,0]},{"i":{"x":0.833,"y":0.837},"o":{"x":0.167,"y":0.171},"t":47,"s":[365.754,165.707,0],"to":[0.099,-0.023,0],"ti":[-0.096,0.006,0]},{"i":{"x":0.833,"y":0.835},"o":{"x":0.167,"y":0.17},"t":48,"s":[366.048,165.664,0],"to":[0.096,-0.006,0],"ti":[-0.093,-0.01,0]},{"i":{"x":0.833,"y":0.834},"o":{"x":0.167,"y":0.169},"t":49,"s":[366.333,165.672,0],"to":[0.093,0.01,0],"ti":[-0.088,-0.026,0]},{"i":{"x":0.833,"y":0.834},"o":{"x":0.167,"y":0.168},"t":50,"s":[366.605,165.727,0],"to":[0.088,0.026,0],"ti":[-0.082,-0.04,0]},{"i":{"x":0.833,"y":0.834},"o":{"x":0.167,"y":0.167},"t":51,"s":[366.86,165.827,0],"to":[0.082,0.04,0],"ti":[-0.074,-0.052,0]},{"i":{"x":0.833,"y":0.834},"o":{"x":0.167,"y":0.167},"t":52,"s":[367.094,165.966,0],"to":[0.074,0.052,0],"ti":[-0.065,-0.063,0]},{"i":{"x":0.833,"y":0.835},"o":{"x":0.167,"y":0.167},"t":53,"s":[367.302,166.14,0],"to":[0.065,0.063,0],"ti":[-0.054,-0.071,0]},{"i":{"x":0.833,"y":0.836},"o":{"x":0.167,"y":0.168},"t":54,"s":[367.481,166.343,0],"to":[0.054,0.071,0],"ti":[-0.042,-0.077,0]},{"i":{"x":0.833,"y":0.837},"o":{"x":0.167,"y":0.169},"t":55,"s":[367.625,166.567,0],"to":[0.042,0.077,0],"ti":[-0.028,-0.08,0]},{"i":{"x":0.833,"y":0.838},"o":{"x":0.167,"y":0.17},"t":56,"s":[367.73,166.804,0],"to":[0.028,0.08,0],"ti":[-0.012,-0.079,0]},{"i":{"x":0.833,"y":0.836},"o":{"x":0.167,"y":0.171},"t":57,"s":[367.791,167.045,0],"to":[0.012,0.079,0],"ti":[0.003,-0.077,0]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.169},"t":58,"s":[367.803,167.28,0],"to":[-0.003,0.077,0],"ti":[0.005,-0.038,0]},{"t":59,"s":[367.776,167.508,0]}],"ix":2,"l":2},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":1,"k":[{"i":{"x":[0.833,0.833,0.833],"y":[-0.282,0.584,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.167,0.167,0]},"t":1,"s":[72.171,102.828,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.885,1.039,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.089,0.104,0]},"t":2,"s":[75.523,112.995,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.019,0.877,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.304,0.027,0]},"t":3,"s":[123.744,153.636,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.693,1.033,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.016,0.256,0]},"t":4,"s":[141.925,93.916,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.936,0.883,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.114,0.023,0]},"t":5,"s":[119.587,65.139,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.769,0.894,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.276,0.289,0]},"t":6,"s":[59.668,105.203,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.003,2.032,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.13,0.39,0]},"t":7,"s":[73.555,121.412,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.032,0.905,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.003,0.077,0]},"t":8,"s":[98.171,125.813,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.887,1.503,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.023,0.67,0]},"t":9,"s":[72.643,66.925,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.904,0.969,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.315,0.071,0]},"t":10,"s":[108.084,58.564,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.479,0.968,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.612,-0.05,0]},"t":11,"s":[120.859,117.368,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.764,0.88,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.071,-0.052,0]},"t":12,"s":[122.874,80.731,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.916,1.012,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.129,0.273,0]},"t":13,"s":[109.267,103.32,100]},{"i":{"x":[0.833,0.833,0.833],"y":[3.633,1.171,1]},"o":{"x":[0.167,0.167,0.167],"y":[12.53,0.011,0]},"t":14,"s":[84.332,113.266,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.72,0.952,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.081,0.056,0]},"t":15,"s":[84.165,101.864,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.993,0.575,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.119,-0.115,0]},"t":16,"s":[89.607,136.724,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.905,0.946,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.007,0.104,0]},"t":17,"s":[102.429,122.041,100]},{"i":{"x":[0.833,0.833,0.833],"y":[2.566,0.936,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.667,-0.15,0]},"t":18,"s":[90.653,61.798,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.025,1.312,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.079,-0.274,0]},"t":19,"s":[88.97,83.343,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.879,0.882,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.019,0.066,0]},"t":20,"s":[122.282,78.321,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.099,0.909,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.27,0.282,0]},"t":21,"s":[78.796,102.131,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.927,5.118,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.045,0.945,0]},"t":22,"s":[59.361,112.137,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.136,0.975,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.587,0.082,0]},"t":23,"s":[102.002,113.104,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.943,0.921,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.052,-0.036,0]},"t":24,"s":[96.704,64.316,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.841,2.162,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.183,-1.67,0]},"t":25,"s":[110.65,98.408,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.252,1.02,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.176,0.078,0]},"t":26,"s":[106.279,96.788,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.859,0.808,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.094,0.016,0]},"t":27,"s":[102.331,121.007,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.964,0.929,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.205,0.147,0]},"t":28,"s":[70.842,91.027,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.045,0.592,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.063,-0.479,0]},"t":29,"s":[49.193,51.754,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.816,0.982,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.029,0.105,0]},"t":30,"s":[61.557,57.576,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.115,0.844,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.152,-0.022,0]},"t":31,"s":[42.519,80.235,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.93,0.834,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.048,0.179,0]},"t":32,"s":[19.507,62.344,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.463,1.029,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.45,0.167,0]},"t":33,"s":[74.401,46.72,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.951,0.852,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.099,0.021,0]},"t":34,"s":[65.831,31.185,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.744,1.068,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.12,0.191,0]},"t":35,"s":[19.228,52.105,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.91,0.811,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.124,0.037,0]},"t":36,"s":[38.347,68.326,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.615,1.069,1]},"o":{"x":[0.167,0.167,0.167],"y":[1.166,0.149,0]},"t":37,"s":[77.863,38.956,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.84,0.995,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.073,0.038,0]},"t":38,"s":[80.904,1.852,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.914,0.924,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.174,-0.006,0]},"t":39,"s":[55.41,69.603,100]},{"i":{"x":[0.833,0.833,0.833],"y":[4.375,0.208,1]},"o":{"x":[0.167,0.167,0.167],"y":[2.748,-0.921,0]},"t":40,"s":[31.879,6.237,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.903,1.05,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.081,0.093,0]},"t":41,"s":[31.143,11.494,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.257,0.972,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.59,0.031,0]},"t":42,"s":[61.679,56.19,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.875,0.989,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.063,-0.042,0]},"t":43,"s":[66.703,-15.414,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.777,0.969,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.252,-0.012,0]},"t":44,"s":[46.165,32.254,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.071,0.833,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.133,-0.048,0]},"t":45,"s":[36.01,-9.217,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.042,0.895,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.038,0.166,0]},"t":46,"s":[18.977,17.016,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.967,0.64,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.028,0.41,0]},"t":47,"s":[50.584,43.433,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.951,1.026,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.054,0.108,0]},"t":48,"s":[3.2,50.175,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.826,0.687,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.121,0.02,0]},"t":49,"s":[31.978,72.587,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.958,0.985,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.16,0.114,0]},"t":50,"s":[20.225,43.283,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.531,0.899,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.084,-0.019,0]},"t":51,"s":[7.424,-37.423,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.924,0.885,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.101,0.475,0]},"t":52,"s":[13.811,28.463,100]},{"i":{"x":[0.833,0.833,0.833],"y":[-0.207,0.722,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.872,0.302,0]},"t":53,"s":[43.338,42.467,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.956,0.943,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.09,0.119,0]},"t":54,"s":[40.761,47.811,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.074,0.996,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.092,-0.177,0]},"t":55,"s":[6.006,60.269,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.951,2.021,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.039,-0.004,0]},"t":56,"s":[22.502,56.273,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.823,0.991,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.12,0.077,0]},"t":57,"s":[-8.669,60.085,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.833,0.833,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.158,-0.01,0]},"t":58,"s":[4.137,9.589,100]},{"t":59,"s":[18.516,54.805,100]}],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"d":1,"ty":"el","s":{"a":0,"k":[10,10],"ix":2},"p":{"a":0,"k":[0,0],"ix":3},"nm":"Ellipse Path 1","mn":"ADBE Vector Shape - Ellipse","hd":false},{"ty":"fl","c":{"a":0,"k":[1,0.10196078431372549,0.3803921568627451,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Ellipse 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":1,"op":60,"st":1,"bm":0},{"ddd":0,"ind":26,"ty":4,"nm":"Shape Layer 26","sr":1,"ks":{"o":{"a":1,"k":[{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":4,"s":[0]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":9,"s":[100]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":31,"s":[100]},{"t":47,"s":[0]}],"ix":11},"r":{"a":0,"k":92,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.833,"y":0.589},"o":{"x":0.167,"y":0.167},"t":4,"s":[269.491,306.01,0],"to":[0.367,0.261,0],"ti":[-1.762,-1.355,0]},{"i":{"x":0.833,"y":0.764},"o":{"x":0.167,"y":0.105},"t":5,"s":[271.692,307.579,0],"to":[1.762,1.355,0],"ti":[-4.055,-2.961,0]},{"i":{"x":0.833,"y":0.824},"o":{"x":0.167,"y":0.129},"t":6,"s":[280.065,314.139,0],"to":[4.055,2.961,0],"ti":[-5.763,-3.712,0]},{"i":{"x":0.833,"y":0.844},"o":{"x":0.167,"y":0.158},"t":7,"s":[296.024,325.342,0],"to":[5.763,3.712,0],"ti":[-5.909,-3.26,0]},{"i":{"x":0.833,"y":0.847},"o":{"x":0.167,"y":0.179},"t":8,"s":[314.641,336.411,0],"to":[5.909,3.26,0],"ti":[-5.199,-2.461,0]},{"i":{"x":0.833,"y":0.847},"o":{"x":0.167,"y":0.184},"t":9,"s":[331.476,344.903,0],"to":[5.199,2.461,0],"ti":[-4.435,-1.834,0]},{"i":{"x":0.833,"y":0.846},"o":{"x":0.167,"y":0.183},"t":10,"s":[345.833,351.179,0],"to":[4.435,1.834,0],"ti":[-3.802,-1.398,0]},{"i":{"x":0.833,"y":0.845},"o":{"x":0.167,"y":0.181},"t":11,"s":[358.083,355.908,0],"to":[3.802,1.398,0],"ti":[-3.292,-1.093,0]},{"i":{"x":0.833,"y":0.844},"o":{"x":0.167,"y":0.18},"t":12,"s":[368.642,359.567,0],"to":[3.292,1.093,0],"ti":[-2.886,-0.867,0]},{"i":{"x":0.833,"y":0.843},"o":{"x":0.167,"y":0.178},"t":13,"s":[377.836,362.467,0],"to":[2.886,0.867,0],"ti":[-2.566,-0.687,0]},{"i":{"x":0.833,"y":0.842},"o":{"x":0.167,"y":0.177},"t":14,"s":[385.959,364.771,0],"to":[2.566,0.687,0],"ti":[-2.306,-0.543,0]},{"i":{"x":0.833,"y":0.841},"o":{"x":0.167,"y":0.176},"t":15,"s":[393.233,366.589,0],"to":[2.306,0.543,0],"ti":[-2.085,-0.431,0]},{"i":{"x":0.833,"y":0.841},"o":{"x":0.167,"y":0.176},"t":16,"s":[399.792,368.03,0],"to":[2.085,0.431,0],"ti":[-1.895,-0.342,0]},{"i":{"x":0.833,"y":0.841},"o":{"x":0.167,"y":0.175},"t":17,"s":[405.741,369.174,0],"to":[1.895,0.342,0],"ti":[-1.729,-0.272,0]},{"i":{"x":0.833,"y":0.84},"o":{"x":0.167,"y":0.175},"t":18,"s":[411.16,370.082,0],"to":[1.729,0.272,0],"ti":[-1.583,-0.217,0]},{"i":{"x":0.833,"y":0.84},"o":{"x":0.167,"y":0.174},"t":19,"s":[416.115,370.805,0],"to":[1.583,0.217,0],"ti":[-1.452,-0.173,0]},{"i":{"x":0.833,"y":0.84},"o":{"x":0.167,"y":0.174},"t":20,"s":[420.657,371.382,0],"to":[1.452,0.173,0],"ti":[-1.335,-0.139,0]},{"i":{"x":0.833,"y":0.84},"o":{"x":0.167,"y":0.174},"t":21,"s":[424.829,371.844,0],"to":[1.335,0.139,0],"ti":[-1.229,-0.113,0]},{"i":{"x":0.833,"y":0.84},"o":{"x":0.167,"y":0.174},"t":22,"s":[428.668,372.218,0],"to":[1.229,0.113,0],"ti":[-1.131,-0.094,0]},{"i":{"x":0.833,"y":0.84},"o":{"x":0.167,"y":0.174},"t":23,"s":[432.201,372.525,0],"to":[1.131,0.094,0],"ti":[-1.041,-0.08,0]},{"i":{"x":0.833,"y":0.84},"o":{"x":0.167,"y":0.174},"t":24,"s":[435.454,372.783,0],"to":[1.041,0.08,0],"ti":[-0.958,-0.071,0]},{"i":{"x":0.833,"y":0.84},"o":{"x":0.167,"y":0.174},"t":25,"s":[438.448,373.008,0],"to":[0.958,0.071,0],"ti":[-0.88,-0.066,0]},{"i":{"x":0.833,"y":0.84},"o":{"x":0.167,"y":0.174},"t":26,"s":[441.201,373.211,0],"to":[0.88,0.066,0],"ti":[-0.807,-0.063,0]},{"i":{"x":0.833,"y":0.84},"o":{"x":0.167,"y":0.174},"t":27,"s":[443.727,373.403,0],"to":[0.807,0.063,0],"ti":[-0.738,-0.063,0]},{"i":{"x":0.833,"y":0.841},"o":{"x":0.167,"y":0.174},"t":28,"s":[446.042,373.591,0],"to":[0.738,0.063,0],"ti":[-0.673,-0.065,0]},{"i":{"x":0.833,"y":0.841},"o":{"x":0.167,"y":0.175},"t":29,"s":[448.156,373.783,0],"to":[0.673,0.065,0],"ti":[-0.611,-0.069,0]},{"i":{"x":0.833,"y":0.841},"o":{"x":0.167,"y":0.175},"t":30,"s":[450.079,373.983,0],"to":[0.611,0.069,0],"ti":[-0.552,-0.073,0]},{"i":{"x":0.833,"y":0.842},"o":{"x":0.167,"y":0.176},"t":31,"s":[451.822,374.195,0],"to":[0.552,0.073,0],"ti":[-0.495,-0.077,0]},{"i":{"x":0.833,"y":0.842},"o":{"x":0.167,"y":0.176},"t":32,"s":[453.39,374.419,0],"to":[0.495,0.077,0],"ti":[-0.44,-0.082,0]},{"i":{"x":0.833,"y":0.843},"o":{"x":0.167,"y":0.177},"t":33,"s":[454.792,374.659,0],"to":[0.44,0.082,0],"ti":[-0.387,-0.086,0]},{"i":{"x":0.833,"y":0.844},"o":{"x":0.167,"y":0.178},"t":34,"s":[456.031,374.911,0],"to":[0.387,0.086,0],"ti":[-0.336,-0.09,0]},{"i":{"x":0.833,"y":0.845},"o":{"x":0.167,"y":0.179},"t":35,"s":[457.115,375.176,0],"to":[0.336,0.09,0],"ti":[-0.286,-0.092,0]},{"i":{"x":0.833,"y":0.847},"o":{"x":0.167,"y":0.181},"t":36,"s":[458.047,375.45,0],"to":[0.286,0.092,0],"ti":[-0.237,-0.093,0]},{"i":{"x":0.833,"y":0.849},"o":{"x":0.167,"y":0.183},"t":37,"s":[458.831,375.73,0],"to":[0.237,0.093,0],"ti":[-0.19,-0.093,0]},{"i":{"x":0.833,"y":0.851},"o":{"x":0.167,"y":0.186},"t":38,"s":[459.47,376.01,0],"to":[0.19,0.093,0],"ti":[-0.143,-0.09,0]},{"i":{"x":0.833,"y":0.855},"o":{"x":0.167,"y":0.19},"t":39,"s":[459.968,376.286,0],"to":[0.143,0.09,0],"ti":[-0.097,-0.085,0]},{"i":{"x":0.833,"y":0.857},"o":{"x":0.167,"y":0.195},"t":40,"s":[460.328,376.55,0],"to":[0.097,0.085,0],"ti":[-0.052,-0.077,0]},{"i":{"x":0.833,"y":0.85},"o":{"x":0.167,"y":0.2},"t":41,"s":[460.55,376.795,0],"to":[0.052,0.077,0],"ti":[-0.007,-0.066,0]},{"i":{"x":0.833,"y":0.811},"o":{"x":0.167,"y":0.188},"t":42,"s":[460.639,377.012,0],"to":[0.007,0.066,0],"ti":[0.032,-0.061,0]},{"i":{"x":0.833,"y":0.813},"o":{"x":0.167,"y":0.149},"t":43,"s":[460.595,377.193,0],"to":[-0.032,0.061,0],"ti":[0.056,-0.069,0]},{"i":{"x":0.833,"y":0.825},"o":{"x":0.167,"y":0.151},"t":44,"s":[460.445,377.377,0],"to":[-0.056,0.069,0],"ti":[0.06,-0.083,0]},{"i":{"x":0.833,"y":0.827},"o":{"x":0.167,"y":0.159},"t":45,"s":[460.259,377.605,0],"to":[-0.06,0.083,0],"ti":[0.055,-0.097,0]},{"i":{"x":0.833,"y":0.828},"o":{"x":0.167,"y":0.16},"t":46,"s":[460.084,377.877,0],"to":[-0.055,0.097,0],"ti":[0.049,-0.11,0]},{"i":{"x":0.833,"y":0.829},"o":{"x":0.167,"y":0.161},"t":47,"s":[459.926,378.189,0],"to":[-0.049,0.11,0],"ti":[0.042,-0.121,0]},{"i":{"x":0.833,"y":0.829},"o":{"x":0.167,"y":0.162},"t":48,"s":[459.788,378.537,0],"to":[-0.042,0.121,0],"ti":[0.034,-0.13,0]},{"i":{"x":0.833,"y":0.83},"o":{"x":0.167,"y":0.163},"t":49,"s":[459.673,378.915,0],"to":[-0.034,0.13,0],"ti":[0.025,-0.138,0]},{"i":{"x":0.833,"y":0.83},"o":{"x":0.167,"y":0.163},"t":50,"s":[459.585,379.319,0],"to":[-0.025,0.138,0],"ti":[0.015,-0.145,0]},{"i":{"x":0.833,"y":0.831},"o":{"x":0.167,"y":0.164},"t":51,"s":[459.525,379.745,0],"to":[-0.015,0.145,0],"ti":[0.004,-0.15,0]},{"i":{"x":0.833,"y":0.831},"o":{"x":0.167,"y":0.164},"t":52,"s":[459.496,380.189,0],"to":[-0.004,0.15,0],"ti":[-0.006,-0.154,0]},{"i":{"x":0.833,"y":0.832},"o":{"x":0.167,"y":0.165},"t":53,"s":[459.499,380.647,0],"to":[0.006,0.154,0],"ti":[-0.017,-0.157,0]},{"i":{"x":0.833,"y":0.832},"o":{"x":0.167,"y":0.165},"t":54,"s":[459.535,381.115,0],"to":[0.017,0.157,0],"ti":[-0.028,-0.158,0]},{"i":{"x":0.833,"y":0.832},"o":{"x":0.167,"y":0.165},"t":55,"s":[459.603,381.589,0],"to":[0.028,0.158,0],"ti":[-0.039,-0.159,0]},{"i":{"x":0.833,"y":0.832},"o":{"x":0.167,"y":0.166},"t":56,"s":[459.705,382.066,0],"to":[0.039,0.159,0],"ti":[-0.05,-0.158,0]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.166},"t":57,"s":[459.838,382.542,0],"to":[0.05,0.158,0],"ti":[-0.059,-0.156,0]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.166},"t":58,"s":[460.002,383.013,0],"to":[0.059,0.156,0],"ti":[-0.032,-0.077,0]},{"t":59,"s":[460.195,383.477,0]}],"ix":2,"l":2},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":1,"k":[{"i":{"x":[0.833,0.833,0.833],"y":[0.947,0.942,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.167,0.167,0]},"t":4,"s":[97.409,66.055,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.753,1.579,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.148,-0.189,0]},"t":5,"s":[44.567,76.147,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.004,1.01,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.126,0.073,0]},"t":6,"s":[63.613,73.054,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.971,0.9,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.004,0.009,0]},"t":7,"s":[100.946,97.634,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.792,1.619,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.045,0.494,0]},"t":8,"s":[61.805,70.187,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.951,0.93,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.139,0.073,0]},"t":9,"s":[87.307,64.612,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.68,0.923,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.12,-0.436,0]},"t":10,"s":[125.487,111.62,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.883,-3.054,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.113,-0.938,0]},"t":11,"s":[109.816,104.073,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.123,0.988,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.289,0.085,0]},"t":12,"s":[65.323,104.689,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.897,1.021,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.05,-0.013,0]},"t":13,"s":[47.252,134.038,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.754,0.996,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.445,0.017,0]},"t":14,"s":[91.917,108.765,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.965,0.782,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.126,-0.004,0]},"t":15,"s":[102.196,140.322,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.598,0.943,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.059,0.135,0]},"t":16,"s":[122.314,110.374,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.954,0.983,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.105,-0.179,0]},"t":17,"s":[110.56,62.157,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.992,0.811,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.102,-0.022,0]},"t":18,"s":[65.661,77.47,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.964,1.174,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.009,0.149,0]},"t":19,"s":[85.871,65.321,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.389,0.889,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.062,0.056,0]},"t":20,"s":[67.714,49.917,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.983,1.088,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.096,0.335,0]},"t":21,"s":[78.115,97.527,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.976,1.012,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.021,0.043,0]},"t":22,"s":[143.974,113.298,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.021,1.014,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.034,0.01,0]},"t":23,"s":[91.205,80.878,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.004,0.973,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.017,0.012,0]},"t":24,"s":[128.534,117.961,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.938,0.878,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.004,-0.04,0]},"t":25,"s":[81.833,74.831,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.701,1.076,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.245,0.261,0]},"t":26,"s":[130.604,103.951,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.848,0.884,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.115,0.04,0]},"t":27,"s":[118.24,117.585,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.924,1.085,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.185,0.297,0]},"t":28,"s":[86.185,91.54,100]},{"i":{"x":[0.833,0.833,0.833],"y":[2.239,1.079,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.857,0.042,0]},"t":29,"s":[59.806,81.372,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.06,0.921,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.078,0.041,0]},"t":30,"s":[62.144,101.879,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.904,1.073,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.035,-1.454,0]},"t":31,"s":[25.053,61.948,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.113,0.614,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.64,0.039,0]},"t":32,"s":[88.839,64.113,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.878,0.934,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.048,0.106,0]},"t":33,"s":[98.389,60.046,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.973,1.366,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.265,-0.316,0]},"t":34,"s":[75.918,45.28,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.487,0.892,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.04,0.068,0]},"t":35,"s":[65.601,48.361,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.02,1.735,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.1,0.366,0]},"t":36,"s":[72.571,31.73,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.839,1.002,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.016,0.075,0]},"t":37,"s":[108.482,26.833,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.939,0.884,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.173,0.002,0]},"t":38,"s":[64.065,74.946,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.807,1.122,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.226,0.296,0]},"t":39,"s":[22.794,25.686,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.039,1.029,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.147,0.05,0]},"t":40,"s":[33.918,6.407,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.952,0.971,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.027,0.021,0]},"t":41,"s":[48.539,53.893,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.843,1.001,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.114,-0.046,0]},"t":42,"s":[27.022,-9.839,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.415,0.929,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.177,0.001,0]},"t":43,"s":[36.118,31.361,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.042,0.504,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.069,-0.476,0]},"t":44,"s":[44.181,-10.557,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.979,0.874,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.028,0.1,0]},"t":45,"s":[-4.037,-4.307,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.945,0.884,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.027,0.248,0]},"t":46,"s":[68.255,26.661,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.863,0.961,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.164,0.298,0]},"t":47,"s":[13.766,42.343,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.171,-1.3,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.213,-0.072,0]},"t":48,"s":[32.121,48.441,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.938,0.952,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.056,0.086,0]},"t":49,"s":[43.941,45.171,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.907,0.82,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.238,-0.116,0]},"t":50,"s":[7.866,-41.801,100]},{"i":{"x":[0.833,0.833,0.833],"y":[-0.765,0.908,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.767,0.155,0]},"t":51,"s":[17.231,-5.411,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.963,0.772,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.087,0.843,0]},"t":52,"s":[18.373,36.907,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.047,0.926,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.066,0.131,0]},"t":53,"s":[41.417,41.547,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.142,1.256,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.03,-0.64,0]},"t":54,"s":[28.526,49.618,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.933,1.594,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.053,0.063,0]},"t":55,"s":[48.627,48.688,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.831,0.986,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.344,0.073,0]},"t":56,"s":[-5.742,52.477,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.959,1.011,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.164,-0.017,0]},"t":57,"s":[4.853,21.683,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.833,0.833,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.082,0.01,0]},"t":58,"s":[15.758,47.142,100]},{"t":59,"s":[10.26,18.203,100]}],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"d":1,"ty":"el","s":{"a":0,"k":[10,10],"ix":2},"p":{"a":0,"k":[0,0],"ix":3},"nm":"Ellipse Path 1","mn":"ADBE Vector Shape - Ellipse","hd":false},{"ty":"fl","c":{"a":0,"k":[0.3137254901960784,0.8901960784313725,0.7607843137254902,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Ellipse 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":4,"op":60,"st":4,"bm":0},{"ddd":0,"ind":27,"ty":4,"nm":"Shape Layer 25","sr":1,"ks":{"o":{"a":1,"k":[{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":10,"s":[0]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":15,"s":[100]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":37,"s":[100]},{"t":53,"s":[0]}],"ix":11},"r":{"a":0,"k":92,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.833,"y":0.59},"o":{"x":0.167,"y":0.167},"t":10,"s":[269.753,298.518,0],"to":[0.371,-0.157,0],"ti":[-1.794,0.84,0]},{"i":{"x":0.833,"y":0.764},"o":{"x":0.167,"y":0.105},"t":11,"s":[271.976,297.574,0],"to":[1.794,-0.84,0],"ti":[-3.913,2.158,0]},{"i":{"x":0.833,"y":0.824},"o":{"x":0.167,"y":0.129},"t":12,"s":[280.516,293.478,0],"to":[3.913,-2.158,0],"ti":[-5.148,3.28,0]},{"i":{"x":0.833,"y":0.844},"o":{"x":0.167,"y":0.158},"t":13,"s":[295.452,284.625,0],"to":[5.148,-3.28,0],"ti":[-4.973,3.376,0]},{"i":{"x":0.833,"y":0.847},"o":{"x":0.167,"y":0.179},"t":14,"s":[311.406,273.799,0],"to":[4.973,-3.376,0],"ti":[-4.286,2.798,0]},{"i":{"x":0.833,"y":0.847},"o":{"x":0.167,"y":0.184},"t":15,"s":[325.289,264.369,0],"to":[4.286,-2.798,0],"ti":[-3.685,2.155,0]},{"i":{"x":0.833,"y":0.846},"o":{"x":0.167,"y":0.183},"t":16,"s":[337.123,257.014,0],"to":[3.685,-2.155,0],"ti":[-3.222,1.625,0]},{"i":{"x":0.833,"y":0.845},"o":{"x":0.167,"y":0.181},"t":17,"s":[347.398,251.439,0],"to":[3.222,-1.625,0],"ti":[-2.85,1.213,0]},{"i":{"x":0.833,"y":0.844},"o":{"x":0.167,"y":0.18},"t":18,"s":[356.453,247.262,0],"to":[2.85,-1.213,0],"ti":[-2.541,0.896,0]},{"i":{"x":0.833,"y":0.843},"o":{"x":0.167,"y":0.179},"t":19,"s":[364.5,244.163,0],"to":[2.541,-0.896,0],"ti":[-2.276,0.656,0]},{"i":{"x":0.833,"y":0.842},"o":{"x":0.167,"y":0.178},"t":20,"s":[371.696,241.885,0],"to":[2.276,-0.656,0],"ti":[-2.047,0.474,0]},{"i":{"x":0.833,"y":0.842},"o":{"x":0.167,"y":0.177},"t":21,"s":[378.157,240.228,0],"to":[2.047,-0.474,0],"ti":[-1.845,0.337,0]},{"i":{"x":0.833,"y":0.842},"o":{"x":0.167,"y":0.176},"t":22,"s":[383.976,239.042,0],"to":[1.845,-0.337,0],"ti":[-1.666,0.235,0]},{"i":{"x":0.833,"y":0.842},"o":{"x":0.167,"y":0.176},"t":23,"s":[389.227,238.206,0],"to":[1.666,-0.235,0],"ti":[-1.505,0.161,0]},{"i":{"x":0.833,"y":0.841},"o":{"x":0.167,"y":0.176},"t":24,"s":[393.971,237.63,0],"to":[1.505,-0.161,0],"ti":[-1.361,0.108,0]},{"i":{"x":0.833,"y":0.841},"o":{"x":0.167,"y":0.176},"t":25,"s":[398.259,237.241,0],"to":[1.361,-0.108,0],"ti":[-1.229,0.074,0]},{"i":{"x":0.833,"y":0.841},"o":{"x":0.167,"y":0.176},"t":26,"s":[402.134,236.98,0],"to":[1.229,-0.074,0],"ti":[-1.111,0.051,0]},{"i":{"x":0.833,"y":0.841},"o":{"x":0.167,"y":0.176},"t":27,"s":[405.636,236.799,0],"to":[1.111,-0.051,0],"ti":[-1.01,0.021,0]},{"i":{"x":0.833,"y":0.84},"o":{"x":0.167,"y":0.175},"t":28,"s":[408.801,236.672,0],"to":[1.01,-0.021,0],"ti":[-0.926,-0.021,0]},{"i":{"x":0.833,"y":0.84},"o":{"x":0.167,"y":0.174},"t":29,"s":[411.696,236.673,0],"to":[0.926,0.021,0],"ti":[-0.853,-0.06,0]},{"i":{"x":0.833,"y":0.839},"o":{"x":0.167,"y":0.173},"t":30,"s":[414.357,236.801,0],"to":[0.853,0.06,0],"ti":[-0.787,-0.092,0]},{"i":{"x":0.833,"y":0.839},"o":{"x":0.167,"y":0.173},"t":31,"s":[416.812,237.033,0],"to":[0.787,0.092,0],"ti":[-0.729,-0.118,0]},{"i":{"x":0.833,"y":0.839},"o":{"x":0.167,"y":0.173},"t":32,"s":[419.082,237.352,0],"to":[0.729,0.118,0],"ti":[-0.677,-0.139,0]},{"i":{"x":0.833,"y":0.838},"o":{"x":0.167,"y":0.172},"t":33,"s":[421.188,237.741,0],"to":[0.677,0.139,0],"ti":[-0.631,-0.156,0]},{"i":{"x":0.833,"y":0.838},"o":{"x":0.167,"y":0.172},"t":34,"s":[423.146,238.187,0],"to":[0.631,0.156,0],"ti":[-0.589,-0.169,0]},{"i":{"x":0.833,"y":0.838},"o":{"x":0.167,"y":0.171},"t":35,"s":[424.973,238.678,0],"to":[0.589,0.169,0],"ti":[-0.552,-0.179,0]},{"i":{"x":0.833,"y":0.838},"o":{"x":0.167,"y":0.171},"t":36,"s":[426.682,239.204,0],"to":[0.552,0.179,0],"ti":[-0.518,-0.186,0]},{"i":{"x":0.833,"y":0.837},"o":{"x":0.167,"y":0.171},"t":37,"s":[428.285,239.754,0],"to":[0.518,0.186,0],"ti":[-0.488,-0.19,0]},{"i":{"x":0.833,"y":0.837},"o":{"x":0.167,"y":0.171},"t":38,"s":[429.793,240.32,0],"to":[0.488,0.19,0],"ti":[-0.46,-0.192,0]},{"i":{"x":0.833,"y":0.837},"o":{"x":0.167,"y":0.171},"t":39,"s":[431.212,240.895,0],"to":[0.46,0.192,0],"ti":[-0.433,-0.191,0]},{"i":{"x":0.833,"y":0.838},"o":{"x":0.167,"y":0.171},"t":40,"s":[432.55,241.471,0],"to":[0.433,0.191,0],"ti":[-0.408,-0.188,0]},{"i":{"x":0.833,"y":0.838},"o":{"x":0.167,"y":0.171},"t":41,"s":[433.811,242.041,0],"to":[0.408,0.188,0],"ti":[-0.384,-0.184,0]},{"i":{"x":0.833,"y":0.838},"o":{"x":0.167,"y":0.171},"t":42,"s":[434.998,242.6,0],"to":[0.384,0.184,0],"ti":[-0.36,-0.177,0]},{"i":{"x":0.833,"y":0.839},"o":{"x":0.167,"y":0.172},"t":43,"s":[436.114,243.143,0],"to":[0.36,0.177,0],"ti":[-0.337,-0.169,0]},{"i":{"x":0.833,"y":0.839},"o":{"x":0.167,"y":0.172},"t":44,"s":[437.16,243.663,0],"to":[0.337,0.169,0],"ti":[-0.314,-0.159,0]},{"i":{"x":0.833,"y":0.84},"o":{"x":0.167,"y":0.173},"t":45,"s":[438.136,244.156,0],"to":[0.314,0.159,0],"ti":[-0.29,-0.148,0]},{"i":{"x":0.833,"y":0.841},"o":{"x":0.167,"y":0.174},"t":46,"s":[439.041,244.617,0],"to":[0.29,0.148,0],"ti":[-0.265,-0.135,0]},{"i":{"x":0.833,"y":0.842},"o":{"x":0.167,"y":0.175},"t":47,"s":[439.873,245.043,0],"to":[0.265,0.135,0],"ti":[-0.239,-0.121,0]},{"i":{"x":0.833,"y":0.844},"o":{"x":0.167,"y":0.177},"t":48,"s":[440.629,245.429,0],"to":[0.239,0.121,0],"ti":[-0.211,-0.106,0]},{"i":{"x":0.833,"y":0.842},"o":{"x":0.167,"y":0.179},"t":49,"s":[441.306,245.771,0],"to":[0.211,0.106,0],"ti":[-0.188,-0.091,0]},{"i":{"x":0.833,"y":0.839},"o":{"x":0.167,"y":0.176},"t":50,"s":[441.897,246.067,0],"to":[0.188,0.091,0],"ti":[-0.176,-0.076,0]},{"i":{"x":0.833,"y":0.84},"o":{"x":0.167,"y":0.172},"t":51,"s":[442.436,246.318,0],"to":[0.176,0.076,0],"ti":[-0.167,-0.062,0]},{"i":{"x":0.833,"y":0.843},"o":{"x":0.167,"y":0.174},"t":52,"s":[442.952,246.526,0],"to":[0.167,0.062,0],"ti":[-0.154,-0.046,0]},{"i":{"x":0.833,"y":0.845},"o":{"x":0.167,"y":0.177},"t":53,"s":[443.435,246.688,0],"to":[0.154,0.046,0],"ti":[-0.137,-0.029,0]},{"i":{"x":0.833,"y":0.849},"o":{"x":0.167,"y":0.181},"t":54,"s":[443.874,246.8,0],"to":[0.137,0.029,0],"ti":[-0.117,-0.011,0]},{"i":{"x":0.833,"y":0.853},"o":{"x":0.167,"y":0.186},"t":55,"s":[444.258,246.86,0],"to":[0.117,0.011,0],"ti":[-0.092,0.008,0]},{"i":{"x":0.833,"y":0.85},"o":{"x":0.167,"y":0.192},"t":56,"s":[444.574,246.866,0],"to":[0.092,-0.008,0],"ti":[-0.065,0.028,0]},{"i":{"x":0.833,"y":0.809},"o":{"x":0.167,"y":0.188},"t":57,"s":[444.81,246.814,0],"to":[0.065,-0.028,0],"ti":[-0.052,0.051,0]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.148},"t":58,"s":[444.965,246.7,0],"to":[0.052,-0.051,0],"ti":[-0.027,0.032,0]},{"t":59,"s":[445.124,246.509,0]}],"ix":2,"l":2},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":1,"k":[{"i":{"x":[0.833,0.833,0.833],"y":[0.872,1.012,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.167,0.167,0]},"t":10,"s":[110.669,116.358,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.04,0.96,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.239,0.011,0]},"t":11,"s":[59.779,98.507,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.934,1.168,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.027,-0.078,0]},"t":12,"s":[32.558,118.992,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.283,0.983,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.327,0.056,0]},"t":13,"s":[72.99,108.43,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.904,0.749,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.064,-0.022,0]},"t":14,"s":[64.776,140.246,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.471,0.987,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.643,0.125,0]},"t":15,"s":[100.849,115.061,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.917,0.951,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.071,-0.015,0]},"t":16,"s":[106.224,64.521,100]},{"i":{"x":[0.833,0.833,0.833],"y":[7.839,0.661,1]},"o":{"x":[0.167,0.167,0.167],"y":[-10.254,-0.119,0]},"t":17,"s":[70.46,107.342,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.07,0.987,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.082,0.111,0]},"t":18,"s":[70.749,89.722,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.822,0.875,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.038,-0.015,0]},"t":19,"s":[46.798,35.679,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.002,0.943,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.157,0.249,0]},"t":20,"s":[90.882,81.249,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.942,1.169,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.002,-0.177,0]},"t":21,"s":[140.894,104.127,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.056,1.071,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.196,0.056,0]},"t":22,"s":[89.564,96.8,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.972,0.871,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.034,0.038,0]},"t":23,"s":[104.901,119.028,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.839,1.124,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.041,0.236,0]},"t":24,"s":[79.173,77.868,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.075,0.964,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.172,0.05,0]},"t":25,"s":[96.359,55.376,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.864,0.984,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.04,-0.064,0]},"t":26,"s":[112.425,111.326,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.964,0.96,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.216,-0.02,0]},"t":27,"s":[81.872,79.606,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.136,0.906,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.063,-0.076,0]},"t":28,"s":[62.674,105.212,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.04,0.329,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.052,0.758,0]},"t":29,"s":[73.611,91.797,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.897,0.991,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.027,0.095,0]},"t":30,"s":[44.883,90.141,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.864,1.007,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.445,-0.009,0]},"t":31,"s":[87.448,78.456,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.687,0.817,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.216,0.007,0]},"t":32,"s":[97.261,88.946,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.172,0.78,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.114,0.153,0]},"t":33,"s":[103.41,77.55,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.025,0.995,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.056,0.134,0]},"t":34,"s":[120.354,63.853,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.939,0.776,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.019,-0.005,0]},"t":35,"s":[68.478,41.315,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.781,1.002,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.226,0.133,0]},"t":36,"s":[135.85,62.489,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.836,0.898,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.135,0.002,0]},"t":37,"s":[117.729,98.108,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.955,0.732,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.17,0.45,0]},"t":38,"s":[88.261,61.749,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.161,0.879,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.097,0.121,0]},"t":39,"s":[59.775,53.492,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.92,1.01,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.055,0.268,0]},"t":40,"s":[72.912,35.197,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.014,1.011,1]},"o":{"x":[0.167,0.167,0.167],"y":[-1.932,0.009,0]},"t":41,"s":[34.35,26.941,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.166,0.799,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.091,0.01,0]},"t":42,"s":[35.945,36.212,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.01,1.008,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.055,0.142,0]},"t":43,"s":[53.22,25.739,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.967,0.804,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.009,0.007,0]},"t":44,"s":[1.613,10.927,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.916,0.919,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.054,0.145,0]},"t":45,"s":[59.191,27.12,100]},{"i":{"x":[0.833,0.833,0.833],"y":[32.334,1.954,1]},"o":{"x":[0.167,0.167,0.167],"y":[28.082,-2.647,0]},"t":46,"s":[24.376,49.1,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.963,1.605,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.083,0.077,0]},"t":47,"s":[24.272,48.429,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.911,0.914,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.066,0.073,0]},"t":48,"s":[63.337,56.778,100]},{"i":{"x":[0.833,0.833,0.833],"y":[-0.402,1.919,1]},"o":{"x":[0.167,0.167,0.167],"y":[1.325,2.783,0]},"t":49,"s":[41.555,-12.234,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.891,0.867,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.089,0.076,0]},"t":50,"s":[40.094,-14.365,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.348,0.976,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.357,0.224,0]},"t":51,"s":[16.969,11.25,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.893,1.059,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.067,-0.033,0]},"t":52,"s":[9.934,26.464,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.223,1.041,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.372,0.034,0]},"t":53,"s":[46.338,15.586,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.949,0.977,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.061,0.028,0]},"t":54,"s":[56.833,34.129,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.872,0.807,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.133,-0.032,0]},"t":55,"s":[18.206,6.413,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.098,0.917,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.239,0.147,0]},"t":56,"s":[33.108,26.401,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.855,-124.032,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.045,-45.948,0]},"t":57,"s":[41.083,52.674,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.833,0.833,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.195,0.083,0]},"t":58,"s":[23.746,52.627,100]},{"t":59,"s":[10.82,-18.691,100]}],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"d":1,"ty":"el","s":{"a":0,"k":[10,10],"ix":2},"p":{"a":0,"k":[0,0],"ix":3},"nm":"Ellipse Path 1","mn":"ADBE Vector Shape - Ellipse","hd":false},{"ty":"fl","c":{"a":0,"k":[1,0.10196078431372549,0.3803921568627451,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Ellipse 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":10,"op":60,"st":10,"bm":0},{"ddd":0,"ind":28,"ty":4,"nm":"Shape Layer 24","sr":1,"ks":{"o":{"a":1,"k":[{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":7,"s":[0]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":12,"s":[100]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":34,"s":[100]},{"t":50,"s":[0]}],"ix":11},"r":{"a":0,"k":92,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.833,"y":0.604},"o":{"x":0.167,"y":0.167},"t":7,"s":[265.04,301.431,0],"to":[-0.341,-0.143,0],"ti":[1.527,0.877,0]},{"i":{"x":0.833,"y":0.765},"o":{"x":0.167,"y":0.106},"t":8,"s":[262.993,300.574,0],"to":[-1.527,-0.877,0],"ti":[3.095,2.404,0]},{"i":{"x":0.833,"y":0.824},"o":{"x":0.167,"y":0.129},"t":9,"s":[255.881,296.17,0],"to":[-3.095,-2.404,0],"ti":[3.636,3.902,0]},{"i":{"x":0.833,"y":0.844},"o":{"x":0.167,"y":0.158},"t":10,"s":[244.425,286.149,0],"to":[-3.636,-3.902,0],"ti":[2.978,4.369,0]},{"i":{"x":0.833,"y":0.847},"o":{"x":0.167,"y":0.178},"t":11,"s":[234.064,272.758,0],"to":[-2.978,-4.369,0],"ti":[2.214,3.975,0]},{"i":{"x":0.833,"y":0.847},"o":{"x":0.167,"y":0.183},"t":12,"s":[226.554,259.936,0],"to":[-2.214,-3.975,0],"ti":[1.769,3.38,0]},{"i":{"x":0.833,"y":0.846},"o":{"x":0.167,"y":0.183},"t":13,"s":[220.781,248.906,0],"to":[-1.769,-3.38,0],"ti":[1.52,2.839,0]},{"i":{"x":0.833,"y":0.845},"o":{"x":0.167,"y":0.181},"t":14,"s":[215.943,239.657,0],"to":[-1.52,-2.839,0],"ti":[1.364,2.397,0]},{"i":{"x":0.833,"y":0.844},"o":{"x":0.167,"y":0.18},"t":15,"s":[211.658,231.874,0],"to":[-1.364,-2.397,0],"ti":[1.247,2.04,0]},{"i":{"x":0.833,"y":0.844},"o":{"x":0.167,"y":0.179},"t":16,"s":[207.761,225.273,0],"to":[-1.247,-2.04,0],"ti":[1.147,1.749,0]},{"i":{"x":0.833,"y":0.843},"o":{"x":0.167,"y":0.178},"t":17,"s":[204.179,219.632,0],"to":[-1.147,-1.749,0],"ti":[1.054,1.508,0]},{"i":{"x":0.833,"y":0.843},"o":{"x":0.167,"y":0.178},"t":18,"s":[200.881,214.781,0],"to":[-1.054,-1.508,0],"ti":[0.966,1.308,0]},{"i":{"x":0.833,"y":0.843},"o":{"x":0.167,"y":0.177},"t":19,"s":[197.852,210.583,0],"to":[-0.966,-1.308,0],"ti":[0.881,1.139,0]},{"i":{"x":0.833,"y":0.843},"o":{"x":0.167,"y":0.177},"t":20,"s":[195.083,206.935,0],"to":[-0.881,-1.139,0],"ti":[0.798,0.996,0]},{"i":{"x":0.833,"y":0.843},"o":{"x":0.167,"y":0.177},"t":21,"s":[192.565,203.748,0],"to":[-0.798,-0.996,0],"ti":[0.718,0.875,0]},{"i":{"x":0.833,"y":0.843},"o":{"x":0.167,"y":0.177},"t":22,"s":[190.293,200.956,0],"to":[-0.718,-0.875,0],"ti":[0.64,0.771,0]},{"i":{"x":0.833,"y":0.843},"o":{"x":0.167,"y":0.178},"t":23,"s":[188.258,198.5,0],"to":[-0.64,-0.771,0],"ti":[0.565,0.681,0]},{"i":{"x":0.833,"y":0.843},"o":{"x":0.167,"y":0.178},"t":24,"s":[186.453,196.332,0],"to":[-0.565,-0.681,0],"ti":[0.494,0.604,0]},{"i":{"x":0.833,"y":0.844},"o":{"x":0.167,"y":0.178},"t":25,"s":[184.867,194.413,0],"to":[-0.494,-0.604,0],"ti":[0.425,0.537,0]},{"i":{"x":0.833,"y":0.844},"o":{"x":0.167,"y":0.178},"t":26,"s":[183.491,192.71,0],"to":[-0.425,-0.537,0],"ti":[0.361,0.479,0]},{"i":{"x":0.833,"y":0.844},"o":{"x":0.167,"y":0.179},"t":27,"s":[182.314,191.193,0],"to":[-0.361,-0.479,0],"ti":[0.301,0.428,0]},{"i":{"x":0.833,"y":0.844},"o":{"x":0.167,"y":0.179},"t":28,"s":[181.325,189.838,0],"to":[-0.301,-0.428,0],"ti":[0.244,0.384,0]},{"i":{"x":0.833,"y":0.844},"o":{"x":0.167,"y":0.179},"t":29,"s":[180.511,188.625,0],"to":[-0.244,-0.384,0],"ti":[0.192,0.346,0]},{"i":{"x":0.833,"y":0.844},"o":{"x":0.167,"y":0.179},"t":30,"s":[179.859,187.534,0],"to":[-0.192,-0.346,0],"ti":[0.145,0.312,0]},{"i":{"x":0.833,"y":0.843},"o":{"x":0.167,"y":0.179},"t":31,"s":[179.357,186.551,0],"to":[-0.145,-0.312,0],"ti":[0.103,0.284,0]},{"i":{"x":0.833,"y":0.842},"o":{"x":0.167,"y":0.178},"t":32,"s":[178.99,185.66,0],"to":[-0.103,-0.284,0],"ti":[0.066,0.259,0]},{"i":{"x":0.833,"y":0.841},"o":{"x":0.167,"y":0.177},"t":33,"s":[178.741,184.85,0],"to":[-0.066,-0.259,0],"ti":[0.034,0.238,0]},{"i":{"x":0.833,"y":0.84},"o":{"x":0.167,"y":0.175},"t":34,"s":[178.596,184.107,0],"to":[-0.034,-0.238,0],"ti":[0.008,0.219,0]},{"i":{"x":0.833,"y":0.839},"o":{"x":0.167,"y":0.174},"t":35,"s":[178.536,183.424,0],"to":[-0.008,-0.219,0],"ti":[-0.011,0.204,0]},{"i":{"x":0.833,"y":0.838},"o":{"x":0.167,"y":0.172},"t":36,"s":[178.545,182.791,0],"to":[0.011,-0.204,0],"ti":[-0.025,0.191,0]},{"i":{"x":0.833,"y":0.837},"o":{"x":0.167,"y":0.171},"t":37,"s":[178.604,182.2,0],"to":[0.025,-0.191,0],"ti":[-0.033,0.18,0]},{"i":{"x":0.833,"y":0.837},"o":{"x":0.167,"y":0.171},"t":38,"s":[178.696,181.645,0],"to":[0.033,-0.18,0],"ti":[-0.033,0.171,0]},{"i":{"x":0.833,"y":0.837},"o":{"x":0.167,"y":0.171},"t":39,"s":[178.8,181.121,0],"to":[0.033,-0.171,0],"ti":[-0.027,0.163,0]},{"i":{"x":0.833,"y":0.837},"o":{"x":0.167,"y":0.171},"t":40,"s":[178.897,180.622,0],"to":[0.027,-0.163,0],"ti":[-0.014,0.157,0]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.17},"t":41,"s":[178.964,180.143,0],"to":[0.014,-0.157,0],"ti":[-0.002,0.154,0]},{"i":{"x":0.833,"y":0.829},"o":{"x":0.167,"y":0.167},"t":42,"s":[178.981,179.68,0],"to":[0.002,-0.154,0],"ti":[-0.011,0.158,0]},{"i":{"x":0.833,"y":0.83},"o":{"x":0.167,"y":0.162},"t":43,"s":[178.978,179.217,0],"to":[0.011,-0.158,0],"ti":[-0.033,0.162,0]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.164},"t":44,"s":[179.048,178.732,0],"to":[0.033,-0.162,0],"ti":[-0.051,0.161,0]},{"i":{"x":0.833,"y":0.835},"o":{"x":0.167,"y":0.166},"t":45,"s":[179.179,178.243,0],"to":[0.051,-0.161,0],"ti":[-0.064,0.155,0]},{"i":{"x":0.833,"y":0.837},"o":{"x":0.167,"y":0.169},"t":46,"s":[179.355,177.765,0],"to":[0.064,-0.155,0],"ti":[-0.069,0.148,0]},{"i":{"x":0.833,"y":0.838},"o":{"x":0.167,"y":0.17},"t":47,"s":[179.564,177.312,0],"to":[0.069,-0.148,0],"ti":[-0.061,0.142,0]},{"i":{"x":0.833,"y":0.84},"o":{"x":0.167,"y":0.172},"t":48,"s":[179.767,176.879,0],"to":[0.061,-0.142,0],"ti":[-0.046,0.136,0]},{"i":{"x":0.833,"y":0.843},"o":{"x":0.167,"y":0.174},"t":49,"s":[179.929,176.457,0],"to":[0.046,-0.136,0],"ti":[-0.028,0.126,0]},{"i":{"x":0.833,"y":0.845},"o":{"x":0.167,"y":0.177},"t":50,"s":[180.041,176.061,0],"to":[0.028,-0.126,0],"ti":[-0.009,0.113,0]},{"i":{"x":0.833,"y":0.845},"o":{"x":0.167,"y":0.18},"t":51,"s":[180.098,175.7,0],"to":[0.009,-0.113,0],"ti":[0.012,0.096,0]},{"i":{"x":0.833,"y":0.842},"o":{"x":0.167,"y":0.181},"t":52,"s":[180.095,175.385,0],"to":[-0.012,-0.096,0],"ti":[0.034,0.077,0]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.176},"t":53,"s":[180.027,175.124,0],"to":[-0.034,-0.077,0],"ti":[0.057,0.055,0]},{"i":{"x":0.833,"y":0.821},"o":{"x":0.167,"y":0.166},"t":54,"s":[179.891,174.924,0],"to":[-0.057,-0.055,0],"ti":[0.08,0.032,0]},{"i":{"x":0.833,"y":0.815},"o":{"x":0.167,"y":0.156},"t":55,"s":[179.687,174.792,0],"to":[-0.08,-0.032,0],"ti":[0.103,0.008,0]},{"i":{"x":0.833,"y":0.815},"o":{"x":0.167,"y":0.152},"t":56,"s":[179.413,174.732,0],"to":[-0.103,-0.008,0],"ti":[0.125,-0.018,0]},{"i":{"x":0.833,"y":0.817},"o":{"x":0.167,"y":0.152},"t":57,"s":[179.07,174.747,0],"to":[-0.125,0.018,0],"ti":[0.147,-0.043,0]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.153},"t":58,"s":[178.66,174.838,0],"to":[-0.147,0.043,0],"ti":[0.079,-0.028,0]},{"t":59,"s":[178.187,175.006,0]}],"ix":2,"l":2},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":1,"k":[{"i":{"x":[0.833,0.833,0.833],"y":[1.856,0.976,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.167,0.167,0]},"t":7,"s":[67.557,101.258,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.965,0.856,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.076,-0.033,0]},"t":8,"s":[62.081,60.567,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.899,1,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.062,0.197,0]},"t":9,"s":[123.801,89.604,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.652,0.91,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.485,0,0]},"t":10,"s":[88.344,110.822,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.902,1.594,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.11,1.18,0]},"t":11,"s":[80.981,89.674,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.494,0.831,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.569,0.073,0]},"t":12,"s":[57.576,88.067,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.815,1.117,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.071,0.164,0]},"t":13,"s":[53.56,101.12,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.035,0.99,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.152,0.049,0]},"t":14,"s":[81.37,114.583,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.939,0.9,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.025,-0.011,0]},"t":15,"s":[115.142,82.186,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.154,1.445,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.235,0.491,0]},"t":16,"s":[67.083,110.768,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.037,0.85,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.054,0.07,0]},"t":17,"s":[79.677,116.611,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.885,1.062,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.026,0.187,0]},"t":18,"s":[43.885,79.546,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.972,0.909,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.301,0.035,0]},"t":19,"s":[95.707,49.721,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.783,0.41,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.043,0.938,0]},"t":20,"s":[115.579,101.629,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.957,0.995,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.135,0.097,0]},"t":21,"s":[102.439,106.687,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.875,0.793,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.087,-0.005,0]},"t":22,"s":[81.407,137.444,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.844,0.981,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.25,0.14,0]},"t":23,"s":[91.687,108.575,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.4,1.01,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.178,-0.024,0]},"t":24,"s":[96.813,65.889,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.924,1.015,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.069,0.009,0]},"t":25,"s":[101.302,98.9,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.186,0.906,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.902,0.013,0]},"t":26,"s":[75.267,62.121,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.894,0.887,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.058,0.724,0]},"t":27,"s":[77.47,105.409,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.167,2.294,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.393,0.315,0]},"t":28,"s":[70.35,111.04,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.394,0.91,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.056,0.078,0]},"t":29,"s":[68.432,113.069,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.999,1.578,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.097,1.095,0]},"t":30,"s":[74.202,79.548,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.979,0.978,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.001,0.073,0]},"t":31,"s":[110.376,76.788,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.071,0.748,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.029,-0.029,0]},"t":32,"s":[74.587,98.699,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.005,0.895,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.038,0.125,0]},"t":33,"s":[101.235,82.443,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.897,1.096,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.004,0.401,0]},"t":34,"s":[51.784,49.555,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.916,0.982,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.43,0.045,0]},"t":35,"s":[103.982,40.922,100]},{"i":{"x":[0.833,0.833,0.833],"y":[22.316,1.143,1]},"o":{"x":[0.167,0.167,0.167],"y":[6.077,-0.023,0]},"t":36,"s":[116.519,59.525,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.868,1.051,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.083,0.053,0]},"t":37,"s":[116.693,44.996,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.915,0.931,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.226,0.031,0]},"t":38,"s":[71.936,84.414,100]},{"i":{"x":[0.833,0.833,0.833],"y":[-0.19,1.045,1]},"o":{"x":[0.167,0.167,0.167],"y":[4.525,-0.385,0]},"t":39,"s":[45.732,21.041,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.945,0.893,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.09,0.029,0]},"t":40,"s":[45.24,32.32,100]},{"i":{"x":[0.833,0.833,0.833],"y":[2.147,0.824,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.16,0.379,0]},"t":41,"s":[38.713,14.915,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.02,0.834,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.078,0.158,0]},"t":42,"s":[40.945,10.011,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.96,1.294,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.016,0.167,0]},"t":43,"s":[7.998,4.532,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.811,0.925,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.078,0.065,0]},"t":44,"s":[48.948,-0.907,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.977,2.089,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.149,-0.765,0]},"t":45,"s":[27.778,23.742,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.789,1.036,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.033,0.077,0]},"t":46,"s":[0.947,21.32,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.934,0.902,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.138,0.025,0]},"t":47,"s":[20.246,55.394,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.839,1.071,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.317,0.568,0]},"t":48,"s":[49.89,6.617,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.533,0.903,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.173,0.038,0]},"t":49,"s":[43.72,-1.776,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.939,1.723,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.101,0.578,0]},"t":50,"s":[37.975,13.785,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.517,1.043,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.234,0.075,0]},"t":51,"s":[11.499,16.406,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.963,0.978,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.101,0.028,0]},"t":52,"s":[18.462,-8.949,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.022,0.904,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.068,-0.031,0]},"t":53,"s":[51.821,29.36,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.996,1.698,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.017,0.617,0]},"t":54,"s":[33.392,1.359,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.888,0.865,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.004,0.074,0]},"t":55,"s":[56.645,-3.016,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.486,0.985,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.324,0.219,0]},"t":56,"s":[34.461,37.994,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.009,0.874,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.099,-0.018,0]},"t":57,"s":[26.772,63.226,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.833,0.833,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.008,0.247,0]},"t":58,"s":[-12.998,42.488,100]},{"t":59,"s":[31.14,31.954,100]}],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"d":1,"ty":"el","s":{"a":0,"k":[10,10],"ix":2},"p":{"a":0,"k":[0,0],"ix":3},"nm":"Ellipse Path 1","mn":"ADBE Vector Shape - Ellipse","hd":false},{"ty":"fl","c":{"a":0,"k":[0.3137254901960784,0.8901960784313725,0.7607843137254902,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Ellipse 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":7,"op":60,"st":7,"bm":0},{"ddd":0,"ind":29,"ty":4,"nm":"Shape Layer 23","sr":1,"ks":{"o":{"a":1,"k":[{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":5,"s":[0]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":10,"s":[100]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":32,"s":[100]},{"t":48,"s":[0]}],"ix":11},"r":{"a":0,"k":92,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.833,"y":0.598},"o":{"x":0.167,"y":0.167},"t":5,"s":[261.789,302.009,0],"to":[-0.181,-0.464,0],"ti":[0.71,2.293,0]},{"i":{"x":0.833,"y":0.765},"o":{"x":0.167,"y":0.105},"t":6,"s":[260.702,299.226,0],"to":[-0.71,-2.293,0],"ti":[1.003,5.268,0]},{"i":{"x":0.833,"y":0.824},"o":{"x":0.167,"y":0.129},"t":7,"s":[257.531,288.249,0],"to":[-1.003,-5.268,0],"ti":[0.104,7.272,0]},{"i":{"x":0.833,"y":0.844},"o":{"x":0.167,"y":0.158},"t":8,"s":[254.686,267.621,0],"to":[-0.104,-7.272,0],"ti":[-1.513,7.003,0]},{"i":{"x":0.833,"y":0.847},"o":{"x":0.167,"y":0.179},"t":9,"s":[256.909,244.618,0],"to":[1.513,-7.003,0],"ti":[-2.58,5.598,0]},{"i":{"x":0.833,"y":0.846},"o":{"x":0.167,"y":0.183},"t":10,"s":[263.763,225.603,0],"to":[2.58,-5.598,0],"ti":[-2.89,4.322,0]},{"i":{"x":0.833,"y":0.845},"o":{"x":0.167,"y":0.182},"t":11,"s":[272.387,211.032,0],"to":[2.89,-4.322,0],"ti":[-2.817,3.434,0]},{"i":{"x":0.833,"y":0.844},"o":{"x":0.167,"y":0.18},"t":12,"s":[281.103,199.67,0],"to":[2.817,-3.434,0],"ti":[-2.604,2.843,0]},{"i":{"x":0.833,"y":0.843},"o":{"x":0.167,"y":0.179},"t":13,"s":[289.287,190.426,0],"to":[2.604,-2.843,0],"ti":[-2.352,2.444,0]},{"i":{"x":0.833,"y":0.842},"o":{"x":0.167,"y":0.177},"t":14,"s":[296.729,182.612,0],"to":[2.352,-2.444,0],"ti":[-2.098,2.172,0]},{"i":{"x":0.833,"y":0.842},"o":{"x":0.167,"y":0.176},"t":15,"s":[303.402,175.762,0],"to":[2.098,-2.172,0],"ti":[-1.847,1.982,0]},{"i":{"x":0.833,"y":0.841},"o":{"x":0.167,"y":0.176},"t":16,"s":[309.316,169.581,0],"to":[1.847,-1.982,0],"ti":[-1.599,1.847,0]},{"i":{"x":0.833,"y":0.841},"o":{"x":0.167,"y":0.176},"t":17,"s":[314.483,163.871,0],"to":[1.599,-1.847,0],"ti":[-1.351,1.749,0]},{"i":{"x":0.833,"y":0.841},"o":{"x":0.167,"y":0.175},"t":18,"s":[318.909,158.499,0],"to":[1.351,-1.749,0],"ti":[-1.1,1.67,0]},{"i":{"x":0.833,"y":0.841},"o":{"x":0.167,"y":0.175},"t":19,"s":[322.589,153.379,0],"to":[1.1,-1.67,0],"ti":[-0.847,1.597,0]},{"i":{"x":0.833,"y":0.842},"o":{"x":0.167,"y":0.176},"t":20,"s":[325.509,148.477,0],"to":[0.847,-1.597,0],"ti":[-0.598,1.514,0]},{"i":{"x":0.833,"y":0.842},"o":{"x":0.167,"y":0.176},"t":21,"s":[327.669,143.798,0],"to":[0.598,-1.514,0],"ti":[-0.368,1.415,0]},{"i":{"x":0.833,"y":0.842},"o":{"x":0.167,"y":0.176},"t":22,"s":[329.098,139.39,0],"to":[0.368,-1.415,0],"ti":[-0.17,1.301,0]},{"i":{"x":0.833,"y":0.842},"o":{"x":0.167,"y":0.176},"t":23,"s":[329.877,135.307,0],"to":[0.17,-1.301,0],"ti":[-0.012,1.178,0]},{"i":{"x":0.833,"y":0.841},"o":{"x":0.167,"y":0.176},"t":24,"s":[330.12,131.586,0],"to":[0.012,-1.178,0],"ti":[0.108,1.057,0]},{"i":{"x":0.833,"y":0.841},"o":{"x":0.167,"y":0.176},"t":25,"s":[329.95,128.236,0],"to":[-0.108,-1.057,0],"ti":[0.197,0.942,0]},{"i":{"x":0.833,"y":0.841},"o":{"x":0.167,"y":0.175},"t":26,"s":[329.473,125.245,0],"to":[-0.197,-0.942,0],"ti":[0.245,0.841,0]},{"i":{"x":0.833,"y":0.841},"o":{"x":0.167,"y":0.175},"t":27,"s":[328.77,122.586,0],"to":[-0.245,-0.841,0],"ti":[0.252,0.756,0]},{"i":{"x":0.833,"y":0.841},"o":{"x":0.167,"y":0.175},"t":28,"s":[328.003,120.201,0],"to":[-0.252,-0.756,0],"ti":[0.241,0.68,0]},{"i":{"x":0.833,"y":0.842},"o":{"x":0.167,"y":0.175},"t":29,"s":[327.259,118.051,0],"to":[-0.241,-0.68,0],"ti":[0.227,0.611,0]},{"i":{"x":0.833,"y":0.842},"o":{"x":0.167,"y":0.176},"t":30,"s":[326.555,116.118,0],"to":[-0.227,-0.611,0],"ti":[0.211,0.546,0]},{"i":{"x":0.833,"y":0.842},"o":{"x":0.167,"y":0.176},"t":31,"s":[325.898,114.387,0],"to":[-0.211,-0.546,0],"ti":[0.196,0.484,0]},{"i":{"x":0.833,"y":0.843},"o":{"x":0.167,"y":0.177},"t":32,"s":[325.288,112.845,0],"to":[-0.196,-0.484,0],"ti":[0.183,0.426,0]},{"i":{"x":0.833,"y":0.843},"o":{"x":0.167,"y":0.177},"t":33,"s":[324.72,111.482,0],"to":[-0.183,-0.426,0],"ti":[0.173,0.371,0]},{"i":{"x":0.833,"y":0.844},"o":{"x":0.167,"y":0.178},"t":34,"s":[324.187,110.287,0],"to":[-0.173,-0.371,0],"ti":[0.164,0.318,0]},{"i":{"x":0.833,"y":0.845},"o":{"x":0.167,"y":0.179},"t":35,"s":[323.684,109.255,0],"to":[-0.164,-0.318,0],"ti":[0.159,0.267,0]},{"i":{"x":0.833,"y":0.845},"o":{"x":0.167,"y":0.18},"t":36,"s":[323.201,108.379,0],"to":[-0.159,-0.267,0],"ti":[0.156,0.217,0]},{"i":{"x":0.833,"y":0.845},"o":{"x":0.167,"y":0.18},"t":37,"s":[322.731,107.655,0],"to":[-0.156,-0.217,0],"ti":[0.154,0.168,0]},{"i":{"x":0.833,"y":0.844},"o":{"x":0.167,"y":0.181},"t":38,"s":[322.267,107.078,0],"to":[-0.154,-0.168,0],"ti":[0.155,0.121,0]},{"i":{"x":0.833,"y":0.841},"o":{"x":0.167,"y":0.179},"t":39,"s":[321.804,106.645,0],"to":[-0.155,-0.121,0],"ti":[0.157,0.075,0]},{"i":{"x":0.833,"y":0.835},"o":{"x":0.167,"y":0.175},"t":40,"s":[321.338,106.352,0],"to":[-0.157,-0.075,0],"ti":[0.16,0.031,0]},{"i":{"x":0.833,"y":0.83},"o":{"x":0.167,"y":0.169},"t":41,"s":[320.863,106.193,0],"to":[-0.16,-0.031,0],"ti":[0.165,-0.011,0]},{"i":{"x":0.833,"y":0.826},"o":{"x":0.167,"y":0.163},"t":42,"s":[320.376,106.164,0],"to":[-0.165,0.011,0],"ti":[0.169,-0.052,0]},{"i":{"x":0.833,"y":0.824},"o":{"x":0.167,"y":0.16},"t":43,"s":[319.875,106.261,0],"to":[-0.169,0.052,0],"ti":[0.172,-0.092,0]},{"i":{"x":0.833,"y":0.823},"o":{"x":0.167,"y":0.158},"t":44,"s":[319.363,106.479,0],"to":[-0.172,0.092,0],"ti":[0.18,-0.125,0]},{"i":{"x":0.833,"y":0.824},"o":{"x":0.167,"y":0.158},"t":45,"s":[318.841,106.814,0],"to":[-0.18,0.125,0],"ti":[0.197,-0.144,0]},{"i":{"x":0.833,"y":0.826},"o":{"x":0.167,"y":0.159},"t":46,"s":[318.282,107.227,0],"to":[-0.197,0.144,0],"ti":[0.217,-0.157,0]},{"i":{"x":0.833,"y":0.828},"o":{"x":0.167,"y":0.16},"t":47,"s":[317.66,107.68,0],"to":[-0.217,0.157,0],"ti":[0.233,-0.168,0]},{"i":{"x":0.833,"y":0.83},"o":{"x":0.167,"y":0.162},"t":48,"s":[316.983,108.169,0],"to":[-0.233,0.168,0],"ti":[0.246,-0.177,0]},{"i":{"x":0.833,"y":0.831},"o":{"x":0.167,"y":0.163},"t":49,"s":[316.262,108.687,0],"to":[-0.246,0.177,0],"ti":[0.254,-0.183,0]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.164},"t":50,"s":[315.509,109.229,0],"to":[-0.254,0.183,0],"ti":[0.259,-0.188,0]},{"i":{"x":0.833,"y":0.834},"o":{"x":0.167,"y":0.166},"t":51,"s":[314.735,109.788,0],"to":[-0.259,0.188,0],"ti":[0.258,-0.189,0]},{"i":{"x":0.833,"y":0.836},"o":{"x":0.167,"y":0.167},"t":52,"s":[313.957,110.355,0],"to":[-0.258,0.189,0],"ti":[0.251,-0.187,0]},{"i":{"x":0.833,"y":0.838},"o":{"x":0.167,"y":0.169},"t":53,"s":[313.19,110.921,0],"to":[-0.251,0.187,0],"ti":[0.238,-0.182,0]},{"i":{"x":0.833,"y":0.84},"o":{"x":0.167,"y":0.172},"t":54,"s":[312.452,111.477,0],"to":[-0.238,0.182,0],"ti":[0.219,-0.173,0]},{"i":{"x":0.833,"y":0.844},"o":{"x":0.167,"y":0.174},"t":55,"s":[311.761,112.011,0],"to":[-0.219,0.173,0],"ti":[0.192,-0.16,0]},{"i":{"x":0.833,"y":0.845},"o":{"x":0.167,"y":0.178},"t":56,"s":[311.139,112.513,0],"to":[-0.192,0.16,0],"ti":[0.164,-0.143,0]},{"i":{"x":0.833,"y":0.846},"o":{"x":0.167,"y":0.18},"t":57,"s":[310.606,112.97,0],"to":[-0.164,0.143,0],"ti":[0.139,-0.125,0]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.181},"t":58,"s":[310.154,113.373,0],"to":[-0.139,0.125,0],"ti":[0.064,-0.057,0]},{"t":59,"s":[309.77,113.717,0]}],"ix":2,"l":2},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":1,"k":[{"i":{"x":[0.833,0.833,0.833],"y":[0.657,0.977,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.167,0.167,0]},"t":5,"s":[122.178,94.449,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.995,0.673,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.11,-0.032,0]},"t":6,"s":[102.174,113.837,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.918,0.997,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.005,0.112,0]},"t":7,"s":[39.796,99.783,100]},{"i":{"x":[0.833,0.833,0.833],"y":[3.373,0.943,1]},"o":{"x":[0.167,0.167,0.167],"y":[-5.85,-0.003,0]},"t":8,"s":[98.461,58.708,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.007,0.913,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.081,-0.182,0]},"t":9,"s":[97.637,98.226,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.861,-0.461,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.006,2.089,0]},"t":10,"s":[121.922,85.826,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.953,1.17,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.208,0.088,0]},"t":11,"s":[95.646,85.311,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.37,0.913,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.109,0.056,0]},"t":12,"s":[78.043,76.794,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.019,1.331,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.096,1.985,0]},"t":13,"s":[85.681,102.643,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.951,1.177,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.016,0.067,0]},"t":14,"s":[135.804,103.775,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.984,0.896,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.12,0.057,0]},"t":15,"s":[74.18,98.145,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.067,2.296,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.019,0.418,0]},"t":16,"s":[99.49,115.703,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.975,0.941,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.037,0.078,0]},"t":17,"s":[78.946,120.079,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.978,0.68,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.036,-0.197,0]},"t":18,"s":[115.943,47.625,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.04,0.894,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.029,0.113,0]},"t":19,"s":[90.116,69.173,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.011,1.066,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.027,0.387,0]},"t":20,"s":[109.209,130.363,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.972,0.906,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.01,0.037,0]},"t":21,"s":[80.945,147.149,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.048,0.791,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.043,0.733,0]},"t":22,"s":[112.856,117.083,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.002,0.619,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.03,0.139,0]},"t":23,"s":[91.758,113.226,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.913,0.958,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.002,0.107,0]},"t":24,"s":[124.953,107.402,100]},{"i":{"x":[0.833,0.833,0.833],"y":[-0.03,0.669,1]},"o":{"x":[0.167,0.167,0.167],"y":[1.871,-0.086,0]},"t":25,"s":[90.896,86.565,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.06,0.86,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.091,0.111,0]},"t":26,"s":[89.309,96.814,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.994,1.197,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.035,0.207,0]},"t":27,"s":[71.266,127.218,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.064,0.909,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.007,0.059,0]},"t":28,"s":[102.251,147.752,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.023,1.152,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.036,1.009,0]},"t":29,"s":[73.55,78.754,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.89,0.917,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.018,0.054,0]},"t":30,"s":[124.448,72.546,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.937,-191.097,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.347,-188.615,0]},"t":31,"s":[59.508,90.091,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.122,0.759,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.254,0.083,0]},"t":32,"s":[39,90.083,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.937,0.922,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.092,0.127,0]},"t":33,"s":[44.062,72.23,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.05,0.825,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.262,-1.141,0]},"t":34,"s":[92.349,38.4,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.058,-0.83,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.031,0.159,0]},"t":35,"s":[80.711,40.702,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.872,0.959,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.034,0.087,0]},"t":36,"s":[99.332,43.228,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.945,0.925,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.238,-0.08,0]},"t":37,"s":[67.744,96.178,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.837,1.576,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.16,-0.761,0]},"t":38,"s":[50.721,69.109,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.787,0.792,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.171,0.073,0]},"t":39,"s":[56.555,71.779,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.453,0.836,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.137,0.139,0]},"t":40,"s":[62.129,50.654,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.984,0.889,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.07,0.169,0]},"t":41,"s":[70.779,19.084,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.921,1.022,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.02,0.334,0]},"t":42,"s":[15.062,-11.663,100]},{"i":{"x":[0.833,0.833,0.833],"y":[-0.296,0.99,1]},"o":{"x":[0.167,0.167,0.167],"y":[-1.554,0.018,0]},"t":43,"s":[59.871,-21.902,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.84,1.249,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.089,-0.012,0]},"t":44,"s":[57.59,-8.938,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.049,0.942,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.174,0.062,0]},"t":45,"s":[24.406,-20.323,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.912,0.841,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.031,-0.187,0]},"t":46,"s":[-6.116,25.019,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.989,1.089,1]},"o":{"x":[0.167,0.167,0.167],"y":[1.626,0.175,0]},"t":47,"s":[42.39,11.059,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.274,0.867,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.013,0.043,0]},"t":48,"s":[45.011,-1.68,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.577,1.034,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.064,0.225,0]},"t":49,"s":[42.735,24.617,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.975,0.934,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.073,0.024,0]},"t":50,"s":[52.491,40.139,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.91,0.866,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.036,-0.326,0]},"t":51,"s":[-24.813,18.208,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.149,1.734,1]},"o":{"x":[0.167,0.167,0.167],"y":[1.154,0.22,0]},"t":52,"s":[29.263,22.673,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.047,0.962,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.092,0.075,0]},"t":53,"s":[33.471,25.402,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.891,0.786,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.03,-0.07,0]},"t":54,"s":[72.245,-1.354,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.937,0.815,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.355,0.137,0]},"t":55,"s":[11.503,13.161,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.127,0.918,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.26,0.152,0]},"t":56,"s":[-7.15,35.893,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.908,-1.79,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.092,-4.527,0]},"t":57,"s":[-2.619,63.648,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.833,0.833,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.867,0.086,0]},"t":58,"s":[40.328,63.147,100]},{"t":59,"s":[44.897,46.852,100]}],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"d":1,"ty":"el","s":{"a":0,"k":[10,10],"ix":2},"p":{"a":0,"k":[0,0],"ix":3},"nm":"Ellipse Path 1","mn":"ADBE Vector Shape - Ellipse","hd":false},{"ty":"fl","c":{"a":0,"k":[1,0.10196078431372549,0.3803921568627451,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Ellipse 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":5,"op":60,"st":5,"bm":0},{"ddd":0,"ind":30,"ty":4,"nm":"Shape Layer 22","sr":1,"ks":{"o":{"a":1,"k":[{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":2,"s":[0]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":7,"s":[100]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":29,"s":[100]},{"t":45,"s":[0]}],"ix":11},"r":{"a":0,"k":92,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.833,"y":0.555},"o":{"x":0.167,"y":0.167},"t":2,"s":[263.182,299.465,0],"to":[-0.365,0.027,0],"ti":[1.949,-0.179,0]},{"i":{"x":0.833,"y":0.762},"o":{"x":0.167,"y":0.103},"t":3,"s":[260.99,299.63,0],"to":[-1.949,0.179,0],"ti":[4.498,-0.649,0]},{"i":{"x":0.833,"y":0.824},"o":{"x":0.167,"y":0.128},"t":4,"s":[251.489,300.54,0],"to":[-4.498,0.649,0],"ti":[6.073,-1.426,0]},{"i":{"x":0.833,"y":0.844},"o":{"x":0.167,"y":0.158},"t":5,"s":[234.001,303.526,0],"to":[-6.073,1.426,0],"ti":[5.777,-2.109,0]},{"i":{"x":0.833,"y":0.847},"o":{"x":0.167,"y":0.179},"t":6,"s":[215.049,309.096,0],"to":[-5.777,2.109,0],"ti":[4.639,-2.46,0]},{"i":{"x":0.833,"y":0.846},"o":{"x":0.167,"y":0.183},"t":7,"s":[199.338,316.18,0],"to":[-4.639,2.46,0],"ti":[3.563,-2.581,0]},{"i":{"x":0.833,"y":0.845},"o":{"x":0.167,"y":0.182},"t":8,"s":[187.213,323.857,0],"to":[-3.563,2.581,0],"ti":[2.716,-2.584,0]},{"i":{"x":0.833,"y":0.844},"o":{"x":0.167,"y":0.18},"t":9,"s":[177.957,331.668,0],"to":[-2.716,2.584,0],"ti":[2.065,-2.517,0]},{"i":{"x":0.833,"y":0.843},"o":{"x":0.167,"y":0.178},"t":10,"s":[170.915,339.358,0],"to":[-2.065,2.517,0],"ti":[1.564,-2.411,0]},{"i":{"x":0.833,"y":0.842},"o":{"x":0.167,"y":0.177},"t":11,"s":[165.569,346.772,0],"to":[-1.564,2.411,0],"ti":[1.165,-2.278,0]},{"i":{"x":0.833,"y":0.842},"o":{"x":0.167,"y":0.177},"t":12,"s":[161.531,353.821,0],"to":[-1.165,2.278,0],"ti":[0.843,-2.131,0]},{"i":{"x":0.833,"y":0.841},"o":{"x":0.167,"y":0.176},"t":13,"s":[158.58,360.44,0],"to":[-0.843,2.131,0],"ti":[0.594,-1.982,0]},{"i":{"x":0.833,"y":0.841},"o":{"x":0.167,"y":0.175},"t":14,"s":[156.475,366.609,0],"to":[-0.594,1.982,0],"ti":[0.404,-1.836,0]},{"i":{"x":0.833,"y":0.84},"o":{"x":0.167,"y":0.175},"t":15,"s":[155.019,372.333,0],"to":[-0.404,1.836,0],"ti":[0.261,-1.696,0]},{"i":{"x":0.833,"y":0.84},"o":{"x":0.167,"y":0.174},"t":16,"s":[154.052,377.626,0],"to":[-0.261,1.696,0],"ti":[0.155,-1.565,0]},{"i":{"x":0.833,"y":0.84},"o":{"x":0.167,"y":0.174},"t":17,"s":[153.453,382.511,0],"to":[-0.155,1.565,0],"ti":[0.078,-1.441,0]},{"i":{"x":0.833,"y":0.84},"o":{"x":0.167,"y":0.174},"t":18,"s":[153.124,387.013,0],"to":[-0.078,1.441,0],"ti":[0.024,-1.327,0]},{"i":{"x":0.833,"y":0.84},"o":{"x":0.167,"y":0.174},"t":19,"s":[152.987,391.16,0],"to":[-0.024,1.327,0],"ti":[-0.012,-1.222,0]},{"i":{"x":0.833,"y":0.84},"o":{"x":0.167,"y":0.174},"t":20,"s":[152.981,394.977,0],"to":[0.012,1.222,0],"ti":[-0.034,-1.124,0]},{"i":{"x":0.833,"y":0.84},"o":{"x":0.167,"y":0.174},"t":21,"s":[153.059,398.49,0],"to":[0.034,1.124,0],"ti":[-0.044,-1.033,0]},{"i":{"x":0.833,"y":0.84},"o":{"x":0.167,"y":0.174},"t":22,"s":[153.183,401.72,0],"to":[0.044,1.033,0],"ti":[-0.045,-0.949,0]},{"i":{"x":0.833,"y":0.84},"o":{"x":0.167,"y":0.174},"t":23,"s":[153.322,404.688,0],"to":[0.045,0.949,0],"ti":[-0.039,-0.87,0]},{"i":{"x":0.833,"y":0.84},"o":{"x":0.167,"y":0.174},"t":24,"s":[153.452,407.413,0],"to":[0.039,0.87,0],"ti":[-0.028,-0.797,0]},{"i":{"x":0.833,"y":0.841},"o":{"x":0.167,"y":0.174},"t":25,"s":[153.556,409.91,0],"to":[0.028,0.797,0],"ti":[-0.013,-0.729,0]},{"i":{"x":0.833,"y":0.841},"o":{"x":0.167,"y":0.175},"t":26,"s":[153.62,412.195,0],"to":[0.013,0.729,0],"ti":[0.005,-0.666,0]},{"i":{"x":0.833,"y":0.841},"o":{"x":0.167,"y":0.175},"t":27,"s":[153.633,414.284,0],"to":[-0.005,0.666,0],"ti":[0.025,-0.607,0]},{"i":{"x":0.833,"y":0.841},"o":{"x":0.167,"y":0.175},"t":28,"s":[153.589,416.19,0],"to":[-0.025,0.607,0],"ti":[0.045,-0.552,0]},{"i":{"x":0.833,"y":0.841},"o":{"x":0.167,"y":0.175},"t":29,"s":[153.484,417.926,0],"to":[-0.045,0.552,0],"ti":[0.066,-0.501,0]},{"i":{"x":0.833,"y":0.841},"o":{"x":0.167,"y":0.175},"t":30,"s":[153.316,419.502,0],"to":[-0.066,0.501,0],"ti":[0.086,-0.452,0]},{"i":{"x":0.833,"y":0.841},"o":{"x":0.167,"y":0.175},"t":31,"s":[153.086,420.929,0],"to":[-0.086,0.452,0],"ti":[0.105,-0.407,0]},{"i":{"x":0.833,"y":0.84},"o":{"x":0.167,"y":0.175},"t":32,"s":[152.797,422.216,0],"to":[-0.105,0.407,0],"ti":[0.122,-0.364,0]},{"i":{"x":0.833,"y":0.84},"o":{"x":0.167,"y":0.174},"t":33,"s":[152.454,423.37,0],"to":[-0.122,0.364,0],"ti":[0.137,-0.324,0]},{"i":{"x":0.833,"y":0.84},"o":{"x":0.167,"y":0.174},"t":34,"s":[152.063,424.4,0],"to":[-0.137,0.324,0],"ti":[0.149,-0.286,0]},{"i":{"x":0.833,"y":0.84},"o":{"x":0.167,"y":0.174},"t":35,"s":[151.631,425.314,0],"to":[-0.149,0.286,0],"ti":[0.157,-0.251,0]},{"i":{"x":0.833,"y":0.841},"o":{"x":0.167,"y":0.174},"t":36,"s":[151.17,426.118,0],"to":[-0.157,0.251,0],"ti":[0.161,-0.217,0]},{"i":{"x":0.833,"y":0.841},"o":{"x":0.167,"y":0.175},"t":37,"s":[150.689,426.818,0],"to":[-0.161,0.217,0],"ti":[0.161,-0.186,0]},{"i":{"x":0.833,"y":0.842},"o":{"x":0.167,"y":0.175},"t":38,"s":[150.202,427.422,0],"to":[-0.161,0.186,0],"ti":[0.156,-0.157,0]},{"i":{"x":0.833,"y":0.844},"o":{"x":0.167,"y":0.177},"t":39,"s":[149.722,427.936,0],"to":[-0.156,0.157,0],"ti":[0.145,-0.13,0]},{"i":{"x":0.833,"y":0.848},"o":{"x":0.167,"y":0.179},"t":40,"s":[149.266,428.364,0],"to":[-0.145,0.13,0],"ti":[0.13,-0.103,0]},{"i":{"x":0.833,"y":0.85},"o":{"x":0.167,"y":0.184},"t":41,"s":[148.849,428.714,0],"to":[-0.13,0.103,0],"ti":[0.112,-0.075,0]},{"i":{"x":0.833,"y":0.85},"o":{"x":0.167,"y":0.187},"t":42,"s":[148.487,428.98,0],"to":[-0.112,0.075,0],"ti":[0.095,-0.052,0]},{"i":{"x":0.833,"y":0.853},"o":{"x":0.167,"y":0.188},"t":43,"s":[148.176,429.161,0],"to":[-0.095,0.052,0],"ti":[0.076,-0.036,0]},{"i":{"x":0.833,"y":0.858},"o":{"x":0.167,"y":0.193},"t":44,"s":[147.92,429.292,0],"to":[-0.076,0.036,0],"ti":[0.058,-0.02,0]},{"i":{"x":0.833,"y":0.864},"o":{"x":0.167,"y":0.202},"t":45,"s":[147.718,429.375,0],"to":[-0.058,0.02,0],"ti":[0.041,-0.005,0]},{"i":{"x":0.833,"y":0.858},"o":{"x":0.167,"y":0.214},"t":46,"s":[147.57,429.411,0],"to":[-0.041,0.005,0],"ti":[0.024,0.009,0]},{"i":{"x":0.833,"y":0.811},"o":{"x":0.167,"y":0.201},"t":47,"s":[147.473,429.404,0],"to":[-0.024,-0.009,0],"ti":[0.007,0.022,0]},{"i":{"x":0.833,"y":0.791},"o":{"x":0.167,"y":0.149},"t":48,"s":[147.427,429.356,0],"to":[-0.007,-0.022,0],"ti":[-0.008,0.035,0]},{"i":{"x":0.833,"y":0.803},"o":{"x":0.167,"y":0.139},"t":49,"s":[147.429,429.27,0],"to":[0.008,-0.035,0],"ti":[-0.023,0.046,0]},{"i":{"x":0.833,"y":0.812},"o":{"x":0.167,"y":0.144},"t":50,"s":[147.476,429.149,0],"to":[0.023,-0.046,0],"ti":[-0.037,0.056,0]},{"i":{"x":0.833,"y":0.817},"o":{"x":0.167,"y":0.15},"t":51,"s":[147.567,428.995,0],"to":[0.037,-0.056,0],"ti":[-0.05,0.065,0]},{"i":{"x":0.833,"y":0.821},"o":{"x":0.167,"y":0.153},"t":52,"s":[147.698,428.811,0],"to":[0.05,-0.065,0],"ti":[-0.061,0.073,0]},{"i":{"x":0.833,"y":0.824},"o":{"x":0.167,"y":0.156},"t":53,"s":[147.866,428.602,0],"to":[0.061,-0.073,0],"ti":[-0.072,0.08,0]},{"i":{"x":0.833,"y":0.826},"o":{"x":0.167,"y":0.158},"t":54,"s":[148.067,428.371,0],"to":[0.072,-0.08,0],"ti":[-0.081,0.086,0]},{"i":{"x":0.833,"y":0.828},"o":{"x":0.167,"y":0.16},"t":55,"s":[148.297,428.121,0],"to":[0.081,-0.086,0],"ti":[-0.088,0.09,0]},{"i":{"x":0.833,"y":0.83},"o":{"x":0.167,"y":0.162},"t":56,"s":[148.552,427.857,0],"to":[0.088,-0.09,0],"ti":[-0.094,0.093,0]},{"i":{"x":0.833,"y":0.831},"o":{"x":0.167,"y":0.163},"t":57,"s":[148.827,427.581,0],"to":[0.094,-0.093,0],"ti":[-0.099,0.094,0]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.165},"t":58,"s":[149.118,427.299,0],"to":[0.099,-0.094,0],"ti":[-0.05,0.047,0]},{"t":59,"s":[149.419,427.014,0]}],"ix":2,"l":2},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":1,"k":[{"i":{"x":[0.833,0.833,0.833],"y":[0.986,0.936,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.167,0.167,0]},"t":2,"s":[78.01,99.254,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.926,0.531,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.017,-0.276,0]},"t":3,"s":[128.233,72.965,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.337,0.874,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.687,0.101,0]},"t":4,"s":[86.694,79.056,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.838,1.122,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.067,0.245,0]},"t":5,"s":[91.188,107.271,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.041,0.842,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.172,0.05,0]},"t":6,"s":[68.543,121.799,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.872,0.956,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.027,0.177,0]},"t":7,"s":[47.195,85.924,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.787,0.859,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.24,-0.094,0]},"t":8,"s":[79.057,53.992,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.016,0.785,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.137,0.203,0]},"t":9,"s":[95.971,69,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.952,0.823,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.013,0.136,0]},"t":10,"s":[122.24,79.447,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.768,0.977,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.111,0.157,0]},"t":11,"s":[91.043,95.99,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.026,0.806,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.13,-0.032,0]},"t":12,"s":[104.449,114.638,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.962,1.057,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.02,0.146,0]},"t":13,"s":[128.392,101.122,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.99,0.907,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.071,0.034,0]},"t":14,"s":[96.902,83.222,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.026,1.705,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.011,0.803,0]},"t":15,"s":[113.87,113.437,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.96,0.836,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.02,0.075,0]},"t":16,"s":[98.911,116.933,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.845,1.094,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.077,0.169,0]},"t":17,"s":[118.545,83.843,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.612,0.887,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.18,0.044,0]},"t":18,"s":[108.334,51.632,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.07,1.117,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.106,0.317,0]},"t":19,"s":[99.536,120.079,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.987,0.942,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.038,0.049,0]},"t":20,"s":[67.373,144.525,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.993,0.851,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.016,-0.19,0]},"t":21,"s":[126.372,85.891,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.911,1.01,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.008,0.19,0]},"t":22,"s":[76.632,103.785,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.779,0.97,1]},"o":{"x":[0.167,0.167,0.167],"y":[1.349,0.009,0]},"t":23,"s":[122.04,117.809,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.843,1.086,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.075,-0.046,0]},"t":24,"s":[125.029,102.081,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.135,1.161,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.177,0.042,0]},"t":25,"s":[94.09,112.18,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.981,0.983,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.052,0.055,0]},"t":26,"s":[66.677,91.705,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.946,0.885,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.024,-0.021,0]},"t":27,"s":[138.63,151.835,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.1,0.917,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.153,0.304,0]},"t":28,"s":[82.794,103.616,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.825,23.963,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.045,-14.676,0]},"t":29,"s":[102.519,85.419,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.959,0.97,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.159,0.083,0]},"t":30,"s":[59.163,85.522,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.781,1.06,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.081,-0.046,0]},"t":31,"s":[11.254,57.108,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.97,0.941,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.134,0.035,0]},"t":32,"s":[35.57,75.366,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.91,0.974,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.047,-0.198,0]},"t":33,"s":[75.263,44.021,100]},{"i":{"x":[0.833,0.833,0.833],"y":[-0.16,1.441,1]},"o":{"x":[0.167,0.167,0.167],"y":[1.081,-0.038,0]},"t":34,"s":[49.845,53.322,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.019,0.947,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.09,0.07,0]},"t":35,"s":[47.721,46.924,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.937,0.949,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.016,-0.142,0]},"t":36,"s":[20.292,87.156,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.992,1.01,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.252,-0.131,0]},"t":37,"s":[54.089,72.277,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.683,0.671,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.008,0.009,0]},"t":38,"s":[45.702,78.056,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.156,0.664,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.113,0.112,0]},"t":39,"s":[53.33,71.575,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.955,0.899,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.054,0.111,0]},"t":40,"s":[74.741,52.442,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.831,0.987,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.096,0.48,0]},"t":41,"s":[13.206,-5.553,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.979,1.051,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.164,-0.015,0]},"t":42,"s":[41.869,-17.736,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.774,1.016,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.027,0.032,0]},"t":43,"s":[71.33,-7.435,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.967,0.832,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.132,0.013,0]},"t":44,"s":[49.137,-24.086,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.882,0.938,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.056,0.165,0]},"t":45,"s":[11.219,-4.255,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.917,0.797,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.282,-0.239,0]},"t":46,"s":[33.923,16.004,100]},{"i":{"x":[0.833,0.833,0.833],"y":[26.628,1.515,1]},"o":{"x":[0.167,0.167,0.167],"y":[-41.355,0.141,0]},"t":47,"s":[43.431,10.768,100]},{"i":{"x":[0.833,0.833,0.833],"y":[2.023,0.936,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.083,0.072,0]},"t":48,"s":[43.412,3.234,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.937,0.76,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.077,-0.285,0]},"t":49,"s":[49.311,57.286,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.757,0.888,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.255,0.128,0]},"t":50,"s":[-29.011,45.06,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.807,1.116,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.127,0.328,0]},"t":51,"s":[-9.692,22.004,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.012,0.927,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.147,0.048,0]},"t":52,"s":[27.268,14.157,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.85,0.798,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.01,-0.607,0]},"t":53,"s":[75.93,32.89,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.972,0.796,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.187,0.142,0]},"t":54,"s":[20.351,30.629,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.765,1.805,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.041,0.141,0]},"t":55,"s":[-24.176,27.418,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.956,0.931,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.129,0.076,0]},"t":56,"s":[5.634,22.754,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.972,0.674,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.092,-0.394,0]},"t":57,"s":[59.79,72.494,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.833,0.833,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.043,0.112,0]},"t":58,"s":[34.087,63.804,100]},{"t":59,"s":[51.017,38.534,100]}],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"d":1,"ty":"el","s":{"a":0,"k":[10,10],"ix":2},"p":{"a":0,"k":[0,0],"ix":3},"nm":"Ellipse Path 1","mn":"ADBE Vector Shape - Ellipse","hd":false},{"ty":"fl","c":{"a":0,"k":[1,0.10196078431372549,0.3803921568627451,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Ellipse 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":2,"op":60,"st":2,"bm":0},{"ddd":0,"ind":31,"ty":4,"nm":"Shape Layer 21","sr":1,"ks":{"o":{"a":1,"k":[{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":8,"s":[0]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":13,"s":[100]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":35,"s":[100]},{"t":51,"s":[0]}],"ix":11},"r":{"a":0,"k":92,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.833,"y":0.611},"o":{"x":0.167,"y":0.167},"t":8,"s":[268.269,306.17,0],"to":[0.003,0.494,0],"ti":[-0.124,-2.305,0]},{"i":{"x":0.833,"y":0.766},"o":{"x":0.167,"y":0.106},"t":9,"s":[268.288,309.137,0],"to":[0.124,2.305,0],"ti":[-0.683,-5.046,0]},{"i":{"x":0.833,"y":0.825},"o":{"x":0.167,"y":0.129},"t":10,"s":[269.014,319.997,0],"to":[0.683,5.046,0],"ti":[-1.89,-6.606,0]},{"i":{"x":0.833,"y":0.844},"o":{"x":0.167,"y":0.159},"t":11,"s":[272.384,339.414,0],"to":[1.89,6.606,0],"ti":[-3.289,-5.823,0]},{"i":{"x":0.833,"y":0.848},"o":{"x":0.167,"y":0.179},"t":12,"s":[280.355,359.634,0],"to":[3.289,5.823,0],"ti":[-4.111,-3.921,0]},{"i":{"x":0.833,"y":0.847},"o":{"x":0.167,"y":0.184},"t":13,"s":[292.117,374.353,0],"to":[4.111,3.921,0],"ti":[-4.169,-2.298,0]},{"i":{"x":0.833,"y":0.846},"o":{"x":0.167,"y":0.183},"t":14,"s":[305.021,383.162,0],"to":[4.169,2.298,0],"ti":[-3.808,-1.326,0]},{"i":{"x":0.833,"y":0.845},"o":{"x":0.167,"y":0.181},"t":15,"s":[317.131,388.142,0],"to":[3.808,1.326,0],"ti":[-3.36,-0.831,0]},{"i":{"x":0.833,"y":0.844},"o":{"x":0.167,"y":0.18},"t":16,"s":[327.87,391.116,0],"to":[3.36,0.831,0],"ti":[-2.951,-0.601,0]},{"i":{"x":0.833,"y":0.843},"o":{"x":0.167,"y":0.179},"t":17,"s":[337.294,393.129,0],"to":[2.951,0.601,0],"ti":[-2.599,-0.51,0]},{"i":{"x":0.833,"y":0.842},"o":{"x":0.167,"y":0.178},"t":18,"s":[345.577,394.723,0],"to":[2.599,0.51,0],"ti":[-2.295,-0.495,0]},{"i":{"x":0.833,"y":0.842},"o":{"x":0.167,"y":0.177},"t":19,"s":[352.885,396.19,0],"to":[2.295,0.495,0],"ti":[-2.032,-0.524,0]},{"i":{"x":0.833,"y":0.841},"o":{"x":0.167,"y":0.176},"t":20,"s":[359.349,397.694,0],"to":[2.032,0.524,0],"ti":[-1.8,-0.577,0]},{"i":{"x":0.833,"y":0.841},"o":{"x":0.167,"y":0.175},"t":21,"s":[365.075,399.331,0],"to":[1.8,0.577,0],"ti":[-1.589,-0.643,0]},{"i":{"x":0.833,"y":0.84},"o":{"x":0.167,"y":0.175},"t":22,"s":[370.148,401.158,0],"to":[1.589,0.643,0],"ti":[-1.392,-0.71,0]},{"i":{"x":0.833,"y":0.84},"o":{"x":0.167,"y":0.174},"t":23,"s":[374.61,403.19,0],"to":[1.392,0.71,0],"ti":[-1.21,-0.771,0]},{"i":{"x":0.833,"y":0.839},"o":{"x":0.167,"y":0.174},"t":24,"s":[378.501,405.417,0],"to":[1.21,0.771,0],"ti":[-1.047,-0.823,0]},{"i":{"x":0.833,"y":0.839},"o":{"x":0.167,"y":0.173},"t":25,"s":[381.873,407.817,0],"to":[1.047,0.823,0],"ti":[-0.897,-0.852,0]},{"i":{"x":0.833,"y":0.841},"o":{"x":0.167,"y":0.173},"t":26,"s":[384.78,410.354,0],"to":[0.897,0.852,0],"ti":[-0.758,-0.847,0]},{"i":{"x":0.833,"y":0.841},"o":{"x":0.167,"y":0.175},"t":27,"s":[387.255,412.931,0],"to":[0.758,0.847,0],"ti":[-0.635,-0.817,0]},{"i":{"x":0.833,"y":0.841},"o":{"x":0.167,"y":0.175},"t":28,"s":[389.33,415.438,0],"to":[0.635,0.817,0],"ti":[-0.53,-0.777,0]},{"i":{"x":0.833,"y":0.841},"o":{"x":0.167,"y":0.175},"t":29,"s":[391.063,417.835,0],"to":[0.53,0.777,0],"ti":[-0.442,-0.729,0]},{"i":{"x":0.833,"y":0.841},"o":{"x":0.167,"y":0.175},"t":30,"s":[392.509,420.098,0],"to":[0.442,0.729,0],"ti":[-0.37,-0.677,0]},{"i":{"x":0.833,"y":0.842},"o":{"x":0.167,"y":0.176},"t":31,"s":[393.717,422.209,0],"to":[0.37,0.677,0],"ti":[-0.311,-0.623,0]},{"i":{"x":0.833,"y":0.842},"o":{"x":0.167,"y":0.176},"t":32,"s":[394.73,424.158,0],"to":[0.311,0.623,0],"ti":[-0.263,-0.569,0]},{"i":{"x":0.833,"y":0.842},"o":{"x":0.167,"y":0.176},"t":33,"s":[395.584,425.945,0],"to":[0.263,0.569,0],"ti":[-0.224,-0.517,0]},{"i":{"x":0.833,"y":0.842},"o":{"x":0.167,"y":0.176},"t":34,"s":[396.309,427.572,0],"to":[0.224,0.517,0],"ti":[-0.192,-0.467,0]},{"i":{"x":0.833,"y":0.842},"o":{"x":0.167,"y":0.177},"t":35,"s":[396.928,429.046,0],"to":[0.192,0.467,0],"ti":[-0.166,-0.419,0]},{"i":{"x":0.833,"y":0.843},"o":{"x":0.167,"y":0.177},"t":36,"s":[397.461,430.372,0],"to":[0.166,0.419,0],"ti":[-0.144,-0.373,0]},{"i":{"x":0.833,"y":0.843},"o":{"x":0.167,"y":0.177},"t":37,"s":[397.921,431.558,0],"to":[0.144,0.373,0],"ti":[-0.125,-0.33,0]},{"i":{"x":0.833,"y":0.844},"o":{"x":0.167,"y":0.178},"t":38,"s":[398.322,432.612,0],"to":[0.125,0.33,0],"ti":[-0.11,-0.29,0]},{"i":{"x":0.833,"y":0.844},"o":{"x":0.167,"y":0.179},"t":39,"s":[398.672,433.54,0],"to":[0.11,0.29,0],"ti":[-0.096,-0.253,0]},{"i":{"x":0.833,"y":0.845},"o":{"x":0.167,"y":0.179},"t":40,"s":[398.98,434.351,0],"to":[0.096,0.253,0],"ti":[-0.085,-0.219,0]},{"i":{"x":0.833,"y":0.846},"o":{"x":0.167,"y":0.18},"t":41,"s":[399.251,435.056,0],"to":[0.085,0.219,0],"ti":[-0.075,-0.187,0]},{"i":{"x":0.833,"y":0.847},"o":{"x":0.167,"y":0.181},"t":42,"s":[399.491,435.663,0],"to":[0.075,0.187,0],"ti":[-0.066,-0.157,0]},{"i":{"x":0.833,"y":0.848},"o":{"x":0.167,"y":0.183},"t":43,"s":[399.701,436.178,0],"to":[0.066,0.157,0],"ti":[-0.058,-0.129,0]},{"i":{"x":0.833,"y":0.851},"o":{"x":0.167,"y":0.185},"t":44,"s":[399.886,436.606,0],"to":[0.058,0.129,0],"ti":[-0.05,-0.103,0]},{"i":{"x":0.833,"y":0.854},"o":{"x":0.167,"y":0.188},"t":45,"s":[400.047,436.953,0],"to":[0.05,0.103,0],"ti":[-0.042,-0.078,0]},{"i":{"x":0.833,"y":0.858},"o":{"x":0.167,"y":0.194},"t":46,"s":[400.184,437.223,0],"to":[0.042,0.078,0],"ti":[-0.035,-0.055,0]},{"i":{"x":0.833,"y":0.847},"o":{"x":0.167,"y":0.203},"t":47,"s":[400.3,437.422,0],"to":[0.035,0.055,0],"ti":[-0.029,-0.04,0]},{"i":{"x":0.833,"y":0.824},"o":{"x":0.167,"y":0.183},"t":48,"s":[400.393,437.552,0],"to":[0.029,0.04,0],"ti":[-0.025,-0.04,0]},{"i":{"x":0.833,"y":0.824},"o":{"x":0.167,"y":0.158},"t":49,"s":[400.471,437.661,0],"to":[0.025,0.04,0],"ti":[-0.022,-0.048,0]},{"i":{"x":0.833,"y":0.824},"o":{"x":0.167,"y":0.158},"t":50,"s":[400.541,437.792,0],"to":[0.022,0.048,0],"ti":[-0.019,-0.055,0]},{"i":{"x":0.833,"y":0.825},"o":{"x":0.167,"y":0.159},"t":51,"s":[400.602,437.946,0],"to":[0.019,0.055,0],"ti":[-0.015,-0.062,0]},{"i":{"x":0.833,"y":0.826},"o":{"x":0.167,"y":0.159},"t":52,"s":[400.653,438.122,0],"to":[0.015,0.062,0],"ti":[-0.012,-0.069,0]},{"i":{"x":0.833,"y":0.827},"o":{"x":0.167,"y":0.16},"t":53,"s":[400.694,438.32,0],"to":[0.012,0.069,0],"ti":[-0.008,-0.076,0]},{"i":{"x":0.833,"y":0.827},"o":{"x":0.167,"y":0.16},"t":54,"s":[400.724,438.538,0],"to":[0.008,0.076,0],"ti":[-0.004,-0.082,0]},{"i":{"x":0.833,"y":0.823},"o":{"x":0.167,"y":0.161},"t":55,"s":[400.743,438.775,0],"to":[0.004,0.082,0],"ti":[0.001,-0.09,0]},{"i":{"x":0.833,"y":0.821},"o":{"x":0.167,"y":0.157},"t":56,"s":[400.751,439.03,0],"to":[-0.001,0.09,0],"ti":[0.01,-0.102,0]},{"i":{"x":0.833,"y":0.826},"o":{"x":0.167,"y":0.156},"t":57,"s":[400.737,439.316,0],"to":[-0.01,0.102,0],"ti":[0.019,-0.113,0]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.16},"t":58,"s":[400.693,439.644,0],"to":[-0.019,0.113,0],"ti":[0.011,-0.059,0]},{"t":59,"s":[400.624,439.996,0]}],"ix":2,"l":2},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":1,"k":[{"i":{"x":[0.833,0.833,0.833],"y":[-5.266,0.476,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.167,0.167,0]},"t":8,"s":[101.238,48.218,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.994,0.881,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.084,0.099,0]},"t":9,"s":[100.863,56.49,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.87,1.004,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.006,0.279,0]},"t":10,"s":[73.006,100.241,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.943,0.716,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.233,0.004,0]},"t":11,"s":[98.954,118.86,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.692,0.993,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.185,0.118,0]},"t":12,"s":[113.422,99.365,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.962,0.95,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.114,-0.008,0]},"t":13,"s":[108.921,52.336,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.185,0.915,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.07,-0.128,0]},"t":14,"s":[96.758,95.263,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.076,1.566,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.057,3.934,0]},"t":15,"s":[103.361,78.328,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.937,0.67,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.04,0.073,0]},"t":16,"s":[82.062,77.962,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.435,0.477,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.254,0.111,0]},"t":17,"s":[122.901,80.818,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.986,1.01,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.098,0.099,0]},"t":18,"s":[112.827,89.288,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.908,0.891,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.016,0.009,0]},"t":19,"s":[54.614,133.963,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.977,1.123,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.871,0.351,0]},"t":20,"s":[103.222,83.744,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.079,0.913,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.032,0.05,0]},"t":21,"s":[108.364,68.125,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.812,-0.393,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.04,2.172,0]},"t":22,"s":[104.662,106.75,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.481,1.162,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.15,0.089,0]},"t":23,"s":[111.862,108.291,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.008,0.968,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.071,0.055,0]},"t":24,"s":[120.907,132.514,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.948,0.892,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.007,-0.052,0]},"t":25,"s":[59.613,61.164,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.83,1.094,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.141,0.365,0]},"t":26,"s":[126.782,105.209,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.965,1.033,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.163,0.044,0]},"t":27,"s":[101.882,118.255,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.119,1.091,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.059,0.024,0]},"t":28,"s":[75.979,90.498,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.983,0.951,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.049,0.043,0]},"t":29,"s":[91.099,129.32,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.873,0.878,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.022,-0.118,0]},"t":30,"s":[54.42,48.147,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.036,0.911,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.244,0.263,0]},"t":31,"s":[83.4,81.807,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.914,2.819,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.025,1.369,0]},"t":32,"s":[98.473,97.441,100]},{"i":{"x":[0.833,0.833,0.833],"y":[-4.522,1.004,1]},"o":{"x":[0.167,0.167,0.167],"y":[3.172,0.08,0]},"t":33,"s":[76.975,98.454,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.977,1.005,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.085,0.004,0]},"t":34,"s":[76.395,75.312,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.837,0.918,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.032,0.005,0]},"t":35,"s":[38.536,99.649,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.978,2.101,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.171,-4.786,0]},"t":36,"s":[65.888,73.73,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.051,1.151,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.03,0.077,0]},"t":37,"s":[91.971,74.174,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.063,1.136,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.032,0.054,0]},"t":38,"s":[72.715,67.868,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.933,0.911,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.036,0.052,0]},"t":39,"s":[103.785,85.634,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.65,1.416,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.353,1.245,0]},"t":40,"s":[49.165,38.912,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.946,0.946,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.109,0.069,0]},"t":41,"s":[59.594,35.561,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.678,0.628,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.154,-0.156,0]},"t":42,"s":[92.977,55.629,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.84,0.983,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.112,0.107,0]},"t":43,"s":[81.252,48.654,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.985,0.899,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.174,-0.021,0]},"t":44,"s":[47.617,24.458,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.939,1.627,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.018,0.474,0]},"t":45,"s":[16.653,43.696,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.962,0.982,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.225,0.074,0]},"t":46,"s":[42.187,47.804,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.962,0.963,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.07,-0.023,0]},"t":47,"s":[35.289,12.804,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.877,0.815,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.077,-0.066,0]},"t":48,"s":[39.035,40.12,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.027,0.867,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.257,0.151,0]},"t":49,"s":[-7.944,24.911,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.741,1.341,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.02,0.223,0]},"t":50,"s":[-30.445,6.276,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.947,0.894,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.123,0.067,0]},"t":51,"s":[-0.773,-4.844,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.692,0.982,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.147,0.394,0]},"t":52,"s":[61.907,51.792,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.975,0.468,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.114,-0.024,0]},"t":53,"s":[39.231,67.006,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.829,0.968,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.035,0.099,0]},"t":54,"s":[-21.899,55.157,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.981,0.881,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.163,-0.053,0]},"t":55,"s":[21.199,-8.63,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.959,1.022,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.025,0.276,0]},"t":56,"s":[66.363,30.41,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.943,0.882,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.08,0.018,0]},"t":57,"s":[31.738,47.268,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.833,0.833,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.182,0.283,0]},"t":58,"s":[49.437,25.928,100]},{"t":59,"s":[43.876,17.029,100]}],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"d":1,"ty":"el","s":{"a":0,"k":[10,10],"ix":2},"p":{"a":0,"k":[0,0],"ix":3},"nm":"Ellipse Path 1","mn":"ADBE Vector Shape - Ellipse","hd":false},{"ty":"fl","c":{"a":0,"k":[1,0.10196078431372549,0.3803921568627451,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Ellipse 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":8,"op":60,"st":8,"bm":0},{"ddd":0,"ind":32,"ty":4,"nm":"Shape Layer 20","sr":1,"ks":{"o":{"a":1,"k":[{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":6,"s":[0]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":11,"s":[100]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":33,"s":[100]},{"t":49,"s":[0]}],"ix":11},"r":{"a":0,"k":0,"ix":10,"x":"var $bm_rt;\n$bm_rt = $bm_mul(time, 300);"},"p":{"a":1,"k":[{"i":{"x":0.833,"y":0.574},"o":{"x":0.167,"y":0.167},"t":6,"s":[273.987,258.212,0],"to":[-0.226,0.261,0],"ti":[1.235,-1.262,0]},{"i":{"x":0.833,"y":0.763},"o":{"x":0.167,"y":0.104},"t":7,"s":[272.634,259.781,0],"to":[-1.235,1.262,0],"ti":[3.172,-2.472,0]},{"i":{"x":0.833,"y":0.824},"o":{"x":0.167,"y":0.129},"t":8,"s":[266.579,265.787,0],"to":[-3.172,2.472,0],"ti":[4.928,-2.338,0]},{"i":{"x":0.833,"y":0.844},"o":{"x":0.167,"y":0.158},"t":9,"s":[253.605,274.613,0],"to":[-4.928,2.338,0],"ti":[5.281,-0.789,0]},{"i":{"x":0.833,"y":0.847},"o":{"x":0.167,"y":0.179},"t":10,"s":[237.011,279.814,0],"to":[-5.281,0.789,0],"ti":[4.495,0.772,0]},{"i":{"x":0.833,"y":0.846},"o":{"x":0.167,"y":0.183},"t":11,"s":[221.916,279.349,0],"to":[-4.495,-0.772,0],"ti":[3.459,1.674,0]},{"i":{"x":0.833,"y":0.845},"o":{"x":0.167,"y":0.182},"t":12,"s":[210.041,275.183,0],"to":[-3.459,-1.674,0],"ti":[2.573,2.049,0]},{"i":{"x":0.833,"y":0.844},"o":{"x":0.167,"y":0.18},"t":13,"s":[201.165,269.307,0],"to":[-2.573,-2.049,0],"ti":[1.908,2.132,0]},{"i":{"x":0.833,"y":0.843},"o":{"x":0.167,"y":0.179},"t":14,"s":[194.6,262.891,0],"to":[-1.908,-2.132,0],"ti":[1.43,2.079,0]},{"i":{"x":0.833,"y":0.842},"o":{"x":0.167,"y":0.177},"t":15,"s":[189.714,256.512,0],"to":[-1.43,-2.079,0],"ti":[1.088,1.968,0]},{"i":{"x":0.833,"y":0.842},"o":{"x":0.167,"y":0.176},"t":16,"s":[186.021,250.417,0],"to":[-1.088,-1.968,0],"ti":[0.842,1.835,0]},{"i":{"x":0.833,"y":0.841},"o":{"x":0.167,"y":0.176},"t":17,"s":[183.185,244.707,0],"to":[-0.842,-1.835,0],"ti":[0.663,1.699,0]},{"i":{"x":0.833,"y":0.841},"o":{"x":0.167,"y":0.175},"t":18,"s":[180.968,239.407,0],"to":[-0.663,-1.699,0],"ti":[0.532,1.568,0]},{"i":{"x":0.833,"y":0.841},"o":{"x":0.167,"y":0.175},"t":19,"s":[179.205,234.511,0],"to":[-0.532,-1.568,0],"ti":[0.434,1.443,0]},{"i":{"x":0.833,"y":0.841},"o":{"x":0.167,"y":0.175},"t":20,"s":[177.777,230,0],"to":[-0.434,-1.443,0],"ti":[0.361,1.326,0]},{"i":{"x":0.833,"y":0.841},"o":{"x":0.167,"y":0.175},"t":21,"s":[176.6,225.853,0],"to":[-0.361,-1.326,0],"ti":[0.305,1.218,0]},{"i":{"x":0.833,"y":0.841},"o":{"x":0.167,"y":0.175},"t":22,"s":[175.612,222.043,0],"to":[-0.305,-1.218,0],"ti":[0.263,1.116,0]},{"i":{"x":0.833,"y":0.841},"o":{"x":0.167,"y":0.175},"t":23,"s":[174.767,218.548,0],"to":[-0.263,-1.116,0],"ti":[0.231,1.022,0]},{"i":{"x":0.833,"y":0.841},"o":{"x":0.167,"y":0.175},"t":24,"s":[174.033,215.345,0],"to":[-0.231,-1.022,0],"ti":[0.205,0.934,0]},{"i":{"x":0.833,"y":0.841},"o":{"x":0.167,"y":0.175},"t":25,"s":[173.383,212.416,0],"to":[-0.205,-0.934,0],"ti":[0.185,0.851,0]},{"i":{"x":0.833,"y":0.841},"o":{"x":0.167,"y":0.175},"t":26,"s":[172.801,209.743,0],"to":[-0.185,-0.851,0],"ti":[0.169,0.773,0]},{"i":{"x":0.833,"y":0.841},"o":{"x":0.167,"y":0.175},"t":27,"s":[172.273,207.311,0],"to":[-0.169,-0.773,0],"ti":[0.155,0.7,0]},{"i":{"x":0.833,"y":0.842},"o":{"x":0.167,"y":0.176},"t":28,"s":[171.79,205.104,0],"to":[-0.155,-0.7,0],"ti":[0.143,0.63,0]},{"i":{"x":0.833,"y":0.842},"o":{"x":0.167,"y":0.176},"t":29,"s":[171.345,203.111,0],"to":[-0.143,-0.63,0],"ti":[0.131,0.565,0]},{"i":{"x":0.833,"y":0.843},"o":{"x":0.167,"y":0.177},"t":30,"s":[170.934,201.322,0],"to":[-0.131,-0.565,0],"ti":[0.12,0.503,0]},{"i":{"x":0.833,"y":0.843},"o":{"x":0.167,"y":0.177},"t":31,"s":[170.557,199.723,0],"to":[-0.12,-0.503,0],"ti":[0.109,0.444,0]},{"i":{"x":0.833,"y":0.844},"o":{"x":0.167,"y":0.178},"t":32,"s":[170.213,198.306,0],"to":[-0.109,-0.444,0],"ti":[0.097,0.389,0]},{"i":{"x":0.833,"y":0.845},"o":{"x":0.167,"y":0.179},"t":33,"s":[169.903,197.059,0],"to":[-0.097,-0.389,0],"ti":[0.085,0.336,0]},{"i":{"x":0.833,"y":0.846},"o":{"x":0.167,"y":0.18},"t":34,"s":[169.629,195.974,0],"to":[-0.085,-0.336,0],"ti":[0.07,0.287,0]},{"i":{"x":0.833,"y":0.848},"o":{"x":0.167,"y":0.182},"t":35,"s":[169.396,195.042,0],"to":[-0.07,-0.287,0],"ti":[0.055,0.24,0]},{"i":{"x":0.833,"y":0.851},"o":{"x":0.167,"y":0.185},"t":36,"s":[169.206,194.254,0],"to":[-0.055,-0.24,0],"ti":[0.037,0.195,0]},{"i":{"x":0.833,"y":0.854},"o":{"x":0.167,"y":0.188},"t":37,"s":[169.067,193.604,0],"to":[-0.037,-0.195,0],"ti":[0.018,0.153,0]},{"i":{"x":0.833,"y":0.856},"o":{"x":0.167,"y":0.193},"t":38,"s":[168.983,193.083,0],"to":[-0.018,-0.153,0],"ti":[-0.004,0.114,0]},{"i":{"x":0.833,"y":0.855},"o":{"x":0.167,"y":0.199},"t":39,"s":[168.961,192.685,0],"to":[0.004,-0.114,0],"ti":[-0.028,0.076,0]},{"i":{"x":0.833,"y":0.852},"o":{"x":0.167,"y":0.195},"t":40,"s":[169.007,192.402,0],"to":[0.028,-0.076,0],"ti":[-0.046,0.036,0]},{"i":{"x":0.833,"y":0.821},"o":{"x":0.167,"y":0.19},"t":41,"s":[169.128,192.228,0],"to":[0.046,-0.036,0],"ti":[-0.055,-0.006,0]},{"i":{"x":0.833,"y":0.805},"o":{"x":0.167,"y":0.156},"t":42,"s":[169.284,192.183,0],"to":[0.055,0.006,0],"ti":[-0.06,-0.042,0]},{"i":{"x":0.833,"y":0.812},"o":{"x":0.167,"y":0.145},"t":43,"s":[169.457,192.261,0],"to":[0.06,0.042,0],"ti":[-0.063,-0.073,0]},{"i":{"x":0.833,"y":0.819},"o":{"x":0.167,"y":0.15},"t":44,"s":[169.642,192.438,0],"to":[0.063,0.073,0],"ti":[-0.065,-0.097,0]},{"i":{"x":0.833,"y":0.831},"o":{"x":0.167,"y":0.154},"t":45,"s":[169.834,192.698,0],"to":[0.065,0.097,0],"ti":[-0.066,-0.11,0]},{"i":{"x":0.833,"y":0.84},"o":{"x":0.167,"y":0.164},"t":46,"s":[170.032,193.022,0],"to":[0.066,0.11,0],"ti":[-0.066,-0.107,0]},{"i":{"x":0.833,"y":0.842},"o":{"x":0.167,"y":0.173},"t":47,"s":[170.231,193.359,0],"to":[0.066,0.107,0],"ti":[-0.066,-0.094,0]},{"i":{"x":0.833,"y":0.845},"o":{"x":0.167,"y":0.176},"t":48,"s":[170.43,193.661,0],"to":[0.066,0.094,0],"ti":[-0.064,-0.077,0]},{"i":{"x":0.833,"y":0.847},"o":{"x":0.167,"y":0.18},"t":49,"s":[170.626,193.92,0],"to":[0.064,0.077,0],"ti":[-0.062,-0.058,0]},{"i":{"x":0.833,"y":0.849},"o":{"x":0.167,"y":0.183},"t":50,"s":[170.816,194.125,0],"to":[0.062,0.058,0],"ti":[-0.059,-0.037,0]},{"i":{"x":0.833,"y":0.845},"o":{"x":0.167,"y":0.185},"t":51,"s":[170.999,194.269,0],"to":[0.059,0.037,0],"ti":[-0.056,-0.014,0]},{"i":{"x":0.833,"y":0.832},"o":{"x":0.167,"y":0.18},"t":52,"s":[171.172,194.347,0],"to":[0.056,0.014,0],"ti":[-0.052,0.01,0]},{"i":{"x":0.833,"y":0.816},"o":{"x":0.167,"y":0.165},"t":53,"s":[171.335,194.353,0],"to":[0.052,-0.01,0],"ti":[-0.048,0.036,0]},{"i":{"x":0.833,"y":0.811},"o":{"x":0.167,"y":0.153},"t":54,"s":[171.485,194.284,0],"to":[0.048,-0.036,0],"ti":[-0.043,0.061,0]},{"i":{"x":0.833,"y":0.812},"o":{"x":0.167,"y":0.149},"t":55,"s":[171.621,194.138,0],"to":[0.043,-0.061,0],"ti":[-0.038,0.087,0]},{"i":{"x":0.833,"y":0.816},"o":{"x":0.167,"y":0.15},"t":56,"s":[171.743,193.915,0],"to":[0.038,-0.087,0],"ti":[-0.033,0.112,0]},{"i":{"x":0.833,"y":0.819},"o":{"x":0.167,"y":0.152},"t":57,"s":[171.851,193.615,0],"to":[0.033,-0.112,0],"ti":[-0.028,0.137,0]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.154},"t":58,"s":[171.943,193.241,0],"to":[0.028,-0.137,0],"ti":[-0.013,0.074,0]},{"t":59,"s":[172.02,192.796,0]}],"ix":2,"l":2},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":1,"k":[{"i":{"x":[0.833,0.833,0.833],"y":[1.05,0.926,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.167,0.167,0]},"t":6,"s":[113.755,94.294,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.873,0.226,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.031,-0.701,0]},"t":7,"s":[142.324,42.5,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.888,0.932,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.241,0.093,0]},"t":8,"s":[96.482,48.003,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.204,0.806,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.325,-0.366,0]},"t":9,"s":[72.3,93.591,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.921,0.58,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.059,0.146,0]},"t":10,"s":[63.951,85.144,100]},{"i":{"x":[0.833,0.833,0.833],"y":[-0.459,0.97,1]},"o":{"x":[0.167,0.167,0.167],"y":[-1.529,0.104,0]},"t":11,"s":[92.765,73.936,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.995,0.876,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.088,-0.048,0]},"t":12,"s":[91.276,28.604,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.992,1.032,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.006,0.254,0]},"t":13,"s":[66.684,57.392,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.995,1.013,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.008,0.023,0]},"t":14,"s":[89.679,71.451,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.731,0.864,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.005,0.012,0]},"t":15,"s":[68.777,51.94,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.035,0.702,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.121,0.214,0]},"t":16,"s":[88.421,74.596,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.935,0.98,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.025,0.116,0]},"t":17,"s":[132.23,89.048,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.575,0.845,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.305,-0.027,0]},"t":18,"s":[69.974,126.315,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.931,0.98,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.104,0.18,0]},"t":19,"s":[83.338,98.135,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.313,0.783,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.391,-0.026,0]},"t":20,"s":[138.117,73.778,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.993,0.847,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.095,0.135,0]},"t":21,"s":[128.488,92.265,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.953,1.145,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.008,0.183,0]},"t":22,"s":[58.681,121.955,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.815,0.916,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.109,0.053,0]},"t":23,"s":[122.512,146.761,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.991,5.116,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.152,17.285,0]},"t":24,"s":[94.854,78.682,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.02,1.177,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.01,0.082,0]},"t":25,"s":[61.077,78.352,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.963,1.017,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.016,0.057,0]},"t":26,"s":[91.084,94.97,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.849,0.969,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.068,0.014,0]},"t":27,"s":[53.825,43.095,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.888,0.893,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.186,-0.051,0]},"t":28,"s":[74.356,105.62,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.814,1.146,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.324,0.371,0]},"t":29,"s":[91.09,66.721,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.498,0.904,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.151,0.053,0]},"t":30,"s":[96.876,55.445,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.992,1.823,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.071,0.645,0]},"t":31,"s":[104.026,86.476,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.986,0.931,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.009,0.076,0]},"t":32,"s":[54.137,91.081,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.906,0.944,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.017,-0.393,0]},"t":33,"s":[99.353,40.985,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.666,0.653,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.764,-0.167,0]},"t":34,"s":[61.794,49.744,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.993,0.855,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.074,0.11,0]},"t":35,"s":[57.194,46.834,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.011,1.186,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.008,0.195,0]},"t":36,"s":[98.549,37.625,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.007,1.031,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.009,0.058,0]},"t":37,"s":[60.683,30.783,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.882,0.935,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.007,0.023,0]},"t":38,"s":[103.351,52.891,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.098,0.657,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.282,-0.288,0]},"t":39,"s":[56.948,22.459,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.953,0.83,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.045,0.11,0]},"t":40,"s":[37.48,29.284,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.913,0.986,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.106,0.164,0]},"t":41,"s":[79.938,50.562,100]},{"i":{"x":[0.833,0.833,0.833],"y":[-5.816,1.008,1]},"o":{"x":[0.167,0.167,0.167],"y":[1.804,-0.017,0]},"t":42,"s":[61.287,72.597,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.946,1.042,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.084,0.008,0]},"t":43,"s":[60.383,54.282,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.87,0.901,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.155,0.028,0]},"t":44,"s":[-12.609,74.447,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.795,0.974,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.231,0.53,0]},"t":45,"s":[12.933,44.106,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.094,1.748,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.141,-0.037,0]},"t":46,"s":[27.371,38.441,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.924,0.881,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.044,0.075,0]},"t":47,"s":[48.387,42.359,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.231,0.927,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.898,0.279,0]},"t":48,"s":[3.703,3.258,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.188,-0.77,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.061,-0.58,0]},"t":49,"s":[7.498,-13.404,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.899,0.864,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.058,0.087,0]},"t":50,"s":[-6.825,-11.313,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.382,0.894,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.485,0.214,0]},"t":51,"s":[39.727,31.017,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.967,1.572,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.068,0.386,0]},"t":52,"s":[49.384,58.012,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.795,0.914,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.055,0.073,0]},"t":53,"s":[-4.528,65.442,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.999,1.691,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.141,3.173,0]},"t":54,"s":[27.852,6.982,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.947,0.907,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.001,0.074,0]},"t":55,"s":[75.027,5.405,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.867,0.463,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.146,0.773,0]},"t":56,"s":[28.477,20.053,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.164,1.39,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.224,0.099,0]},"t":57,"s":[45.412,21.822,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.833,0.833,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.055,0.069,0]},"t":58,"s":[55.436,31.442,100]},{"t":59,"s":[25.653,-23.168,100]}],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"sr","sy":2,"d":1,"pt":{"a":0,"k":3,"ix":3},"p":{"a":0,"k":[6,32],"ix":4},"r":{"a":0,"k":0,"ix":5},"or":{"a":0,"k":6.599,"ix":7},"os":{"a":0,"k":0,"ix":9},"ix":1,"nm":"Polystar Path 1","mn":"ADBE Vector Shape - Star","hd":false},{"ty":"fl","c":{"a":0,"k":[0.3137254901960784,0.8901960784313725,0.7607843137254902,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[-7.382,-25.677],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Polystar 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":6,"op":60,"st":6,"bm":0},{"ddd":0,"ind":33,"ty":4,"nm":"Shape Layer 19","sr":1,"ks":{"o":{"a":1,"k":[{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":11,"s":[0]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":16,"s":[100]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":38,"s":[100]},{"t":54,"s":[0]}],"ix":11},"r":{"a":0,"k":0,"ix":10,"x":"var $bm_rt;\n$bm_rt = $bm_mul(time, 300);"},"p":{"a":1,"k":[{"i":{"x":0.833,"y":0.51},"o":{"x":0.167,"y":0.167},"t":11,"s":[273.717,253.598,0],"to":[0.28,-0.028,0],"ti":[-1.664,-0.082,0]},{"i":{"x":0.833,"y":0.759},"o":{"x":0.167,"y":0.1},"t":12,"s":[275.395,253.428,0],"to":[1.664,0.082,0],"ti":[-3.922,-0.767,0]},{"i":{"x":0.833,"y":0.823},"o":{"x":0.167,"y":0.127},"t":13,"s":[283.7,254.091,0],"to":[3.922,0.767,0],"ti":[-5.158,-1.975,0]},{"i":{"x":0.833,"y":0.844},"o":{"x":0.167,"y":0.158},"t":14,"s":[298.929,258.033,0],"to":[5.158,1.975,0],"ti":[-4.6,-2.946,0]},{"i":{"x":0.833,"y":0.847},"o":{"x":0.167,"y":0.179},"t":15,"s":[314.649,265.939,0],"to":[4.6,2.946,0],"ti":[-3.328,-3.296,0]},{"i":{"x":0.833,"y":0.846},"o":{"x":0.167,"y":0.183},"t":16,"s":[326.529,275.709,0],"to":[3.328,3.296,0],"ti":[-2.233,-3.252,0]},{"i":{"x":0.833,"y":0.845},"o":{"x":0.167,"y":0.182},"t":17,"s":[334.618,285.714,0],"to":[2.233,3.252,0],"ti":[-1.455,-3.046,0]},{"i":{"x":0.833,"y":0.843},"o":{"x":0.167,"y":0.18},"t":18,"s":[339.929,295.223,0],"to":[1.455,3.046,0],"ti":[-0.931,-2.79,0]},{"i":{"x":0.833,"y":0.843},"o":{"x":0.167,"y":0.178},"t":19,"s":[343.35,303.988,0],"to":[0.931,2.79,0],"ti":[-0.582,-2.533,0]},{"i":{"x":0.833,"y":0.842},"o":{"x":0.167,"y":0.177},"t":20,"s":[345.513,311.964,0],"to":[0.582,2.533,0],"ti":[-0.353,-2.293,0]},{"i":{"x":0.833,"y":0.841},"o":{"x":0.167,"y":0.176},"t":21,"s":[346.843,319.188,0],"to":[0.353,2.293,0],"ti":[-0.205,-2.077,0]},{"i":{"x":0.833,"y":0.841},"o":{"x":0.167,"y":0.176},"t":22,"s":[347.63,325.723,0],"to":[0.205,2.077,0],"ti":[-0.115,-1.887,0]},{"i":{"x":0.833,"y":0.841},"o":{"x":0.167,"y":0.175},"t":23,"s":[348.075,331.651,0],"to":[0.115,1.887,0],"ti":[-0.064,-1.719,0]},{"i":{"x":0.833,"y":0.84},"o":{"x":0.167,"y":0.175},"t":24,"s":[348.319,337.046,0],"to":[0.064,1.719,0],"ti":[-0.041,-1.573,0]},{"i":{"x":0.833,"y":0.839},"o":{"x":0.167,"y":0.174},"t":25,"s":[348.461,341.968,0],"to":[0.041,1.573,0],"ti":[-0.028,-1.452,0]},{"i":{"x":0.833,"y":0.839},"o":{"x":0.167,"y":0.173},"t":26,"s":[348.564,346.482,0],"to":[0.028,1.452,0],"ti":[-0.019,-1.351,0]},{"i":{"x":0.833,"y":0.839},"o":{"x":0.167,"y":0.173},"t":27,"s":[348.628,350.68,0],"to":[0.019,1.351,0],"ti":[-0.016,-1.256,0]},{"i":{"x":0.833,"y":0.839},"o":{"x":0.167,"y":0.173},"t":28,"s":[348.676,354.586,0],"to":[0.016,1.256,0],"ti":[-0.018,-1.167,0]},{"i":{"x":0.833,"y":0.839},"o":{"x":0.167,"y":0.173},"t":29,"s":[348.724,358.218,0],"to":[0.018,1.167,0],"ti":[-0.022,-1.082,0]},{"i":{"x":0.833,"y":0.84},"o":{"x":0.167,"y":0.173},"t":30,"s":[348.783,361.589,0],"to":[0.022,1.082,0],"ti":[-0.029,-1,0]},{"i":{"x":0.833,"y":0.84},"o":{"x":0.167,"y":0.174},"t":31,"s":[348.858,364.71,0],"to":[0.029,1,0],"ti":[-0.036,-0.921,0]},{"i":{"x":0.833,"y":0.84},"o":{"x":0.167,"y":0.174},"t":32,"s":[348.955,367.59,0],"to":[0.036,0.921,0],"ti":[-0.043,-0.844,0]},{"i":{"x":0.833,"y":0.841},"o":{"x":0.167,"y":0.174},"t":33,"s":[349.073,370.236,0],"to":[0.043,0.844,0],"ti":[-0.05,-0.768,0]},{"i":{"x":0.833,"y":0.842},"o":{"x":0.167,"y":0.175},"t":34,"s":[349.212,372.652,0],"to":[0.05,0.768,0],"ti":[-0.056,-0.694,0]},{"i":{"x":0.833,"y":0.842},"o":{"x":0.167,"y":0.176},"t":35,"s":[349.372,374.845,0],"to":[0.056,0.694,0],"ti":[-0.062,-0.622,0]},{"i":{"x":0.833,"y":0.843},"o":{"x":0.167,"y":0.177},"t":36,"s":[349.55,376.819,0],"to":[0.062,0.622,0],"ti":[-0.067,-0.551,0]},{"i":{"x":0.833,"y":0.844},"o":{"x":0.167,"y":0.178},"t":37,"s":[349.743,378.577,0],"to":[0.067,0.551,0],"ti":[-0.07,-0.481,0]},{"i":{"x":0.833,"y":0.845},"o":{"x":0.167,"y":0.179},"t":38,"s":[349.95,380.124,0],"to":[0.07,0.481,0],"ti":[-0.073,-0.413,0]},{"i":{"x":0.833,"y":0.847},"o":{"x":0.167,"y":0.181},"t":39,"s":[350.166,381.464,0],"to":[0.073,0.413,0],"ti":[-0.075,-0.348,0]},{"i":{"x":0.833,"y":0.849},"o":{"x":0.167,"y":0.183},"t":40,"s":[350.389,382.603,0],"to":[0.075,0.348,0],"ti":[-0.075,-0.284,0]},{"i":{"x":0.833,"y":0.852},"o":{"x":0.167,"y":0.186},"t":41,"s":[350.614,383.549,0],"to":[0.075,0.284,0],"ti":[-0.075,-0.222,0]},{"i":{"x":0.833,"y":0.855},"o":{"x":0.167,"y":0.19},"t":42,"s":[350.84,384.306,0],"to":[0.075,0.222,0],"ti":[-0.073,-0.163,0]},{"i":{"x":0.833,"y":0.859},"o":{"x":0.167,"y":0.196},"t":43,"s":[351.062,384.882,0],"to":[0.073,0.163,0],"ti":[-0.071,-0.106,0]},{"i":{"x":0.833,"y":0.86},"o":{"x":0.167,"y":0.204},"t":44,"s":[351.279,385.285,0],"to":[0.071,0.106,0],"ti":[-0.068,-0.053,0]},{"i":{"x":0.833,"y":0.84},"o":{"x":0.167,"y":0.205},"t":45,"s":[351.487,385.521,0],"to":[0.068,0.053,0],"ti":[-0.064,-0.002,0]},{"i":{"x":0.833,"y":0.806},"o":{"x":0.167,"y":0.173},"t":46,"s":[351.685,385.601,0],"to":[0.064,0.002,0],"ti":[-0.059,0.045,0]},{"i":{"x":0.833,"y":0.804},"o":{"x":0.167,"y":0.146},"t":47,"s":[351.87,385.535,0],"to":[0.059,-0.045,0],"ti":[-0.054,0.088,0]},{"i":{"x":0.833,"y":0.811},"o":{"x":0.167,"y":0.145},"t":48,"s":[352.04,385.333,0],"to":[0.054,-0.088,0],"ti":[-0.049,0.127,0]},{"i":{"x":0.833,"y":0.817},"o":{"x":0.167,"y":0.149},"t":49,"s":[352.195,385.007,0],"to":[0.049,-0.127,0],"ti":[-0.043,0.161,0]},{"i":{"x":0.833,"y":0.827},"o":{"x":0.167,"y":0.153},"t":50,"s":[352.331,384.571,0],"to":[0.043,-0.161,0],"ti":[-0.035,0.185,0]},{"i":{"x":0.833,"y":0.835},"o":{"x":0.167,"y":0.161},"t":51,"s":[352.45,384.039,0],"to":[0.035,-0.185,0],"ti":[-0.024,0.192,0]},{"i":{"x":0.833,"y":0.836},"o":{"x":0.167,"y":0.168},"t":52,"s":[352.541,383.461,0],"to":[0.024,-0.192,0],"ti":[-0.013,0.188,0]},{"i":{"x":0.833,"y":0.839},"o":{"x":0.167,"y":0.17},"t":53,"s":[352.596,382.887,0],"to":[0.013,-0.188,0],"ti":[-0.001,0.179,0]},{"i":{"x":0.833,"y":0.841},"o":{"x":0.167,"y":0.172},"t":54,"s":[352.617,382.333,0],"to":[0.001,-0.179,0],"ti":[0.007,0.165,0]},{"i":{"x":0.833,"y":0.842},"o":{"x":0.167,"y":0.175},"t":55,"s":[352.603,381.814,0],"to":[-0.007,-0.165,0],"ti":[0.003,0.149,0]},{"i":{"x":0.833,"y":0.842},"o":{"x":0.167,"y":0.176},"t":56,"s":[352.574,381.345,0],"to":[-0.003,-0.149,0],"ti":[-0.011,0.133,0]},{"i":{"x":0.833,"y":0.842},"o":{"x":0.167,"y":0.176},"t":57,"s":[352.587,380.922,0],"to":[0.011,-0.133,0],"ti":[-0.023,0.117,0]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.177},"t":58,"s":[352.639,380.546,0],"to":[0.023,-0.117,0],"ti":[-0.014,0.055,0]},{"t":59,"s":[352.724,380.217,0]}],"ix":2,"l":2},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":1,"k":[{"i":{"x":[0.833,0.833,0.833],"y":[2.45,-1.622,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.167,0.167,0]},"t":11,"s":[64.676,29.575,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.851,0.952,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.079,0.086,0]},"t":12,"s":[63.619,31.654,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.096,0.849,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.189,-0.115,0]},"t":13,"s":[83.082,94.984,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.044,1.052,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.045,0.186,0]},"t":14,"s":[98.428,68.346,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.951,0.791,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.029,0.032,0]},"t":15,"s":[65.326,46.692,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.846,0.999,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.117,0.139,0]},"t":16,"s":[115.976,81.786,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.191,0.9,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.181,-0.001,0]},"t":17,"s":[94.867,134.538,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.903,1.096,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.058,0.505,0]},"t":18,"s":[76.907,82.463,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.592,0.774,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.576,0.045,0]},"t":19,"s":[136.087,72.163,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.95,0.874,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.073,0.132,0]},"t":20,"s":[146.093,94.38,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.84,1.06,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.126,0.245,0]},"t":21,"s":[64.998,132.461,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.163,0.877,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.174,0.035,0]},"t":22,"s":[97.241,152.104,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.936,0.874,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.055,0.259,0]},"t":23,"s":[126.81,118.253,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.87,0.449,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.273,0.246,0]},"t":24,"s":[39.419,102.155,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.013,0.912,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.231,0.098,0]},"t":25,"s":[59.845,93.89,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.068,2.263,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.011,1.504,0]},"t":26,"s":[71.34,47.538,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.87,0.951,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.037,0.078,0]},"t":27,"s":[58.098,44.818,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.848,0.781,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.234,-0.119,0]},"t":28,"s":[82.135,88.74,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.37,1.069,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.185,0.135,0]},"t":29,"s":[95.452,70.689,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.991,0.946,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.068,0.038,0]},"t":30,"s":[106.399,41.339,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.931,0.784,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.01,-0.156,0]},"t":31,"s":[46.837,95.066,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.867,0.989,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.417,0.136,0]},"t":32,"s":[100.241,76.371,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.202,0.986,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.224,-0.013,0]},"t":33,"s":[91.348,46.624,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.962,0.96,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.059,-0.017,0]},"t":34,"s":[86.082,72.273,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.193,0.684,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.069,-0.076,0]},"t":35,"s":[104.113,51.031,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.964,0.99,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.058,0.113,0]},"t":36,"s":[94.281,62.125,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.717,0.85,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.063,-0.012,0]},"t":37,"s":[126.942,93.089,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.99,0.934,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.118,0.187,0]},"t":38,"s":[108.331,65.944,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.969,0.494,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.012,-0.318,0]},"t":39,"s":[63.723,44.138,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.947,0.903,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.049,0.1,0]},"t":40,"s":[102.921,48.671,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.464,0.113,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.147,0.604,0]},"t":41,"s":[78.268,71.671,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.902,1.068,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.071,0.092,0]},"t":42,"s":[87.176,75.352,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.234,0.908,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.554,0.037,0]},"t":43,"s":[28.705,110.854,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.845,1.441,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.061,0.901,0]},"t":44,"s":[18.357,46.477,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.091,0.982,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.181,0.07,0]},"t":45,"s":[57.758,39.913,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.948,0.801,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.044,-0.023,0]},"t":46,"s":[91.489,81.222,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.953,0.932,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.137,0.143,0]},"t":47,"s":[20.878,48.853,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.976,0.994,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.108,-0.379,0]},"t":48,"s":[47.568,3.824,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.831,1.042,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.035,-0.006,0]},"t":49,"s":[35.966,11.947,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.412,0.592,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.165,0.028,0]},"t":50,"s":[44.168,4.41,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.937,0.978,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.069,0.105,0]},"t":51,"s":[52.575,15.778,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.454,0.926,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.254,-0.03,0]},"t":52,"s":[2.557,60.137,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.959,1.407,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.098,-0.624,0]},"t":53,"s":[14.899,27.604,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.849,1.066,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.082,0.069,0]},"t":54,"s":[83.445,31.436,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.046,0.934,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.186,0.037,0]},"t":55,"s":[48.803,8.908,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.963,0.694,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.03,-0.323,0]},"t":56,"s":[20.805,49.183,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.959,0.832,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.065,0.115,0]},"t":57,"s":[64.315,40.919,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.833,0.833,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.079,0.165,0]},"t":58,"s":[39.92,18.864,100]},{"t":59,"s":[52.411,-3.555,100]}],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"sr","sy":2,"d":1,"pt":{"a":0,"k":3,"ix":3},"p":{"a":0,"k":[6,32],"ix":4},"r":{"a":0,"k":0,"ix":5},"or":{"a":0,"k":6.599,"ix":7},"os":{"a":0,"k":0,"ix":9},"ix":1,"nm":"Polystar Path 1","mn":"ADBE Vector Shape - Star","hd":false},{"ty":"fl","c":{"a":0,"k":[0.167325382607,0.716982433843,1,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[-7.382,-25.677],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Polystar 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":11,"op":60,"st":11,"bm":0},{"ddd":0,"ind":34,"ty":4,"nm":"Shape Layer 18","sr":1,"ks":{"o":{"a":1,"k":[{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":12,"s":[0]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":17,"s":[100]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":39,"s":[100]},{"t":55,"s":[0]}],"ix":11},"r":{"a":0,"k":0,"ix":10,"x":"var $bm_rt;\n$bm_rt = $bm_mul(time, 300);"},"p":{"a":1,"k":[{"i":{"x":0.833,"y":0.544},"o":{"x":0.167,"y":0.167},"t":12,"s":[267.865,255.269,0],"to":[-0.161,-0.402,0],"ti":[1.026,2.138,0]},{"i":{"x":0.833,"y":0.761},"o":{"x":0.167,"y":0.102},"t":13,"s":[266.901,252.858,0],"to":[-1.026,-2.138,0],"ti":[2.899,4.739,0]},{"i":{"x":0.833,"y":0.824},"o":{"x":0.167,"y":0.128},"t":14,"s":[261.706,242.443,0],"to":[-2.899,-4.739,0],"ti":[4.94,5.799,0]},{"i":{"x":0.833,"y":0.844},"o":{"x":0.167,"y":0.158},"t":15,"s":[249.506,224.427,0],"to":[-4.94,-5.799,0],"ti":[5.963,4.5,0]},{"i":{"x":0.833,"y":0.847},"o":{"x":0.167,"y":0.179},"t":16,"s":[232.069,207.647,0],"to":[-5.963,-4.5,0],"ti":[5.888,2.374,0]},{"i":{"x":0.833,"y":0.847},"o":{"x":0.167,"y":0.184},"t":17,"s":[213.727,197.425,0],"to":[-5.888,-2.374,0],"ti":[5.276,0.563,0]},{"i":{"x":0.833,"y":0.845},"o":{"x":0.167,"y":0.182},"t":18,"s":[196.743,193.401,0],"to":[-5.276,-0.563,0],"ti":[4.452,-0.741,0]},{"i":{"x":0.833,"y":0.844},"o":{"x":0.167,"y":0.18},"t":19,"s":[182.074,194.046,0],"to":[-4.452,0.741,0],"ti":[3.589,-1.577,0]},{"i":{"x":0.833,"y":0.843},"o":{"x":0.167,"y":0.179},"t":20,"s":[170.029,197.845,0],"to":[-3.589,1.577,0],"ti":[2.794,-2.037,0]},{"i":{"x":0.833,"y":0.842},"o":{"x":0.167,"y":0.177},"t":21,"s":[160.537,203.508,0],"to":[-2.794,2.037,0],"ti":[2.125,-2.229,0]},{"i":{"x":0.833,"y":0.842},"o":{"x":0.167,"y":0.177},"t":22,"s":[153.265,210.066,0],"to":[-2.125,2.229,0],"ti":[1.595,-2.253,0]},{"i":{"x":0.833,"y":0.842},"o":{"x":0.167,"y":0.176},"t":23,"s":[147.784,216.885,0],"to":[-1.595,2.253,0],"ti":[1.188,-2.18,0]},{"i":{"x":0.833,"y":0.842},"o":{"x":0.167,"y":0.176},"t":24,"s":[143.692,223.586,0],"to":[-1.188,2.18,0],"ti":[0.88,-2.056,0]},{"i":{"x":0.833,"y":0.841},"o":{"x":0.167,"y":0.176},"t":25,"s":[140.657,229.963,0],"to":[-0.88,2.056,0],"ti":[0.65,-1.912,0]},{"i":{"x":0.833,"y":0.841},"o":{"x":0.167,"y":0.176},"t":26,"s":[138.413,235.924,0],"to":[-0.65,1.912,0],"ti":[0.477,-1.76,0]},{"i":{"x":0.833,"y":0.842},"o":{"x":0.167,"y":0.176},"t":27,"s":[136.76,241.434,0],"to":[-0.477,1.76,0],"ti":[0.347,-1.607,0]},{"i":{"x":0.833,"y":0.842},"o":{"x":0.167,"y":0.176},"t":28,"s":[135.553,246.481,0],"to":[-0.347,1.607,0],"ti":[0.25,-1.458,0]},{"i":{"x":0.833,"y":0.842},"o":{"x":0.167,"y":0.176},"t":29,"s":[134.678,251.075,0],"to":[-0.25,1.458,0],"ti":[0.178,-1.316,0]},{"i":{"x":0.833,"y":0.842},"o":{"x":0.167,"y":0.176},"t":30,"s":[134.053,255.23,0],"to":[-0.178,1.316,0],"ti":[0.125,-1.182,0]},{"i":{"x":0.833,"y":0.842},"o":{"x":0.167,"y":0.177},"t":31,"s":[133.611,258.969,0],"to":[-0.125,1.182,0],"ti":[0.088,-1.057,0]},{"i":{"x":0.833,"y":0.843},"o":{"x":0.167,"y":0.177},"t":32,"s":[133.302,262.321,0],"to":[-0.088,1.057,0],"ti":[0.062,-0.942,0]},{"i":{"x":0.833,"y":0.843},"o":{"x":0.167,"y":0.177},"t":33,"s":[133.085,265.313,0],"to":[-0.062,0.942,0],"ti":[0.045,-0.835,0]},{"i":{"x":0.833,"y":0.843},"o":{"x":0.167,"y":0.178},"t":34,"s":[132.931,267.971,0],"to":[-0.045,0.835,0],"ti":[0.035,-0.737,0]},{"i":{"x":0.833,"y":0.844},"o":{"x":0.167,"y":0.178},"t":35,"s":[132.816,270.322,0],"to":[-0.035,0.737,0],"ti":[0.031,-0.648,0]},{"i":{"x":0.833,"y":0.844},"o":{"x":0.167,"y":0.178},"t":36,"s":[132.72,272.392,0],"to":[-0.031,0.648,0],"ti":[0.031,-0.567,0]},{"i":{"x":0.833,"y":0.844},"o":{"x":0.167,"y":0.179},"t":37,"s":[132.63,274.209,0],"to":[-0.031,0.567,0],"ti":[0.035,-0.496,0]},{"i":{"x":0.833,"y":0.844},"o":{"x":0.167,"y":0.179},"t":38,"s":[132.533,275.797,0],"to":[-0.035,0.496,0],"ti":[0.04,-0.432,0]},{"i":{"x":0.833,"y":0.844},"o":{"x":0.167,"y":0.178},"t":39,"s":[132.422,277.182,0],"to":[-0.04,0.432,0],"ti":[0.043,-0.377,0]},{"i":{"x":0.833,"y":0.845},"o":{"x":0.167,"y":0.179},"t":40,"s":[132.291,278.392,0],"to":[-0.043,0.377,0],"ti":[0.036,-0.328,0]},{"i":{"x":0.833,"y":0.845},"o":{"x":0.167,"y":0.18},"t":41,"s":[132.166,279.447,0],"to":[-0.036,0.328,0],"ti":[0.026,-0.282,0]},{"i":{"x":0.833,"y":0.846},"o":{"x":0.167,"y":0.181},"t":42,"s":[132.075,280.359,0],"to":[-0.026,0.282,0],"ti":[0.017,-0.24,0]},{"i":{"x":0.833,"y":0.847},"o":{"x":0.167,"y":0.182},"t":43,"s":[132.011,281.139,0],"to":[-0.017,0.24,0],"ti":[0.01,-0.201,0]},{"i":{"x":0.833,"y":0.849},"o":{"x":0.167,"y":0.184},"t":44,"s":[131.972,281.799,0],"to":[-0.01,0.201,0],"ti":[0.003,-0.166,0]},{"i":{"x":0.833,"y":0.851},"o":{"x":0.167,"y":0.186},"t":45,"s":[131.954,282.347,0],"to":[-0.003,0.166,0],"ti":[-0.002,-0.133,0]},{"i":{"x":0.833,"y":0.854},"o":{"x":0.167,"y":0.189},"t":46,"s":[131.953,282.792,0],"to":[0.002,0.133,0],"ti":[-0.007,-0.102,0]},{"i":{"x":0.833,"y":0.859},"o":{"x":0.167,"y":0.195},"t":47,"s":[131.967,283.142,0],"to":[0.007,0.102,0],"ti":[-0.011,-0.073,0]},{"i":{"x":0.833,"y":0.866},"o":{"x":0.167,"y":0.204},"t":48,"s":[131.994,283.403,0],"to":[0.011,0.073,0],"ti":[-0.014,-0.046,0]},{"i":{"x":0.833,"y":0.869},"o":{"x":0.167,"y":0.222},"t":49,"s":[132.031,283.58,0],"to":[0.014,0.046,0],"ti":[-0.017,-0.02,0]},{"i":{"x":0.833,"y":0.811},"o":{"x":0.167,"y":0.23},"t":50,"s":[132.078,283.677,0],"to":[0.017,0.02,0],"ti":[-0.019,0.004,0]},{"i":{"x":0.833,"y":0.815},"o":{"x":0.167,"y":0.149},"t":51,"s":[132.132,283.7,0],"to":[0.019,-0.004,0],"ti":[-0.021,0.02,0]},{"i":{"x":0.833,"y":0.845},"o":{"x":0.167,"y":0.152},"t":52,"s":[132.193,283.651,0],"to":[0.021,-0.02,0],"ti":[-0.023,0.018,0]},{"i":{"x":0.833,"y":0.838},"o":{"x":0.167,"y":0.18},"t":53,"s":[132.259,283.583,0],"to":[0.023,-0.018,0],"ti":[-0.025,0.008,0]},{"i":{"x":0.833,"y":0.83},"o":{"x":0.167,"y":0.172},"t":54,"s":[132.331,283.544,0],"to":[0.025,-0.008,0],"ti":[-0.026,0,0]},{"i":{"x":0.833,"y":0.826},"o":{"x":0.167,"y":0.164},"t":55,"s":[132.408,283.533,0],"to":[0.026,0,0],"ti":[-0.027,-0.008,0]},{"i":{"x":0.833,"y":0.826},"o":{"x":0.167,"y":0.16},"t":56,"s":[132.487,283.545,0],"to":[0.027,0.008,0],"ti":[-0.027,-0.014,0]},{"i":{"x":0.833,"y":0.828},"o":{"x":0.167,"y":0.16},"t":57,"s":[132.568,283.579,0],"to":[0.027,0.014,0],"ti":[-0.027,-0.019,0]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.162},"t":58,"s":[132.649,283.63,0],"to":[0.027,0.019,0],"ti":[-0.013,-0.011,0]},{"t":59,"s":[132.728,283.695,0]}],"ix":2,"l":2},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":1,"k":[{"i":{"x":[0.833,0.833,0.833],"y":[1.085,2.343,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.167,0.167,0]},"t":12,"s":[95.923,97.698,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.959,0.958,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.042,0.078,0]},"t":13,"s":[119.853,100.809,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.846,0.754,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.082,-0.083,0]},"t":14,"s":[71.61,47.548,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.108,0.987,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.182,0.126,0]},"t":15,"s":[95.909,74.187,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.971,0.88,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.047,-0.015,0]},"t":16,"s":[116.529,126.213,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.765,1.081,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.046,0.272,0]},"t":17,"s":[69.282,82.296,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.014,0.826,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.129,0.041,0]},"t":18,"s":[99.804,62.944,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.925,0.917,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.012,0.16,0]},"t":19,"s":[155.455,101.204,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.797,-29.781,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.787,622.842,0]},"t":20,"s":[90.335,142.892,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.16,1.74,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.141,0.084,0]},"t":21,"s":[96.569,142.898,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.834,0.902,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.055,0.075,0]},"t":22,"s":[105.516,144.953,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.994,0.309,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.168,0.547,0]},"t":23,"s":[79.375,124.659,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.849,0.77,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.007,0.095,0]},"t":24,"s":[53.599,121.011,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.006,0.971,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.187,0.131,0]},"t":25,"s":[77.44,94.418,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.991,0.812,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.006,-0.044,0]},"t":26,"s":[96.658,47.588,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.869,1.081,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.01,0.15,0]},"t":27,"s":[75.964,78.236,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.159,0.966,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.23,0.041,0]},"t":28,"s":[94.431,116.596,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.918,0.82,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.055,-0.058,0]},"t":29,"s":[104.908,41.132,100]},{"i":{"x":[0.833,0.833,0.833],"y":[-2.162,1.012,1]},"o":{"x":[0.167,0.167,0.167],"y":[-4.52,0.155,0]},"t":30,"s":[74.497,85.65,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.955,0.956,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.086,0.011,0]},"t":31,"s":[75.047,137.348,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.922,1.056,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.097,-0.092,0]},"t":32,"s":[95.389,78.111,100]},{"i":{"x":[0.833,0.833,0.833],"y":[3.564,0.949,1]},"o":{"x":[0.167,0.167,0.167],"y":[-1.248,0.034,0]},"t":33,"s":[85.987,106.338,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.138,0.755,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.081,-0.133,0]},"t":34,"s":[86.576,59.064,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.92,0.905,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.052,0.126,0]},"t":35,"s":[67.887,77.283,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.048,1.699,1]},"o":{"x":[0.167,0.167,0.167],"y":[-1.951,0.69,0]},"t":36,"s":[117.559,112.652,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.968,0.892,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.091,0.074,0]},"t":37,"s":[115.524,117.508,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.028,0.868,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.052,0.361,0]},"t":38,"s":[94.322,71.923,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.911,0.982,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.021,0.226,0]},"t":39,"s":[107.41,58.262,100]},{"i":{"x":[0.833,0.833,0.833],"y":[-1.313,0.689,1]},"o":{"x":[0.167,0.167,0.167],"y":[1.225,-0.022,0]},"t":40,"s":[89.918,50.26,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.885,0.965,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.086,0.114,0]},"t":41,"s":[88.641,56.58,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.028,0.486,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.303,-0.061,0]},"t":42,"s":[54.485,73.842,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.806,1.029,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.021,0.099,0]},"t":43,"s":[41.537,63.86,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.052,0.898,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.146,0.021,0]},"t":44,"s":[58.89,12.33,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.928,1.279,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.032,0.455,0]},"t":45,"s":[81.867,81.726,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.858,0.905,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.543,0.064,0]},"t":46,"s":[44.5,97.298,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.018,0.916,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.202,0.656,0]},"t":47,"s":[49.475,29.648,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.415,-65.882,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.015,34.7,0]},"t":48,"s":[52.978,19.812,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.126,1.189,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.069,0.083,0]},"t":49,"s":[48.737,19.789,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.939,0.939,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.05,0.058,0]},"t":50,"s":[74.095,0.809,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.788,1.031,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.229,-0.233,0]},"t":51,"s":[10.268,62.842,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.913,1.141,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.137,0.023,0]},"t":52,"s":[27.318,46.475,100]},{"i":{"x":[0.833,0.833,0.833],"y":[3.588,0.962,1]},"o":{"x":[0.167,0.167,0.167],"y":[2.212,0.052,0]},"t":53,"s":[53.611,68.98,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.93,0.969,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.081,-0.069,0]},"t":54,"s":[54.64,8.452,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.974,0.937,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.428,-0.05,0]},"t":55,"s":[21.647,41.626,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.031,0.906,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.037,-0.251,0]},"t":56,"s":[27.025,20.873,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.011,0.234,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.023,0.727,0]},"t":57,"s":[23.296,26.051,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.833,0.833,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.01,0.093,0]},"t":58,"s":[28.415,26.722,100]},{"t":59,"s":[22.608,32.219,100]}],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"sr","sy":2,"d":1,"pt":{"a":0,"k":3,"ix":3},"p":{"a":0,"k":[6,32],"ix":4},"r":{"a":0,"k":0,"ix":5},"or":{"a":0,"k":6.599,"ix":7},"os":{"a":0,"k":0,"ix":9},"ix":1,"nm":"Polystar Path 1","mn":"ADBE Vector Shape - Star","hd":false},{"ty":"fl","c":{"a":0,"k":[0.167325382607,0.716982433843,1,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[-7.382,-25.677],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Polystar 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":12,"op":60,"st":12,"bm":0},{"ddd":0,"ind":35,"ty":4,"nm":"Shape Layer 17","sr":1,"ks":{"o":{"a":1,"k":[{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":8,"s":[0]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":13,"s":[100]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":35,"s":[100]},{"t":51,"s":[0]}],"ix":11},"r":{"a":0,"k":0,"ix":10,"x":"var $bm_rt;\n$bm_rt = $bm_mul(time, 300);"},"p":{"a":1,"k":[{"i":{"x":0.833,"y":0.628},"o":{"x":0.167,"y":0.167},"t":8,"s":[275.449,259.557,0],"to":[0.546,0.159,0],"ti":[-2.494,-0.483,0]},{"i":{"x":0.833,"y":0.767},"o":{"x":0.167,"y":0.107},"t":9,"s":[278.724,260.512,0],"to":[2.494,0.483,0],"ti":[-5.49,-0.572,0]},{"i":{"x":0.833,"y":0.825},"o":{"x":0.167,"y":0.13},"t":10,"s":[290.414,262.453,0],"to":[5.49,0.572,0],"ti":[-7.452,0.063,0]},{"i":{"x":0.833,"y":0.844},"o":{"x":0.167,"y":0.159},"t":11,"s":[311.663,263.943,0],"to":[7.452,-0.063,0],"ti":[-7.172,1.285,0]},{"i":{"x":0.833,"y":0.847},"o":{"x":0.167,"y":0.179},"t":12,"s":[335.126,262.075,0],"to":[7.172,-1.285,0],"ti":[-5.684,2.429,0]},{"i":{"x":0.833,"y":0.847},"o":{"x":0.167,"y":0.184},"t":13,"s":[354.697,256.231,0],"to":[5.684,-2.429,0],"ti":[-4.084,3.144,0]},{"i":{"x":0.833,"y":0.846},"o":{"x":0.167,"y":0.183},"t":14,"s":[369.23,247.5,0],"to":[4.084,-3.144,0],"ti":[-2.734,3.404,0]},{"i":{"x":0.833,"y":0.844},"o":{"x":0.167,"y":0.181},"t":15,"s":[379.199,237.368,0],"to":[2.734,-3.404,0],"ti":[-1.736,3.345,0]},{"i":{"x":0.833,"y":0.843},"o":{"x":0.167,"y":0.179},"t":16,"s":[385.634,227.077,0],"to":[1.736,-3.345,0],"ti":[-1.056,3.132,0]},{"i":{"x":0.833,"y":0.842},"o":{"x":0.167,"y":0.178},"t":17,"s":[389.616,217.298,0],"to":[1.056,-3.132,0],"ti":[-0.604,2.868,0]},{"i":{"x":0.833,"y":0.842},"o":{"x":0.167,"y":0.177},"t":18,"s":[391.967,208.287,0],"to":[0.604,-2.868,0],"ti":[-0.305,2.608,0]},{"i":{"x":0.833,"y":0.841},"o":{"x":0.167,"y":0.176},"t":19,"s":[393.24,200.09,0],"to":[0.305,-2.608,0],"ti":[-0.105,2.371,0]},{"i":{"x":0.833,"y":0.841},"o":{"x":0.167,"y":0.175},"t":20,"s":[393.797,192.641,0],"to":[0.105,-2.371,0],"ti":[0.031,2.158,0]},{"i":{"x":0.833,"y":0.84},"o":{"x":0.167,"y":0.175},"t":21,"s":[393.871,185.865,0],"to":[-0.031,-2.158,0],"ti":[0.123,1.968,0]},{"i":{"x":0.833,"y":0.84},"o":{"x":0.167,"y":0.174},"t":22,"s":[393.614,179.691,0],"to":[-0.123,-1.968,0],"ti":[0.186,1.797,0]},{"i":{"x":0.833,"y":0.84},"o":{"x":0.167,"y":0.174},"t":23,"s":[393.131,174.057,0],"to":[-0.186,-1.797,0],"ti":[0.228,1.644,0]},{"i":{"x":0.833,"y":0.84},"o":{"x":0.167,"y":0.174},"t":24,"s":[392.497,168.908,0],"to":[-0.228,-1.644,0],"ti":[0.251,1.504,0]},{"i":{"x":0.833,"y":0.84},"o":{"x":0.167,"y":0.174},"t":25,"s":[391.765,164.196,0],"to":[-0.251,-1.504,0],"ti":[0.261,1.377,0]},{"i":{"x":0.833,"y":0.84},"o":{"x":0.167,"y":0.174},"t":26,"s":[390.988,159.882,0],"to":[-0.261,-1.377,0],"ti":[0.262,1.261,0]},{"i":{"x":0.833,"y":0.84},"o":{"x":0.167,"y":0.174},"t":27,"s":[390.199,155.932,0],"to":[-0.262,-1.261,0],"ti":[0.256,1.155,0]},{"i":{"x":0.833,"y":0.84},"o":{"x":0.167,"y":0.174},"t":28,"s":[389.418,152.313,0],"to":[-0.256,-1.155,0],"ti":[0.246,1.057,0]},{"i":{"x":0.833,"y":0.84},"o":{"x":0.167,"y":0.174},"t":29,"s":[388.662,149,0],"to":[-0.246,-1.057,0],"ti":[0.233,0.967,0]},{"i":{"x":0.833,"y":0.841},"o":{"x":0.167,"y":0.174},"t":30,"s":[387.941,145.969,0],"to":[-0.233,-0.967,0],"ti":[0.217,0.882,0]},{"i":{"x":0.833,"y":0.841},"o":{"x":0.167,"y":0.175},"t":31,"s":[387.264,143.201,0],"to":[-0.217,-0.882,0],"ti":[0.199,0.803,0]},{"i":{"x":0.833,"y":0.841},"o":{"x":0.167,"y":0.175},"t":32,"s":[386.64,140.677,0],"to":[-0.199,-0.803,0],"ti":[0.18,0.729,0]},{"i":{"x":0.833,"y":0.842},"o":{"x":0.167,"y":0.175},"t":33,"s":[386.071,138.382,0],"to":[-0.18,-0.729,0],"ti":[0.159,0.659,0]},{"i":{"x":0.833,"y":0.842},"o":{"x":0.167,"y":0.176},"t":34,"s":[385.563,136.303,0],"to":[-0.159,-0.659,0],"ti":[0.138,0.594,0]},{"i":{"x":0.833,"y":0.843},"o":{"x":0.167,"y":0.176},"t":35,"s":[385.116,134.426,0],"to":[-0.138,-0.594,0],"ti":[0.117,0.531,0]},{"i":{"x":0.833,"y":0.843},"o":{"x":0.167,"y":0.177},"t":36,"s":[384.732,132.741,0],"to":[-0.117,-0.531,0],"ti":[0.096,0.473,0]},{"i":{"x":0.833,"y":0.844},"o":{"x":0.167,"y":0.178},"t":37,"s":[384.411,131.237,0],"to":[-0.096,-0.473,0],"ti":[0.076,0.417,0]},{"i":{"x":0.833,"y":0.845},"o":{"x":0.167,"y":0.179},"t":38,"s":[384.153,129.905,0],"to":[-0.076,-0.417,0],"ti":[0.055,0.364,0]},{"i":{"x":0.833,"y":0.846},"o":{"x":0.167,"y":0.18},"t":39,"s":[383.957,128.736,0],"to":[-0.055,-0.364,0],"ti":[0.036,0.314,0]},{"i":{"x":0.833,"y":0.847},"o":{"x":0.167,"y":0.181},"t":40,"s":[383.821,127.721,0],"to":[-0.036,-0.314,0],"ti":[0.017,0.266,0]},{"i":{"x":0.833,"y":0.848},"o":{"x":0.167,"y":0.183},"t":41,"s":[383.742,126.852,0],"to":[-0.017,-0.266,0],"ti":[0,0.222,0]},{"i":{"x":0.833,"y":0.85},"o":{"x":0.167,"y":0.185},"t":42,"s":[383.718,126.122,0],"to":[0,-0.222,0],"ti":[-0.017,0.179,0]},{"i":{"x":0.833,"y":0.851},"o":{"x":0.167,"y":0.187},"t":43,"s":[383.745,125.523,0],"to":[0.017,-0.179,0],"ti":[-0.032,0.139,0]},{"i":{"x":0.833,"y":0.852},"o":{"x":0.167,"y":0.19},"t":44,"s":[383.819,125.048,0],"to":[0.032,-0.139,0],"ti":[-0.046,0.101,0]},{"i":{"x":0.833,"y":0.849},"o":{"x":0.167,"y":0.19},"t":45,"s":[383.937,124.69,0],"to":[0.046,-0.101,0],"ti":[-0.058,0.066,0]},{"i":{"x":0.833,"y":0.839},"o":{"x":0.167,"y":0.185},"t":46,"s":[384.093,124.442,0],"to":[0.058,-0.066,0],"ti":[-0.068,0.032,0]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.173},"t":47,"s":[384.283,124.297,0],"to":[0.068,-0.032,0],"ti":[-0.074,0.009,0]},{"i":{"x":0.833,"y":0.838},"o":{"x":0.167,"y":0.166},"t":48,"s":[384.5,124.247,0],"to":[0.074,-0.009,0],"ti":[-0.073,0.002,0]},{"i":{"x":0.833,"y":0.84},"o":{"x":0.167,"y":0.172},"t":49,"s":[384.725,124.244,0],"to":[0.073,-0.002,0],"ti":[-0.068,0.003,0]},{"i":{"x":0.833,"y":0.843},"o":{"x":0.167,"y":0.174},"t":50,"s":[384.937,124.237,0],"to":[0.068,-0.003,0],"ti":[-0.061,0.006,0]},{"i":{"x":0.833,"y":0.846},"o":{"x":0.167,"y":0.178},"t":51,"s":[385.131,124.223,0],"to":[0.061,-0.006,0],"ti":[-0.052,0.011,0]},{"i":{"x":0.833,"y":0.848},"o":{"x":0.167,"y":0.182},"t":52,"s":[385.302,124.198,0],"to":[0.052,-0.011,0],"ti":[-0.041,0.017,0]},{"i":{"x":0.833,"y":0.844},"o":{"x":0.167,"y":0.184},"t":53,"s":[385.442,124.158,0],"to":[0.041,-0.017,0],"ti":[-0.028,0.024,0]},{"i":{"x":0.833,"y":0.838},"o":{"x":0.167,"y":0.179},"t":54,"s":[385.546,124.097,0],"to":[0.028,-0.024,0],"ti":[-0.026,0.021,0]},{"i":{"x":0.833,"y":0.817},"o":{"x":0.167,"y":0.171},"t":55,"s":[385.612,124.016,0],"to":[0.026,-0.021,0],"ti":[-0.034,0.006,0]},{"i":{"x":0.833,"y":0.813},"o":{"x":0.167,"y":0.153},"t":56,"s":[385.701,123.974,0],"to":[0.034,-0.006,0],"ti":[-0.043,-0.01,0]},{"i":{"x":0.833,"y":0.817},"o":{"x":0.167,"y":0.15},"t":57,"s":[385.818,123.981,0],"to":[0.043,0.01,0],"ti":[-0.048,-0.025,0]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.153},"t":58,"s":[385.957,124.035,0],"to":[0.048,0.025,0],"ti":[-0.025,-0.016,0]},{"t":59,"s":[386.107,124.13,0]}],"ix":2,"l":2},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":1,"k":[{"i":{"x":[0.833,0.833,0.833],"y":[0.944,0.679,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.167,0.167,0]},"t":8,"s":[49.219,103.811,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.111,0.916,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.166,0.113,0]},"t":9,"s":[96.918,92.8,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.903,29.641,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.048,19.517,0]},"t":10,"s":[80.991,61.448,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.606,1.004,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.607,0.083,0]},"t":11,"s":[118.185,61.313,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.989,0.964,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.073,0.004,0]},"t":12,"s":[124.101,107.653,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.917,0.896,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.013,-0.062,0]},"t":13,"s":[75.181,59.182,100]},{"i":{"x":[0.833,0.833,0.833],"y":[-66.49,0.968,1]},"o":{"x":[0.167,0.167,0.167],"y":[-62.917,0.413,0]},"t":14,"s":[117.68,86.992,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.946,0.5,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.083,-0.053,0]},"t":15,"s":[117.624,94.022,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.649,0.962,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.154,0.1,0]},"t":16,"s":[72.153,89.729,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.994,0.616,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.109,-0.07,0]},"t":17,"s":[88.106,68.252,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.926,0.942,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.006,0.106,0]},"t":18,"s":[139.441,79.894,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.246,0.999,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.681,-0.189,0]},"t":19,"s":[91.59,121.922,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.031,1.095,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.062,-0.001,0]},"t":20,"s":[96.806,109.051,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.909,0.978,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.023,0.044,0]},"t":21,"s":[76.174,121.699,100]},{"i":{"x":[0.833,0.833,0.833],"y":[2.265,0.951,1]},"o":{"x":[0.167,0.167,0.167],"y":[1.035,-0.029,0]},"t":22,"s":[104.516,94.655,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.009,0.92,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.078,-0.118,0]},"t":23,"s":[106.997,114.689,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.918,7.262,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.008,-1.803,0]},"t":24,"s":[66.837,106.412,100]},{"i":{"x":[0.833,0.833,0.833],"y":[-2.408,1.035,1]},"o":{"x":[0.167,0.167,0.167],"y":[-4.987,0.082,0]},"t":25,"s":[111.464,106.778,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.975,1.046,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.085,0.025,0]},"t":26,"s":[110.731,78.932,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.884,0.945,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.036,0.03,0]},"t":27,"s":[81.464,118.575,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.215,0.686,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.293,-0.158,0]},"t":28,"s":[101.82,56.948,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.963,0.967,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.06,0.113,0]},"t":29,"s":[109.895,78.255,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.916,0.979,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.066,-0.056,0]},"t":30,"s":[80.942,137.249,100]},{"i":{"x":[0.833,0.833,0.833],"y":[-6.827,1.076,1]},"o":{"x":[0.167,0.167,0.167],"y":[12.816,-0.028,0]},"t":31,"s":[97.117,101.933,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.453,0.923,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.084,0.04,0]},"t":32,"s":[97.223,128.384,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.989,0.619,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.07,-0.935,0]},"t":33,"s":[107.061,77.679,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.947,0.924,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.013,0.107,0]},"t":34,"s":[43.764,81.828,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.92,-1.177,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.143,-0.844,0]},"t":35,"s":[98.569,96.663,100]},{"i":{"x":[0.833,0.833,0.833],"y":[2.435,0.919,1]},"o":{"x":[0.167,0.167,0.167],"y":[-2.31,0.087,0]},"t":36,"s":[78.363,95.331,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.931,2.861,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.079,-3.479,0]},"t":37,"s":[79.067,61.85,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.227,0.912,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.407,0.08,0]},"t":38,"s":[66.248,62.633,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.092,-2.126,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.093,1.726,0]},"t":39,"s":[68.428,44.364,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.901,1.019,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.044,0.086,0]},"t":40,"s":[86.481,43.437,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.779,1.036,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.541,0.015,0]},"t":41,"s":[48.571,9.591,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.776,1.009,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.134,0.025,0]},"t":42,"s":[41.665,51.149,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.092,0.916,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.133,0.008,0]},"t":43,"s":[30.243,-8.194,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.935,9.657,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.044,20.432,0]},"t":44,"s":[10.976,57.43,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.606,0.758,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.287,0.083,0]},"t":45,"s":[51.537,57.699,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.918,0.988,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.106,0.127,0]},"t":46,"s":[42.412,29.51,100]},{"i":{"x":[0.833,0.833,0.833],"y":[-8.496,0.903,1]},"o":{"x":[0.167,0.167,0.167],"y":[-5.253,-0.014,0]},"t":47,"s":[8.401,-24.232,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.961,0.669,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.084,0.582,0]},"t":48,"s":[8.932,21.756,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.956,0.913,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.072,0.111,0]},"t":49,"s":[68.92,29.447,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.049,0.445,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.094,1.927,0]},"t":50,"s":[36.715,52.274,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.899,1.631,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.031,0.098,0]},"t":51,"s":[51.861,53.306,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.136,0.908,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.481,0.074,0]},"t":52,"s":[27.738,59.151,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.062,0.322,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.052,0.928,0]},"t":53,"s":[22.685,9.056,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.882,0.979,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.035,0.095,0]},"t":54,"s":[35.966,4.115,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.028,0.812,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.285,-0.029,0]},"t":55,"s":[12.831,-31.117,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.846,0.873,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.021,0.15,0]},"t":56,"s":[3.287,-4.957,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.815,0.964,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.182,0.241,0]},"t":57,"s":[16.066,27.774,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.833,0.833,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.152,-0.063,0]},"t":58,"s":[26.832,45.045,100]},{"t":59,"s":[39.927,35.232,100]}],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"sr","sy":2,"d":1,"pt":{"a":0,"k":3,"ix":3},"p":{"a":0,"k":[6,32],"ix":4},"r":{"a":0,"k":0,"ix":5},"or":{"a":0,"k":6.599,"ix":7},"os":{"a":0,"k":0,"ix":9},"ix":1,"nm":"Polystar Path 1","mn":"ADBE Vector Shape - Star","hd":false},{"ty":"fl","c":{"a":0,"k":[0.167325382607,0.716982433843,1,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[-7.382,-25.677],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Polystar 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":8,"op":60,"st":8,"bm":0},{"ddd":0,"ind":36,"ty":4,"nm":"Shape Layer 16","sr":1,"ks":{"o":{"a":1,"k":[{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":9,"s":[0]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":14,"s":[100]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":36,"s":[100]},{"t":52,"s":[0]}],"ix":11},"r":{"a":0,"k":0,"ix":10,"x":"var $bm_rt;\n$bm_rt = $bm_mul(time, 300);"},"p":{"a":1,"k":[{"i":{"x":0.833,"y":0.587},"o":{"x":0.167,"y":0.167},"t":9,"s":[275.519,261.1,0],"to":[-0.307,-0.313,0],"ti":[1.698,1.185,0]},{"i":{"x":0.833,"y":0.774},"o":{"x":0.167,"y":0.104},"t":10,"s":[273.678,259.22,0],"to":[-1.698,-1.185,0],"ti":[1.745,-2.083,0]},{"i":{"x":0.833,"y":0.821},"o":{"x":0.167,"y":0.132},"t":11,"s":[265.331,253.99,0],"to":[-1.745,2.083,0],"ti":[-0.013,-6.364,0]},{"i":{"x":0.833,"y":0.844},"o":{"x":0.167,"y":0.156},"t":12,"s":[263.209,271.718,0],"to":[0.013,6.364,0],"ti":[-0.69,-6.377,0]},{"i":{"x":0.833,"y":0.847},"o":{"x":0.167,"y":0.179},"t":13,"s":[265.411,292.176,0],"to":[0.69,6.377,0],"ti":[-0.533,-5.437,0]},{"i":{"x":0.833,"y":0.847},"o":{"x":0.167,"y":0.184},"t":14,"s":[267.35,309.98,0],"to":[0.533,5.437,0],"ti":[-0.321,-4.543,0]},{"i":{"x":0.833,"y":0.846},"o":{"x":0.167,"y":0.183},"t":15,"s":[268.609,324.8,0],"to":[0.321,4.543,0],"ti":[-0.144,-3.839,0]},{"i":{"x":0.833,"y":0.845},"o":{"x":0.167,"y":0.181},"t":16,"s":[269.275,337.239,0],"to":[0.144,3.839,0],"ti":[-0.006,-3.291,0]},{"i":{"x":0.833,"y":0.844},"o":{"x":0.167,"y":0.18},"t":17,"s":[269.475,347.837,0],"to":[0.006,3.291,0],"ti":[0.101,-2.855,0]},{"i":{"x":0.833,"y":0.843},"o":{"x":0.167,"y":0.179},"t":18,"s":[269.314,356.986,0],"to":[-0.101,2.855,0],"ti":[0.186,-2.499,0]},{"i":{"x":0.833,"y":0.843},"o":{"x":0.167,"y":0.178},"t":19,"s":[268.867,364.967,0],"to":[-0.186,2.499,0],"ti":[0.253,-2.201,0]},{"i":{"x":0.833,"y":0.842},"o":{"x":0.167,"y":0.177},"t":20,"s":[268.196,371.978,0],"to":[-0.253,2.201,0],"ti":[0.306,-1.947,0]},{"i":{"x":0.833,"y":0.842},"o":{"x":0.167,"y":0.177},"t":21,"s":[267.347,378.17,0],"to":[-0.306,1.947,0],"ti":[0.347,-1.728,0]},{"i":{"x":0.833,"y":0.842},"o":{"x":0.167,"y":0.176},"t":22,"s":[266.36,383.661,0],"to":[-0.347,1.728,0],"ti":[0.377,-1.535,0]},{"i":{"x":0.833,"y":0.842},"o":{"x":0.167,"y":0.176},"t":23,"s":[265.266,388.537,0],"to":[-0.377,1.535,0],"ti":[0.399,-1.366,0]},{"i":{"x":0.833,"y":0.841},"o":{"x":0.167,"y":0.176},"t":24,"s":[264.095,392.873,0],"to":[-0.399,1.366,0],"ti":[0.413,-1.215,0]},{"i":{"x":0.833,"y":0.841},"o":{"x":0.167,"y":0.176},"t":25,"s":[262.872,396.731,0],"to":[-0.413,1.215,0],"ti":[0.42,-1.082,0]},{"i":{"x":0.833,"y":0.841},"o":{"x":0.167,"y":0.175},"t":26,"s":[261.617,400.166,0],"to":[-0.42,1.082,0],"ti":[0.421,-0.962,0]},{"i":{"x":0.833,"y":0.841},"o":{"x":0.167,"y":0.175},"t":27,"s":[260.351,403.22,0],"to":[-0.421,0.962,0],"ti":[0.416,-0.854,0]},{"i":{"x":0.833,"y":0.841},"o":{"x":0.167,"y":0.175},"t":28,"s":[259.092,405.935,0],"to":[-0.416,0.854,0],"ti":[0.407,-0.759,0]},{"i":{"x":0.833,"y":0.841},"o":{"x":0.167,"y":0.175},"t":29,"s":[257.854,408.347,0],"to":[-0.407,0.759,0],"ti":[0.395,-0.675,0]},{"i":{"x":0.833,"y":0.841},"o":{"x":0.167,"y":0.175},"t":30,"s":[256.649,410.491,0],"to":[-0.395,0.675,0],"ti":[0.379,-0.6,0]},{"i":{"x":0.833,"y":0.841},"o":{"x":0.167,"y":0.175},"t":31,"s":[255.487,412.396,0],"to":[-0.379,0.6,0],"ti":[0.361,-0.535,0]},{"i":{"x":0.833,"y":0.841},"o":{"x":0.167,"y":0.175},"t":32,"s":[254.375,414.092,0],"to":[-0.361,0.535,0],"ti":[0.343,-0.477,0]},{"i":{"x":0.833,"y":0.841},"o":{"x":0.167,"y":0.175},"t":33,"s":[253.318,415.604,0],"to":[-0.343,0.477,0],"ti":[0.323,-0.427,0]},{"i":{"x":0.833,"y":0.84},"o":{"x":0.167,"y":0.175},"t":34,"s":[252.319,416.955,0],"to":[-0.323,0.427,0],"ti":[0.304,-0.384,0]},{"i":{"x":0.833,"y":0.84},"o":{"x":0.167,"y":0.174},"t":35,"s":[251.378,418.167,0],"to":[-0.304,0.384,0],"ti":[0.285,-0.347,0]},{"i":{"x":0.833,"y":0.84},"o":{"x":0.167,"y":0.174},"t":36,"s":[250.495,419.259,0],"to":[-0.285,0.347,0],"ti":[0.268,-0.316,0]},{"i":{"x":0.833,"y":0.839},"o":{"x":0.167,"y":0.174},"t":37,"s":[249.666,420.25,0],"to":[-0.268,0.316,0],"ti":[0.252,-0.289,0]},{"i":{"x":0.833,"y":0.84},"o":{"x":0.167,"y":0.173},"t":38,"s":[248.887,421.154,0],"to":[-0.252,0.289,0],"ti":[0.235,-0.266,0]},{"i":{"x":0.833,"y":0.844},"o":{"x":0.167,"y":0.174},"t":39,"s":[248.153,421.986,0],"to":[-0.235,0.266,0],"ti":[0.208,-0.241,0]},{"i":{"x":0.833,"y":0.845},"o":{"x":0.167,"y":0.179},"t":40,"s":[247.479,422.749,0],"to":[-0.208,0.241,0],"ti":[0.175,-0.214,0]},{"i":{"x":0.833,"y":0.845},"o":{"x":0.167,"y":0.18},"t":41,"s":[246.907,423.43,0],"to":[-0.175,0.214,0],"ti":[0.145,-0.189,0]},{"i":{"x":0.833,"y":0.846},"o":{"x":0.167,"y":0.181},"t":42,"s":[246.43,424.033,0],"to":[-0.145,0.189,0],"ti":[0.118,-0.164,0]},{"i":{"x":0.833,"y":0.848},"o":{"x":0.167,"y":0.182},"t":43,"s":[246.038,424.562,0],"to":[-0.118,0.164,0],"ti":[0.094,-0.141,0]},{"i":{"x":0.833,"y":0.849},"o":{"x":0.167,"y":0.184},"t":44,"s":[245.721,425.02,0],"to":[-0.094,0.141,0],"ti":[0.073,-0.118,0]},{"i":{"x":0.833,"y":0.852},"o":{"x":0.167,"y":0.186},"t":45,"s":[245.472,425.408,0],"to":[-0.073,0.118,0],"ti":[0.055,-0.096,0]},{"i":{"x":0.833,"y":0.856},"o":{"x":0.167,"y":0.19},"t":46,"s":[245.282,425.73,0],"to":[-0.055,0.096,0],"ti":[0.039,-0.074,0]},{"i":{"x":0.833,"y":0.862},"o":{"x":0.167,"y":0.197},"t":47,"s":[245.143,425.985,0],"to":[-0.039,0.074,0],"ti":[0.025,-0.053,0]},{"i":{"x":0.833,"y":0.853},"o":{"x":0.167,"y":0.211},"t":48,"s":[245.048,426.176,0],"to":[-0.025,0.053,0],"ti":[0.019,-0.036,0]},{"i":{"x":0.833,"y":0.822},"o":{"x":0.167,"y":0.193},"t":49,"s":[244.991,426.303,0],"to":[-0.019,0.036,0],"ti":[0.026,-0.027,0]},{"i":{"x":0.833,"y":0.815},"o":{"x":0.167,"y":0.157},"t":50,"s":[244.933,426.392,0],"to":[-0.026,0.027,0],"ti":[0.039,-0.021,0]},{"i":{"x":0.833,"y":0.814},"o":{"x":0.167,"y":0.152},"t":51,"s":[244.836,426.464,0],"to":[-0.039,0.021,0],"ti":[0.052,-0.016,0]},{"i":{"x":0.833,"y":0.816},"o":{"x":0.167,"y":0.151},"t":52,"s":[244.701,426.519,0],"to":[-0.052,0.016,0],"ti":[0.065,-0.01,0]},{"i":{"x":0.833,"y":0.818},"o":{"x":0.167,"y":0.152},"t":53,"s":[244.525,426.558,0],"to":[-0.065,0.01,0],"ti":[0.079,-0.004,0]},{"i":{"x":0.833,"y":0.821},"o":{"x":0.167,"y":0.154},"t":54,"s":[244.308,426.579,0],"to":[-0.079,0.004,0],"ti":[0.092,0.002,0]},{"i":{"x":0.833,"y":0.823},"o":{"x":0.167,"y":0.156},"t":55,"s":[244.051,426.582,0],"to":[-0.092,-0.002,0],"ti":[0.105,0.007,0]},{"i":{"x":0.833,"y":0.824},"o":{"x":0.167,"y":0.157},"t":56,"s":[243.755,426.569,0],"to":[-0.105,-0.007,0],"ti":[0.117,0.012,0]},{"i":{"x":0.833,"y":0.826},"o":{"x":0.167,"y":0.159},"t":57,"s":[243.422,426.54,0],"to":[-0.117,-0.012,0],"ti":[0.128,0.017,0]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.16},"t":58,"s":[243.055,426.497,0],"to":[-0.128,-0.017,0],"ti":[0.066,0.01,0]},{"t":59,"s":[242.656,426.439,0]}],"ix":2,"l":2},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":1,"k":[{"i":{"x":[0.833,0.833,0.833],"y":[0.563,1.02,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.167,0.167,0]},"t":9,"s":[92.322,93.922,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.109,0.948,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.103,0.016,0]},"t":10,"s":[95.253,42.092,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.054,0.89,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.047,-0.14,0]},"t":11,"s":[107.675,106.42,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.929,0.725,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.033,0.342,0]},"t":12,"s":[79.026,82.445,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.393,1.033,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.475,0.12,0]},"t":13,"s":[126.398,74.729,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.968,0.877,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.097,0.024,0]},"t":14,"s":[119.33,57.001,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.834,1.214,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.053,0.26,0]},"t":15,"s":[74.88,81.854,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.042,0.975,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.167,0.06,0]},"t":16,"s":[102.06,93.554,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.894,0.977,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.028,-0.035,0]},"t":17,"s":[129.135,51.875,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.863,1.026,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.389,-0.032,0]},"t":18,"s":[88.364,81.23,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.102,0.969,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.212,0.02,0]},"t":19,"s":[77.256,59.947,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.698,1.03,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.046,-0.051,0]},"t":20,"s":[70.037,87.966,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.995,1.049,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.115,0.022,0]},"t":21,"s":[86.104,70.53,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.904,1.07,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.006,0.031,0]},"t":22,"s":[128.201,94.177,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.376,0.967,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.627,0.038,0]},"t":23,"s":[88.836,56.66,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.999,0.977,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.068,-0.054,0]},"t":24,"s":[82.802,125.882,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.976,1.097,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.002,-0.031,0]},"t":25,"s":[116.041,83.757,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.9,0.955,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.033,0.045,0]},"t":26,"s":[83.397,114.487,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.069,0.9,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.508,-0.099,0]},"t":27,"s":[106.769,47.904,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.758,0.953,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.038,0.497,0]},"t":28,"s":[111.355,78.308,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.163,1.569,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.127,-0.106,0]},"t":29,"s":[102.953,84.434,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.9,0.913,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.055,0.073,0]},"t":30,"s":[86.963,81.742,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.429,1.529,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.493,1.851,0]},"t":31,"s":[134.26,102.822,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.944,1.156,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.07,0.072,0]},"t":32,"s":[143.885,103.815,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.922,1.116,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.171,0.054,0]},"t":33,"s":[84.672,96.517,100]},{"i":{"x":[0.833,0.833,0.833],"y":[-0.547,0.897,1]},"o":{"x":[0.167,0.167,0.167],"y":[-1.171,0.048,0]},"t":34,"s":[104.079,117.449,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.772,0.913,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.088,0.439,0]},"t":35,"s":[102.79,67.476,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.975,1.47,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.131,2.037,0]},"t":36,"s":[80.144,55.767,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.822,1.082,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.036,0.071,0]},"t":37,"s":[40.802,55.268,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.966,0.556,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.157,0.041,0]},"t":38,"s":[68.335,58.584,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.795,1.044,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.058,0.103,0]},"t":39,"s":[99.544,51.985,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.792,1.038,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.141,0.029,0]},"t":40,"s":[81.176,23.398,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.896,0.994,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.139,0.026,0]},"t":41,"s":[54.468,66.977,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.334,0.99,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.427,-0.006,0]},"t":42,"s":[14.561,3.526,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.853,0.974,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.067,-0.011,0]},"t":43,"s":[4.89,62.465,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.066,1.037,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.192,-0.037,0]},"t":44,"s":[53.289,10.271,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.882,0.941,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.037,0.026,0]},"t":45,"s":[90.493,46.486,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.078,0.738,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.284,-0.203,0]},"t":46,"s":[23.844,-5.784,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.862,0.898,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.04,0.122,0]},"t":47,"s":[-3.767,9.434,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.96,1.136,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.211,0.454,0]},"t":48,"s":[49.843,42.088,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.76,0.903,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.077,0.052,0]},"t":49,"s":[84.879,49.437,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.867,0.214,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.128,0.58,0]},"t":50,"s":[66.624,30.104,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.951,0.972,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.222,0.093,0]},"t":51,"s":[32.305,26.858,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.916,1.208,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.117,-0.043,0]},"t":52,"s":[11.683,-0.538,100]},{"i":{"x":[0.833,0.833,0.833],"y":[2.198,0.965,1]},"o":{"x":[0.167,0.167,0.167],"y":[10.387,0.06,0]},"t":53,"s":[20.245,17.535,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.475,0.823,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.078,-0.06,0]},"t":54,"s":[20.314,-45.662,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.442,0.954,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.099,0.157,0]},"t":55,"s":[19.25,-8.935,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.962,1.012,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.07,-0.101,0]},"t":56,"s":[13.603,32.461,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.007,0.766,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.071,0.01,0]},"t":57,"s":[49.18,13.788,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.833,0.833,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.006,0.129,0]},"t":58,"s":[29.913,35.117,100]},{"t":59,"s":[50.756,73.724,100]}],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"sr","sy":2,"d":1,"pt":{"a":0,"k":3,"ix":3},"p":{"a":0,"k":[6,32],"ix":4},"r":{"a":0,"k":0,"ix":5},"or":{"a":0,"k":6.599,"ix":7},"os":{"a":0,"k":0,"ix":9},"ix":1,"nm":"Polystar Path 1","mn":"ADBE Vector Shape - Star","hd":false},{"ty":"fl","c":{"a":0,"k":[0.167325382607,0.716982433843,1,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[-7.382,-25.677],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Polystar 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":9,"op":60,"st":9,"bm":0},{"ddd":0,"ind":37,"ty":4,"nm":"Shape Layer 15","sr":1,"ks":{"o":{"a":1,"k":[{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":6,"s":[0]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":11,"s":[100]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":33,"s":[100]},{"t":49,"s":[0]}],"ix":11},"r":{"a":0,"k":0,"ix":10,"x":"var $bm_rt;\n$bm_rt = $bm_mul(time, 300);"},"p":{"a":1,"k":[{"i":{"x":0.833,"y":0.63},"o":{"x":0.167,"y":0.167},"t":6,"s":[275.485,257.563,0],"to":[-0.11,-0.249,0],"ti":[0.689,1.001,0]},{"i":{"x":0.833,"y":0.767},"o":{"x":0.167,"y":0.108},"t":7,"s":[274.824,256.066,0],"to":[-0.689,-1.001,0],"ti":[1.729,2.01,0]},{"i":{"x":0.833,"y":0.824},"o":{"x":0.167,"y":0.13},"t":8,"s":[271.354,251.559,0],"to":[-1.729,-2.01,0],"ti":[2.505,2.573,0]},{"i":{"x":0.833,"y":0.844},"o":{"x":0.167,"y":0.159},"t":9,"s":[264.452,244.008,0],"to":[-2.505,-2.573,0],"ti":[2.58,2.412,0]},{"i":{"x":0.833,"y":0.847},"o":{"x":0.167,"y":0.179},"t":10,"s":[256.321,236.124,0],"to":[-2.58,-2.412,0],"ti":[2.262,1.991,0]},{"i":{"x":0.833,"y":0.847},"o":{"x":0.167,"y":0.183},"t":11,"s":[248.97,229.538,0],"to":[-2.262,-1.991,0],"ti":[1.91,1.643,0]},{"i":{"x":0.833,"y":0.845},"o":{"x":0.167,"y":0.182},"t":12,"s":[242.747,224.176,0],"to":[-1.91,-1.643,0],"ti":[1.608,1.404,0]},{"i":{"x":0.833,"y":0.844},"o":{"x":0.167,"y":0.181},"t":13,"s":[237.508,219.678,0],"to":[-1.608,-1.404,0],"ti":[1.343,1.257,0]},{"i":{"x":0.833,"y":0.842},"o":{"x":0.167,"y":0.179},"t":14,"s":[233.099,215.75,0],"to":[-1.343,-1.257,0],"ti":[1.062,1.209,0]},{"i":{"x":0.833,"y":0.843},"o":{"x":0.167,"y":0.177},"t":15,"s":[229.447,212.138,0],"to":[-1.062,-1.209,0],"ti":[0.452,1.267,0]},{"i":{"x":0.833,"y":0.837},"o":{"x":0.167,"y":0.177},"t":16,"s":[226.724,208.498,0],"to":[-0.452,-1.267,0],"ti":[-0.52,1.026,0]},{"i":{"x":0.833,"y":0.843},"o":{"x":0.167,"y":0.171},"t":17,"s":[226.732,204.537,0],"to":[0.52,-1.026,0],"ti":[-1.055,0.562,0]},{"i":{"x":0.833,"y":0.842},"o":{"x":0.167,"y":0.177},"t":18,"s":[229.844,202.343,0],"to":[1.055,-0.562,0],"ti":[-1.033,0.324,0]},{"i":{"x":0.833,"y":0.842},"o":{"x":0.167,"y":0.176},"t":19,"s":[233.062,201.165,0],"to":[1.033,-0.324,0],"ti":[-0.951,0.215,0]},{"i":{"x":0.833,"y":0.841},"o":{"x":0.167,"y":0.176},"t":20,"s":[236.043,200.396,0],"to":[0.951,-0.215,0],"ti":[-0.867,0.144,0]},{"i":{"x":0.833,"y":0.841},"o":{"x":0.167,"y":0.176},"t":21,"s":[238.767,199.873,0],"to":[0.867,-0.144,0],"ti":[-0.789,0.088,0]},{"i":{"x":0.833,"y":0.841},"o":{"x":0.167,"y":0.176},"t":22,"s":[241.247,199.533,0],"to":[0.789,-0.088,0],"ti":[-0.717,0.039,0]},{"i":{"x":0.833,"y":0.84},"o":{"x":0.167,"y":0.175},"t":23,"s":[243.501,199.346,0],"to":[0.717,-0.039,0],"ti":[-0.654,0.005,0]},{"i":{"x":0.833,"y":0.84},"o":{"x":0.167,"y":0.174},"t":24,"s":[245.548,199.298,0],"to":[0.654,-0.005,0],"ti":[-0.601,-0.009,0]},{"i":{"x":0.833,"y":0.84},"o":{"x":0.167,"y":0.174},"t":25,"s":[247.425,199.317,0],"to":[0.601,0.009,0],"ti":[-0.552,-0.013,0]},{"i":{"x":0.833,"y":0.841},"o":{"x":0.167,"y":0.174},"t":26,"s":[249.152,199.353,0],"to":[0.552,0.013,0],"ti":[-0.504,-0.015,0]},{"i":{"x":0.833,"y":0.841},"o":{"x":0.167,"y":0.175},"t":27,"s":[250.734,199.397,0],"to":[0.504,0.015,0],"ti":[-0.457,-0.014,0]},{"i":{"x":0.833,"y":0.842},"o":{"x":0.167,"y":0.175},"t":28,"s":[252.175,199.441,0],"to":[0.457,0.014,0],"ti":[-0.412,-0.011,0]},{"i":{"x":0.833,"y":0.843},"o":{"x":0.167,"y":0.176},"t":29,"s":[253.479,199.479,0],"to":[0.412,0.011,0],"ti":[-0.367,-0.007,0]},{"i":{"x":0.833,"y":0.844},"o":{"x":0.167,"y":0.177},"t":30,"s":[254.647,199.506,0],"to":[0.367,0.007,0],"ti":[-0.324,-0.001,0]},{"i":{"x":0.833,"y":0.845},"o":{"x":0.167,"y":0.179},"t":31,"s":[255.684,199.519,0],"to":[0.324,0.001,0],"ti":[-0.281,0.005,0]},{"i":{"x":0.833,"y":0.847},"o":{"x":0.167,"y":0.18},"t":32,"s":[256.59,199.514,0],"to":[0.281,-0.005,0],"ti":[-0.238,0.012,0]},{"i":{"x":0.833,"y":0.849},"o":{"x":0.167,"y":0.183},"t":33,"s":[257.369,199.488,0],"to":[0.238,-0.012,0],"ti":[-0.197,0.019,0]},{"i":{"x":0.833,"y":0.851},"o":{"x":0.167,"y":0.186},"t":34,"s":[258.021,199.442,0],"to":[0.197,-0.019,0],"ti":[-0.156,0.027,0]},{"i":{"x":0.833,"y":0.854},"o":{"x":0.167,"y":0.189},"t":35,"s":[258.548,199.373,0],"to":[0.156,-0.027,0],"ti":[-0.116,0.034,0]},{"i":{"x":0.833,"y":0.856},"o":{"x":0.167,"y":0.194},"t":36,"s":[258.956,199.282,0],"to":[0.116,-0.034,0],"ti":[-0.078,0.042,0]},{"i":{"x":0.833,"y":0.852},"o":{"x":0.167,"y":0.199},"t":37,"s":[259.247,199.167,0],"to":[0.078,-0.042,0],"ti":[-0.042,0.049,0]},{"i":{"x":0.833,"y":0.83},"o":{"x":0.167,"y":0.191},"t":38,"s":[259.427,199.031,0],"to":[0.042,-0.049,0],"ti":[-0.007,0.055,0]},{"i":{"x":0.833,"y":0.809},"o":{"x":0.167,"y":0.164},"t":39,"s":[259.498,198.875,0],"to":[0.007,-0.055,0],"ti":[0.027,0.061,0]},{"i":{"x":0.833,"y":0.808},"o":{"x":0.167,"y":0.148},"t":40,"s":[259.466,198.699,0],"to":[-0.027,-0.061,0],"ti":[0.059,0.066,0]},{"i":{"x":0.833,"y":0.813},"o":{"x":0.167,"y":0.147},"t":41,"s":[259.336,198.506,0],"to":[-0.059,-0.066,0],"ti":[0.089,0.071,0]},{"i":{"x":0.833,"y":0.818},"o":{"x":0.167,"y":0.15},"t":42,"s":[259.112,198.3,0],"to":[-0.089,-0.071,0],"ti":[0.117,0.074,0]},{"i":{"x":0.833,"y":0.821},"o":{"x":0.167,"y":0.153},"t":43,"s":[258.802,198.083,0],"to":[-0.117,-0.074,0],"ti":[0.142,0.075,0]},{"i":{"x":0.833,"y":0.824},"o":{"x":0.167,"y":0.156},"t":44,"s":[258.411,197.859,0],"to":[-0.142,-0.075,0],"ti":[0.165,0.075,0]},{"i":{"x":0.833,"y":0.829},"o":{"x":0.167,"y":0.158},"t":45,"s":[257.947,197.632,0],"to":[-0.165,-0.075,0],"ti":[0.182,0.074,0]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.163},"t":46,"s":[257.418,197.407,0],"to":[-0.182,-0.074,0],"ti":[0.188,0.072,0]},{"i":{"x":0.833,"y":0.835},"o":{"x":0.167,"y":0.167},"t":47,"s":[256.856,197.186,0],"to":[-0.188,-0.072,0],"ti":[0.188,0.068,0]},{"i":{"x":0.833,"y":0.836},"o":{"x":0.167,"y":0.168},"t":48,"s":[256.29,196.974,0],"to":[-0.188,-0.068,0],"ti":[0.184,0.062,0]},{"i":{"x":0.833,"y":0.838},"o":{"x":0.167,"y":0.17},"t":49,"s":[255.73,196.777,0],"to":[-0.184,-0.062,0],"ti":[0.178,0.054,0]},{"i":{"x":0.833,"y":0.84},"o":{"x":0.167,"y":0.172},"t":50,"s":[255.185,196.601,0],"to":[-0.178,-0.054,0],"ti":[0.168,0.043,0]},{"i":{"x":0.833,"y":0.842},"o":{"x":0.167,"y":0.174},"t":51,"s":[254.664,196.453,0],"to":[-0.168,-0.043,0],"ti":[0.154,0.03,0]},{"i":{"x":0.833,"y":0.845},"o":{"x":0.167,"y":0.177},"t":52,"s":[254.178,196.341,0],"to":[-0.154,-0.03,0],"ti":[0.136,0.014,0]},{"i":{"x":0.833,"y":0.848},"o":{"x":0.167,"y":0.181},"t":53,"s":[253.74,196.272,0],"to":[-0.136,-0.014,0],"ti":[0.114,-0.009,0]},{"i":{"x":0.833,"y":0.842},"o":{"x":0.167,"y":0.185},"t":54,"s":[253.361,196.256,0],"to":[-0.114,0.009,0],"ti":[0.088,-0.039,0]},{"i":{"x":0.833,"y":0.83},"o":{"x":0.167,"y":0.176},"t":55,"s":[253.059,196.325,0],"to":[-0.088,0.039,0],"ti":[0.062,-0.069,0]},{"i":{"x":0.833,"y":0.821},"o":{"x":0.167,"y":0.163},"t":56,"s":[252.835,196.49,0],"to":[-0.062,0.069,0],"ti":[0.038,-0.096,0]},{"i":{"x":0.833,"y":0.82},"o":{"x":0.167,"y":0.156},"t":57,"s":[252.687,196.74,0],"to":[-0.038,0.096,0],"ti":[0.015,-0.119,0]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.155},"t":58,"s":[252.609,197.066,0],"to":[-0.015,0.119,0],"ti":[0.002,-0.065,0]},{"t":59,"s":[252.597,197.456,0]}],"ix":2,"l":2},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":1,"k":[{"i":{"x":[0.833,0.833,0.833],"y":[0.755,1.005,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.167,0.167,0]},"t":6,"s":[82.502,83.365,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.13,0.857,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.126,0.005,0]},"t":7,"s":[92.567,121.915,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.954,1.043,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.051,0.199,0]},"t":8,"s":[112.053,80.824,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.879,0.874,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.103,0.029,0]},"t":9,"s":[62.128,51.3,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.612,1.126,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.269,0.247,0]},"t":10,"s":[84.48,96.186,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.946,0.869,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.106,0.05,0]},"t":11,"s":[94.531,118.987,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.7,1.007,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.155,0.229,0]},"t":12,"s":[131.266,61.724,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.907,0.775,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.115,0.006,0]},"t":13,"s":[118.417,29.003,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.355,0.999,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.826,0.132,0]},"t":14,"s":[85.075,64.302,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.982,0.898,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.067,-0.001,0]},"t":15,"s":[81.335,124.25,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.912,0.874,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.023,0.465,0]},"t":16,"s":[101.012,65.094,100]},{"i":{"x":[0.833,0.833,0.833],"y":[-1.675,1.303,1]},"o":{"x":[0.167,0.167,0.167],"y":[1.722,0.245,0]},"t":17,"s":[85.57,52.183,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.895,0.888,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.086,0.065,0]},"t":18,"s":[84.785,45.511,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.611,0.792,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.406,0.329,0]},"t":19,"s":[60.369,76.428,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.931,1.274,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.073,0.139,0]},"t":20,"s":[54.064,86.903,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.849,0.976,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.399,0.064,0]},"t":21,"s":[106.629,102.562,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.815,0.921,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.186,-0.033,0]},"t":22,"s":[97.556,35.47,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.903,2.194,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.152,-1.466,0]},"t":23,"s":[90.213,83.359,100]},{"i":{"x":[0.833,0.833,0.833],"y":[3.319,1.048,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.578,0.078,0]},"t":24,"s":[81.292,80.783,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.951,0.967,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.08,0.031,0]},"t":25,"s":[79.79,120.257,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.962,0.971,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.122,-0.054,0]},"t":26,"s":[123.086,57.838,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.232,0.873,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.07,-0.045,0]},"t":27,"s":[105.488,95.827,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.055,0.885,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.061,0.244,0]},"t":28,"s":[115.025,71.124,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.912,1.655,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.033,0.303,0]},"t":29,"s":[78.946,58.294,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.622,0.901,1]},"o":{"x":[0.167,0.167,0.167],"y":[1.606,0.074,0]},"t":30,"s":[138.694,53.428,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.84,0.751,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.073,0.515,0]},"t":31,"s":[141.965,96.547,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.001,1.291,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.174,0.125,0]},"t":32,"s":[114.285,104.867,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.034,0.926,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.001,0.065,0]},"t":33,"s":[88.923,121.377,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.803,1.172,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.024,-0.633,0]},"t":34,"s":[114.574,47.291,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.958,0.952,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.145,0.056,0]},"t":35,"s":[78.466,55.906,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.8,1.065,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.086,-0.111,0]},"t":36,"s":[29.34,29.523,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.943,0.987,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.143,0.036,0]},"t":37,"s":[53.468,40.837,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.752,0.669,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.176,-0.016,0]},"t":38,"s":[87.347,20.728,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.766,1.02,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.126,0.111,0]},"t":39,"s":[76.456,37.629,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.931,0.959,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.129,0.016,0]},"t":40,"s":[54.995,87.783,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.044,1.088,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.403,-0.082,0]},"t":41,"s":[16.235,25.439,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.502,0.976,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.029,0.043,0]},"t":42,"s":[22.876,56.837,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.932,0.922,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.071,-0.033,0]},"t":43,"s":[12.722,-7.888,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.475,0.348,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.373,-1.253,0]},"t":44,"s":[84.051,38.375,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.949,0.884,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.099,0.096,0]},"t":45,"s":[71.021,35.491,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.795,1.365,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.133,0.294,0]},"t":46,"s":[1.946,15.801,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.93,1.015,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.14,0.068,0]},"t":47,"s":[28.567,8.027,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.248,0.922,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.452,0.012,0]},"t":48,"s":[67.532,49.862,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.928,1.749,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.094,-1.199,0]},"t":49,"s":[61.469,0.74,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.813,1.078,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.542,0.075,0]},"t":50,"s":[12.831,3.931,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.066,0.975,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.15,0.04,0]},"t":51,"s":[19.315,-27.951,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.028,0.938,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.037,-0.035,0]},"t":52,"s":[27.419,33.62,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.067,0.472,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.021,-0.242,0]},"t":53,"s":[12.92,-9.628,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.982,1.01,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.037,0.099,0]},"t":54,"s":[32.326,1.452,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.849,0.922,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.022,0.009,0]},"t":55,"s":[-2.724,60.543,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.975,-0.245,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.186,-1.156,0]},"t":56,"s":[24.945,-5.97,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.875,0.971,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.035,0.089,0]},"t":57,"s":[47.481,-1.498,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.833,0.833,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.25,-0.046,0]},"t":58,"s":[31.587,60.823,100]},{"t":59,"s":[23.622,20.517,100]}],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"sr","sy":2,"d":1,"pt":{"a":0,"k":3,"ix":3},"p":{"a":0,"k":[6,32],"ix":4},"r":{"a":0,"k":0,"ix":5},"or":{"a":0,"k":6.599,"ix":7},"os":{"a":0,"k":0,"ix":9},"ix":1,"nm":"Polystar Path 1","mn":"ADBE Vector Shape - Star","hd":false},{"ty":"fl","c":{"a":0,"k":[0.167325382607,0.716982433843,1,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[-7.382,-25.677],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Polystar 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":6,"op":60,"st":6,"bm":0},{"ddd":0,"ind":38,"ty":4,"nm":"Shape Layer 14","sr":1,"ks":{"o":{"a":1,"k":[{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":7,"s":[0]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":12,"s":[100]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":34,"s":[100]},{"t":50,"s":[0]}],"ix":11},"r":{"a":0,"k":0,"ix":10,"x":"var $bm_rt;\n$bm_rt = $bm_mul(time, 300);"},"p":{"a":1,"k":[{"i":{"x":0.833,"y":0.588},"o":{"x":0.167,"y":0.167},"t":7,"s":[276.873,250.83,0],"to":[-0.189,-0.223,0],"ti":[1.057,0.986,0]},{"i":{"x":0.833,"y":0.763},"o":{"x":0.167,"y":0.104},"t":8,"s":[275.739,249.492,0],"to":[-1.057,-0.986,0],"ti":[2.736,1.782,0]},{"i":{"x":0.833,"y":0.824},"o":{"x":0.167,"y":0.129},"t":9,"s":[270.529,244.915,0],"to":[-2.736,-1.782,0],"ti":[4.156,1.618,0]},{"i":{"x":0.833,"y":0.844},"o":{"x":0.167,"y":0.158},"t":10,"s":[259.324,238.803,0],"to":[-4.156,-1.618,0],"ti":[4.345,0.759,0]},{"i":{"x":0.833,"y":0.848},"o":{"x":0.167,"y":0.179},"t":11,"s":[245.595,235.21,0],"to":[-4.345,-0.759,0],"ti":[3.769,0.09,0]},{"i":{"x":0.833,"y":0.847},"o":{"x":0.167,"y":0.184},"t":12,"s":[233.251,234.251,0],"to":[-3.769,-0.09,0],"ti":[3.138,-0.25,0]},{"i":{"x":0.833,"y":0.845},"o":{"x":0.167,"y":0.183},"t":13,"s":[222.982,234.672,0],"to":[-3.138,0.25,0],"ti":[2.634,-0.412,0]},{"i":{"x":0.833,"y":0.844},"o":{"x":0.167,"y":0.181},"t":14,"s":[214.421,235.749,0],"to":[-2.634,0.412,0],"ti":[2.24,-0.491,0]},{"i":{"x":0.833,"y":0.843},"o":{"x":0.167,"y":0.179},"t":15,"s":[207.179,237.146,0],"to":[-2.24,0.491,0],"ti":[1.931,-0.529,0]},{"i":{"x":0.833,"y":0.843},"o":{"x":0.167,"y":0.178},"t":16,"s":[200.979,238.697,0],"to":[-1.931,0.529,0],"ti":[1.683,-0.547,0]},{"i":{"x":0.833,"y":0.842},"o":{"x":0.167,"y":0.177},"t":17,"s":[195.596,240.321,0],"to":[-1.683,0.547,0],"ti":[1.48,-0.555,0]},{"i":{"x":0.833,"y":0.841},"o":{"x":0.167,"y":0.176},"t":18,"s":[190.881,241.977,0],"to":[-1.48,0.555,0],"ti":[1.309,-0.559,0]},{"i":{"x":0.833,"y":0.841},"o":{"x":0.167,"y":0.176},"t":19,"s":[186.716,243.649,0],"to":[-1.309,0.559,0],"ti":[1.16,-0.566,0]},{"i":{"x":0.833,"y":0.84},"o":{"x":0.167,"y":0.175},"t":20,"s":[183.025,245.334,0],"to":[-1.16,0.566,0],"ti":[1.025,-0.581,0]},{"i":{"x":0.833,"y":0.84},"o":{"x":0.167,"y":0.174},"t":21,"s":[179.755,247.045,0],"to":[-1.025,0.581,0],"ti":[0.884,-0.616,0]},{"i":{"x":0.833,"y":0.841},"o":{"x":0.167,"y":0.174},"t":22,"s":[176.877,248.822,0],"to":[-0.884,0.616,0],"ti":[0.678,-0.697,0]},{"i":{"x":0.833,"y":0.847},"o":{"x":0.167,"y":0.175},"t":23,"s":[174.448,250.742,0],"to":[-0.678,0.697,0],"ti":[0.181,-0.747,0]},{"i":{"x":0.833,"y":0.842},"o":{"x":0.167,"y":0.183},"t":24,"s":[172.806,253.001,0],"to":[-0.181,0.747,0],"ti":[-0.395,-0.538,0]},{"i":{"x":0.833,"y":0.844},"o":{"x":0.167,"y":0.177},"t":25,"s":[173.36,255.224,0],"to":[0.395,0.538,0],"ti":[-0.6,-0.241,0]},{"i":{"x":0.833,"y":0.844},"o":{"x":0.167,"y":0.179},"t":26,"s":[175.177,256.227,0],"to":[0.6,0.241,0],"ti":[-0.564,-0.106,0]},{"i":{"x":0.833,"y":0.844},"o":{"x":0.167,"y":0.178},"t":27,"s":[176.961,256.669,0],"to":[0.564,0.106,0],"ti":[-0.502,-0.043,0]},{"i":{"x":0.833,"y":0.844},"o":{"x":0.167,"y":0.179},"t":28,"s":[178.564,256.865,0],"to":[0.502,0.043,0],"ti":[-0.439,-0.008,0]},{"i":{"x":0.833,"y":0.845},"o":{"x":0.167,"y":0.179},"t":29,"s":[179.975,256.928,0],"to":[0.439,0.008,0],"ti":[-0.38,0.014,0]},{"i":{"x":0.833,"y":0.846},"o":{"x":0.167,"y":0.18},"t":30,"s":[181.2,256.911,0],"to":[0.38,-0.014,0],"ti":[-0.325,0.028,0]},{"i":{"x":0.833,"y":0.846},"o":{"x":0.167,"y":0.181},"t":31,"s":[182.253,256.843,0],"to":[0.325,-0.028,0],"ti":[-0.275,0.036,0]},{"i":{"x":0.833,"y":0.847},"o":{"x":0.167,"y":0.182},"t":32,"s":[183.148,256.743,0],"to":[0.275,-0.036,0],"ti":[-0.23,0.04,0]},{"i":{"x":0.833,"y":0.848},"o":{"x":0.167,"y":0.183},"t":33,"s":[183.9,256.626,0],"to":[0.23,-0.04,0],"ti":[-0.19,0.041,0]},{"i":{"x":0.833,"y":0.848},"o":{"x":0.167,"y":0.184},"t":34,"s":[184.526,256.502,0],"to":[0.19,-0.041,0],"ti":[-0.155,0.04,0]},{"i":{"x":0.833,"y":0.85},"o":{"x":0.167,"y":0.185},"t":35,"s":[185.04,256.378,0],"to":[0.155,-0.04,0],"ti":[-0.125,0.037,0]},{"i":{"x":0.833,"y":0.85},"o":{"x":0.167,"y":0.187},"t":36,"s":[185.458,256.261,0],"to":[0.125,-0.037,0],"ti":[-0.1,0.031,0]},{"i":{"x":0.833,"y":0.848},"o":{"x":0.167,"y":0.187},"t":37,"s":[185.791,256.158,0],"to":[0.1,-0.031,0],"ti":[-0.082,0.022,0]},{"i":{"x":0.833,"y":0.838},"o":{"x":0.167,"y":0.185},"t":38,"s":[186.06,256.075,0],"to":[0.082,-0.022,0],"ti":[-0.073,0.001,0]},{"i":{"x":0.833,"y":0.822},"o":{"x":0.167,"y":0.172},"t":39,"s":[186.284,256.027,0],"to":[0.073,-0.001,0],"ti":[-0.069,-0.03,0]},{"i":{"x":0.833,"y":0.817},"o":{"x":0.167,"y":0.157},"t":40,"s":[186.495,256.07,0],"to":[0.069,0.03,0],"ti":[-0.067,-0.059,0]},{"i":{"x":0.833,"y":0.818},"o":{"x":0.167,"y":0.153},"t":41,"s":[186.699,256.205,0],"to":[0.067,0.059,0],"ti":[-0.065,-0.084,0]},{"i":{"x":0.833,"y":0.821},"o":{"x":0.167,"y":0.154},"t":42,"s":[186.897,256.422,0],"to":[0.065,0.084,0],"ti":[-0.063,-0.106,0]},{"i":{"x":0.833,"y":0.824},"o":{"x":0.167,"y":0.156},"t":43,"s":[187.09,256.71,0],"to":[0.063,0.106,0],"ti":[-0.062,-0.125,0]},{"i":{"x":0.833,"y":0.826},"o":{"x":0.167,"y":0.158},"t":44,"s":[187.278,257.059,0],"to":[0.062,0.125,0],"ti":[-0.059,-0.141,0]},{"i":{"x":0.833,"y":0.828},"o":{"x":0.167,"y":0.16},"t":45,"s":[187.46,257.46,0],"to":[0.059,0.141,0],"ti":[-0.057,-0.153,0]},{"i":{"x":0.833,"y":0.829},"o":{"x":0.167,"y":0.162},"t":46,"s":[187.635,257.904,0],"to":[0.057,0.153,0],"ti":[-0.059,-0.163,0]},{"i":{"x":0.833,"y":0.829},"o":{"x":0.167,"y":0.162},"t":47,"s":[187.802,258.381,0],"to":[0.059,0.163,0],"ti":[-0.07,-0.168,0]},{"i":{"x":0.833,"y":0.83},"o":{"x":0.167,"y":0.162},"t":48,"s":[187.988,258.88,0],"to":[0.07,0.168,0],"ti":[-0.084,-0.171,0]},{"i":{"x":0.833,"y":0.831},"o":{"x":0.167,"y":0.164},"t":49,"s":[188.22,259.391,0],"to":[0.084,0.171,0],"ti":[-0.097,-0.171,0]},{"i":{"x":0.833,"y":0.832},"o":{"x":0.167,"y":0.165},"t":50,"s":[188.492,259.907,0],"to":[0.097,0.171,0],"ti":[-0.108,-0.169,0]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.165},"t":51,"s":[188.799,260.419,0],"to":[0.108,0.169,0],"ti":[-0.117,-0.165,0]},{"i":{"x":0.833,"y":0.834},"o":{"x":0.167,"y":0.166},"t":52,"s":[189.138,260.923,0],"to":[0.117,0.165,0],"ti":[-0.125,-0.159,0]},{"i":{"x":0.833,"y":0.835},"o":{"x":0.167,"y":0.167},"t":53,"s":[189.502,261.41,0],"to":[0.125,0.159,0],"ti":[-0.13,-0.151,0]},{"i":{"x":0.833,"y":0.836},"o":{"x":0.167,"y":0.168},"t":54,"s":[189.886,261.876,0],"to":[0.13,0.151,0],"ti":[-0.134,-0.141,0]},{"i":{"x":0.833,"y":0.837},"o":{"x":0.167,"y":0.169},"t":55,"s":[190.284,262.315,0],"to":[0.134,0.141,0],"ti":[-0.135,-0.13,0]},{"i":{"x":0.833,"y":0.838},"o":{"x":0.167,"y":0.17},"t":56,"s":[190.69,262.722,0],"to":[0.135,0.13,0],"ti":[-0.135,-0.117,0]},{"i":{"x":0.833,"y":0.839},"o":{"x":0.167,"y":0.172},"t":57,"s":[191.097,263.093,0],"to":[0.135,0.117,0],"ti":[-0.132,-0.103,0]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.173},"t":58,"s":[191.499,263.423,0],"to":[0.132,0.103,0],"ti":[-0.065,-0.048,0]},{"t":59,"s":[191.888,263.709,0]}],"ix":2,"l":2},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":1,"k":[{"i":{"x":[0.833,0.833,0.833],"y":[0.973,0.541,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.167,0.167,0]},"t":7,"s":[98.652,70.754,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.848,0.518,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.04,0.102,0]},"t":8,"s":[52.407,72.577,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.779,0.997,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.184,0.101,0]},"t":9,"s":[83.757,80.783,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.979,0.867,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.134,-0.003,0]},"t":10,"s":[109.776,120.093,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.877,0.951,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.028,0.222,0]},"t":11,"s":[152.905,82.158,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.679,0.626,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.257,-0.12,0]},"t":12,"s":[120.66,59.419,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.91,0.958,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.113,0.107,0]},"t":13,"s":[105.207,68.721,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.545,0.804,1]},"o":{"x":[0.167,0.167,0.167],"y":[1.14,-0.083,0]},"t":14,"s":[61.164,101.207,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.937,1.003,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.072,0.145,0]},"t":15,"s":[57.69,84.913,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.643,0.859,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.256,0.003,0]},"t":16,"s":[83.888,62.79,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.821,0.798,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.109,0.204,0]},"t":17,"s":[77.451,85.785,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.087,0.898,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.156,0.142,0]},"t":18,"s":[56.288,101.636,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.943,1.927,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.042,0.454,0]},"t":19,"s":[32.111,124.298,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.091,0.877,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.178,0.076,0]},"t":20,"s":[81.428,129.386,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.009,0.967,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.043,0.259,0]},"t":21,"s":[65.685,67.723,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.955,0.799,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.008,-0.054,0]},"t":22,"s":[98.593,38.482,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.732,0.902,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.096,0.142,0]},"t":23,"s":[62.324,56.222,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.936,0.271,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.121,0.546,0]},"t":24,"s":[79.166,81.278,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.932,0.938,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.276,0.094,0]},"t":25,"s":[116.554,85.792,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.592,0.744,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.377,-0.236,0]},"t":26,"s":[107.89,120.793,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.083,0.766,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.073,0.124,0]},"t":27,"s":[109.46,111.669,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.998,0.945,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.042,0.129,0]},"t":28,"s":[96.752,92.8,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.958,0.687,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.002,-0.162,0]},"t":29,"s":[122.109,58.72,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.192,0.866,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.084,0.114,0]},"t":30,"s":[97.302,70.284,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.964,1.117,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.058,0.219,0]},"t":31,"s":[109.621,102.162,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.897,0.962,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.063,0.049,0]},"t":32,"s":[68.943,121.676,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.514,0.947,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.428,-0.071,0]},"t":33,"s":[92.184,74.87,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.944,0.654,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.072,-0.144,0]},"t":34,"s":[97.803,100.145,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.734,0.804,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.175,0.11,0]},"t":35,"s":[57.518,90.885,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.006,0.942,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.121,0.145,0]},"t":36,"s":[70.533,61.657,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.907,0.638,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.006,-0.187,0]},"t":37,"s":[99.011,22.279,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.045,0.875,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.773,0.108,0]},"t":38,"s":[68.391,34.404,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.907,0.968,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.029,0.249,0]},"t":39,"s":[64.692,74.939,100]},{"i":{"x":[0.833,0.833,0.833],"y":[8.194,0.523,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.82,-0.051,0]},"t":40,"s":[70.385,95.308,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.944,0.92,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.082,0.101,0]},"t":41,"s":[71.029,82.647,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.654,0.166,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.168,-2.039,0]},"t":42,"s":[14.793,22.846,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.026,0.906,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.11,0.093,0]},"t":43,"s":[33.445,25.195,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.908,1.479,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.02,0.754,0]},"t":44,"s":[92.281,46.336,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.981,0.951,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.884,0.071,0]},"t":45,"s":[15.049,48.962,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.761,1.318,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.024,-0.118,0]},"t":46,"s":[7.007,31.245,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.054,0.938,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.128,0.066,0]},"t":47,"s":[13.248,38.566,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.957,1.045,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.033,-0.241,0]},"t":48,"s":[24.888,3.264,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.6,1.009,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.09,0.029,0]},"t":49,"s":[5.738,12.341,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.956,0.936,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.105,0.008,0]},"t":50,"s":[14.963,-1.634,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.929,0.639,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.091,-0.275,0]},"t":51,"s":[50.015,13.874,100]},{"i":{"x":[0.833,0.833,0.833],"y":[2.185,1.318,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.46,0.108,0]},"t":52,"s":[33.27,10.27,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.869,0.969,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.078,0.066,0]},"t":53,"s":[35.84,-1.726,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.164,0.842,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.228,-0.049,0]},"t":54,"s":[-3.278,56.039,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.948,1.064,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.055,0.177,0]},"t":55,"s":[-25.859,19.601,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.951,0.938,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.139,0.036,0]},"t":56,"s":[41.265,-12.971,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.325,0.731,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.118,-0.247,0]},"t":57,"s":[16.064,44.564,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.833,0.833,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.066,0.121,0]},"t":58,"s":[26.47,30.06,100]},{"t":59,"s":[-24.557,-2.189,100]}],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[0.012,10.621],[-5.695,5.34],[-2.557,-7.377],[3.15,-2.096]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[1,0.8941176470588236,0.11372549019607843,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[-3.421,4.043],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Rectangle 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":7,"op":60,"st":7,"bm":0},{"ddd":0,"ind":39,"ty":4,"nm":"Shape Layer 13","sr":1,"ks":{"o":{"a":1,"k":[{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":10,"s":[0]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":15,"s":[100]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":37,"s":[100]},{"t":53,"s":[0]}],"ix":11},"r":{"a":0,"k":0,"ix":10,"x":"var $bm_rt;\n$bm_rt = $bm_mul(time, 300);"},"p":{"a":1,"k":[{"i":{"x":0.833,"y":0.569},"o":{"x":0.167,"y":0.167},"t":10,"s":[278.346,255.953,0],"to":[-0.206,-0.234,0],"ti":[0.991,1.248,0]},{"i":{"x":0.833,"y":0.794},"o":{"x":0.167,"y":0.103},"t":11,"s":[277.109,254.547,0],"to":[-0.991,-1.248,0],"ti":[0.533,2.822,0]},{"i":{"x":0.833,"y":0.801},"o":{"x":0.167,"y":0.14},"t":12,"s":[272.4,248.463,0],"to":[-0.533,-2.822,0],"ti":[-2.874,1.607,0]},{"i":{"x":0.833,"y":0.845},"o":{"x":0.167,"y":0.143},"t":13,"s":[273.909,237.613,0],"to":[2.874,-1.607,0],"ti":[-4.877,-0.657,0]},{"i":{"x":0.833,"y":0.848},"o":{"x":0.167,"y":0.18},"t":14,"s":[289.644,238.823,0],"to":[4.877,0.657,0],"ti":[-4.118,-0.868,0]},{"i":{"x":0.833,"y":0.847},"o":{"x":0.167,"y":0.184},"t":15,"s":[303.171,241.554,0],"to":[4.118,0.868,0],"ti":[-3.424,-0.757,0]},{"i":{"x":0.833,"y":0.846},"o":{"x":0.167,"y":0.183},"t":16,"s":[314.351,244.031,0],"to":[3.424,0.757,0],"ti":[-2.889,-0.625,0]},{"i":{"x":0.833,"y":0.845},"o":{"x":0.167,"y":0.181},"t":17,"s":[323.717,246.095,0],"to":[2.889,0.625,0],"ti":[-2.477,-0.51,0]},{"i":{"x":0.833,"y":0.844},"o":{"x":0.167,"y":0.18},"t":18,"s":[331.687,247.782,0],"to":[2.477,0.51,0],"ti":[-2.154,-0.414,0]},{"i":{"x":0.833,"y":0.843},"o":{"x":0.167,"y":0.179},"t":19,"s":[338.58,249.155,0],"to":[2.154,0.414,0],"ti":[-1.892,-0.333,0]},{"i":{"x":0.833,"y":0.843},"o":{"x":0.167,"y":0.178},"t":20,"s":[344.611,250.265,0],"to":[1.892,0.333,0],"ti":[-1.675,-0.265,0]},{"i":{"x":0.833,"y":0.842},"o":{"x":0.167,"y":0.177},"t":21,"s":[349.933,251.155,0],"to":[1.675,0.265,0],"ti":[-1.493,-0.207,0]},{"i":{"x":0.833,"y":0.841},"o":{"x":0.167,"y":0.177},"t":22,"s":[354.663,251.856,0],"to":[1.493,0.207,0],"ti":[-1.344,-0.162,0]},{"i":{"x":0.833,"y":0.84},"o":{"x":0.167,"y":0.176},"t":23,"s":[358.894,252.395,0],"to":[1.344,0.162,0],"ti":[-1.227,-0.138,0]},{"i":{"x":0.833,"y":0.84},"o":{"x":0.167,"y":0.174},"t":24,"s":[362.724,252.827,0],"to":[1.227,0.138,0],"ti":[-1.133,-0.127,0]},{"i":{"x":0.833,"y":0.839},"o":{"x":0.167,"y":0.173},"t":25,"s":[366.255,253.222,0],"to":[1.133,0.127,0],"ti":[-1.051,-0.117,0]},{"i":{"x":0.833,"y":0.839},"o":{"x":0.167,"y":0.173},"t":26,"s":[369.523,253.587,0],"to":[1.051,0.117,0],"ti":[-0.978,-0.109,0]},{"i":{"x":0.833,"y":0.839},"o":{"x":0.167,"y":0.173},"t":27,"s":[372.561,253.926,0],"to":[0.978,0.109,0],"ti":[-0.912,-0.101,0]},{"i":{"x":0.833,"y":0.839},"o":{"x":0.167,"y":0.173},"t":28,"s":[375.391,254.24,0],"to":[0.912,0.101,0],"ti":[-0.852,-0.093,0]},{"i":{"x":0.833,"y":0.839},"o":{"x":0.167,"y":0.173},"t":29,"s":[378.033,254.529,0],"to":[0.852,0.093,0],"ti":[-0.796,-0.085,0]},{"i":{"x":0.833,"y":0.839},"o":{"x":0.167,"y":0.172},"t":30,"s":[380.502,254.795,0],"to":[0.796,0.085,0],"ti":[-0.745,-0.076,0]},{"i":{"x":0.833,"y":0.839},"o":{"x":0.167,"y":0.172},"t":31,"s":[382.812,255.037,0],"to":[0.745,0.076,0],"ti":[-0.697,-0.068,0]},{"i":{"x":0.833,"y":0.839},"o":{"x":0.167,"y":0.172},"t":32,"s":[384.972,255.253,0],"to":[0.697,0.068,0],"ti":[-0.651,-0.059,0]},{"i":{"x":0.833,"y":0.839},"o":{"x":0.167,"y":0.173},"t":33,"s":[386.993,255.443,0],"to":[0.651,0.059,0],"ti":[-0.608,-0.05,0]},{"i":{"x":0.833,"y":0.839},"o":{"x":0.167,"y":0.173},"t":34,"s":[388.881,255.606,0],"to":[0.608,0.05,0],"ti":[-0.567,-0.04,0]},{"i":{"x":0.833,"y":0.839},"o":{"x":0.167,"y":0.173},"t":35,"s":[390.642,255.741,0],"to":[0.567,0.04,0],"ti":[-0.527,-0.03,0]},{"i":{"x":0.833,"y":0.84},"o":{"x":0.167,"y":0.173},"t":36,"s":[392.281,255.845,0],"to":[0.527,0.03,0],"ti":[-0.488,-0.019,0]},{"i":{"x":0.833,"y":0.84},"o":{"x":0.167,"y":0.174},"t":37,"s":[393.801,255.919,0],"to":[0.488,0.019,0],"ti":[-0.45,-0.008,0]},{"i":{"x":0.833,"y":0.841},"o":{"x":0.167,"y":0.174},"t":38,"s":[395.207,255.96,0],"to":[0.45,0.008,0],"ti":[-0.412,0.002,0]},{"i":{"x":0.833,"y":0.841},"o":{"x":0.167,"y":0.175},"t":39,"s":[396.499,255.969,0],"to":[0.412,-0.002,0],"ti":[-0.375,0.014,0]},{"i":{"x":0.833,"y":0.842},"o":{"x":0.167,"y":0.175},"t":40,"s":[397.679,255.945,0],"to":[0.375,-0.014,0],"ti":[-0.338,0.025,0]},{"i":{"x":0.833,"y":0.843},"o":{"x":0.167,"y":0.176},"t":41,"s":[398.748,255.888,0],"to":[0.338,-0.025,0],"ti":[-0.301,0.036,0]},{"i":{"x":0.833,"y":0.843},"o":{"x":0.167,"y":0.177},"t":42,"s":[399.706,255.797,0],"to":[0.301,-0.036,0],"ti":[-0.264,0.047,0]},{"i":{"x":0.833,"y":0.844},"o":{"x":0.167,"y":0.178},"t":43,"s":[400.554,255.673,0],"to":[0.264,-0.047,0],"ti":[-0.227,0.057,0]},{"i":{"x":0.833,"y":0.846},"o":{"x":0.167,"y":0.18},"t":44,"s":[401.291,255.517,0],"to":[0.227,-0.057,0],"ti":[-0.189,0.067,0]},{"i":{"x":0.833,"y":0.847},"o":{"x":0.167,"y":0.181},"t":45,"s":[401.915,255.33,0],"to":[0.189,-0.067,0],"ti":[-0.151,0.077,0]},{"i":{"x":0.833,"y":0.847},"o":{"x":0.167,"y":0.183},"t":46,"s":[402.427,255.112,0],"to":[0.151,-0.077,0],"ti":[-0.113,0.086,0]},{"i":{"x":0.833,"y":0.845},"o":{"x":0.167,"y":0.183},"t":47,"s":[402.824,254.867,0],"to":[0.113,-0.086,0],"ti":[-0.074,0.094,0]},{"i":{"x":0.833,"y":0.838},"o":{"x":0.167,"y":0.18},"t":48,"s":[403.104,254.596,0],"to":[0.074,-0.094,0],"ti":[-0.034,0.101,0]},{"i":{"x":0.833,"y":0.828},"o":{"x":0.167,"y":0.172},"t":49,"s":[403.266,254.302,0],"to":[0.034,-0.101,0],"ti":[0.002,0.108,0]},{"i":{"x":0.833,"y":0.825},"o":{"x":0.167,"y":0.162},"t":50,"s":[403.307,253.989,0],"to":[-0.002,-0.108,0],"ti":[0.028,0.114,0]},{"i":{"x":0.833,"y":0.825},"o":{"x":0.167,"y":0.16},"t":51,"s":[403.256,253.656,0],"to":[-0.028,-0.114,0],"ti":[0.05,0.119,0]},{"i":{"x":0.833,"y":0.831},"o":{"x":0.167,"y":0.159},"t":52,"s":[403.14,253.307,0],"to":[-0.05,-0.119,0],"ti":[0.071,0.117,0]},{"i":{"x":0.833,"y":0.842},"o":{"x":0.167,"y":0.165},"t":53,"s":[402.953,252.945,0],"to":[-0.071,-0.117,0],"ti":[0.081,0.103,0]},{"i":{"x":0.833,"y":0.842},"o":{"x":0.167,"y":0.177},"t":54,"s":[402.716,252.603,0],"to":[-0.081,-0.103,0],"ti":[0.084,0.081,0]},{"i":{"x":0.833,"y":0.841},"o":{"x":0.167,"y":0.176},"t":55,"s":[402.468,252.328,0],"to":[-0.084,-0.081,0],"ti":[0.087,0.059,0]},{"i":{"x":0.833,"y":0.838},"o":{"x":0.167,"y":0.175},"t":56,"s":[402.212,252.118,0],"to":[-0.087,-0.059,0],"ti":[0.089,0.038,0]},{"i":{"x":0.833,"y":0.835},"o":{"x":0.167,"y":0.172},"t":57,"s":[401.947,251.973,0],"to":[-0.089,-0.038,0],"ti":[0.091,0.018,0]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.168},"t":58,"s":[401.676,251.89,0],"to":[-0.091,-0.018,0],"ti":[0.046,0.004,0]},{"t":59,"s":[401.399,251.868,0]}],"ix":2,"l":2},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":1,"k":[{"i":{"x":[0.833,0.833,0.833],"y":[0.884,0.999,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.167,0.167,0]},"t":10,"s":[150.023,84.302,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.718,0.778,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.295,-0.001,0]},"t":11,"s":[115.536,94.772,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.873,0.898,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.118,0.133,0]},"t":12,"s":[101.947,84.391,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.035,1.291,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.243,0.465,0]},"t":13,"s":[69.48,67.085,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.989,0.813,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.025,0.065,0]},"t":14,"s":[52.556,63.303,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.856,0.84,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.012,0.15,0]},"t":15,"s":[76.674,80.31,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.805,0.955,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.197,0.174,0]},"t":16,"s":[55.698,101.414,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.152,1.045,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.145,-0.098,0]},"t":17,"s":[40.385,120.878,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.954,1.219,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.054,0.029,0]},"t":18,"s":[19.82,111.938,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.016,0.871,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.102,0.06,0]},"t":19,"s":[78.004,125.754,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.949,0.912,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.013,0.234,0]},"t":20,"s":[51.803,75.606,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.062,0.955,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.135,1.443,0]},"t":21,"s":[82.948,47.853,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.885,-0.218,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.035,-0.097,0]},"t":22,"s":[71.038,46.152,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.784,0.414,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.302,0.089,0]},"t":23,"s":[91.761,46.937,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.922,0.919,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.136,0.097,0]},"t":24,"s":[99.655,57.625,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.883,0.788,1]},"o":{"x":[0.167,0.167,0.167],"y":[-1.133,-3.004,0]},"t":25,"s":[112.222,122.076,100]},{"i":{"x":[0.833,0.833,0.833],"y":[4.25,0.248,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.289,0.137,0]},"t":26,"s":[111.361,120.336,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.223,0.834,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.081,0.094,0]},"t":27,"s":[111.012,117.65,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.926,1.003,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.061,0.167,0]},"t":28,"s":[124.959,96.101,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.488,0.968,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.648,0.003,0]},"t":29,"s":[73.624,74.745,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.933,0.958,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.071,-0.053,0]},"t":30,"s":[79.474,96.927,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.44,0.823,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.337,-0.086,0]},"t":31,"s":[39.363,83.33,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.955,0.354,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.098,0.158,0]},"t":32,"s":[47.32,90.031,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.968,0.971,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.099,0.096,0]},"t":33,"s":[92.847,97.529,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.719,0.771,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.051,-0.044,0]},"t":34,"s":[72.017,148.186,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.036,0.926,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.118,0.131,0]},"t":35,"s":[84.935,114.918,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.917,1.042,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.025,-0.658,0]},"t":36,"s":[115.633,56.769,100]},{"i":{"x":[0.833,0.833,0.833],"y":[-3.33,1.406,1]},"o":{"x":[0.167,0.167,0.167],"y":[-14.6,0.028,0]},"t":37,"s":[71.733,63.301,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.842,0.897,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.085,0.069,0]},"t":38,"s":[71.982,53.5,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.296,1.262,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.177,0.435,0]},"t":39,"s":[84.68,111.004,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.873,0.865,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.065,0.063,0]},"t":40,"s":[96.026,124.642,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.082,0.913,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.241,0.218,0]},"t":41,"s":[44.359,68.117,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.979,1.947,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.041,1.94,0]},"t":42,"s":[16.99,33.186,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.848,0.777,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.028,0.077,0]},"t":43,"s":[71.31,31.618,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.963,1.037,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.185,0.133,0]},"t":44,"s":[30.563,51.009,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.958,0.898,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.066,0.026,0]},"t":45,"s":[-2.843,83.398,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.188,0.937,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.084,0.448,0]},"t":46,"s":[15.771,36.624,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.924,-0.025,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.058,-0.252,0]},"t":47,"s":[6.523,25.933,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.308,1.025,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.823,0.091,0]},"t":48,"s":[36.606,28.591,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.857,0.867,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.066,0.019,0]},"t":49,"s":[33.84,58.624,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.022,0.96,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.199,0.224,0]},"t":50,"s":[46.823,19.638,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.667,0.763,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.018,-0.078,0]},"t":51,"s":[56.147,-3.469,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.868,0.839,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.111,0.129,0]},"t":52,"s":[44.338,8.441,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.071,1.087,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.227,0.172,0]},"t":53,"s":[8.892,30.372,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.016,0.969,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.038,0.043,0]},"t":54,"s":[-11.629,50.878,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.012,0.823,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.013,-0.049,0]},"t":55,"s":[26.385,8.842,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.938,1.146,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.011,0.158,0]},"t":56,"s":[-18.969,35.342,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.128,0.932,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.244,0.053,0]},"t":57,"s":[33.051,65.059,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.833,0.833,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.051,-0.359,0]},"t":58,"s":[19.792,-16.798,100]},{"t":59,"s":[53.472,-1.378,100]}],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[0.012,10.621],[-5.695,5.34],[-2.557,-7.377],[3.15,-2.096]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[1,0.8941176470588236,0.11372549019607843,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[-3.421,4.043],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Rectangle 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":10,"op":60,"st":10,"bm":0},{"ddd":0,"ind":40,"ty":4,"nm":"Shape Layer 12","sr":1,"ks":{"o":{"a":1,"k":[{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":11,"s":[0]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":16,"s":[100]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":38,"s":[100]},{"t":54,"s":[0]}],"ix":11},"r":{"a":0,"k":0,"ix":10,"x":"var $bm_rt;\n$bm_rt = $bm_mul(time, 300);"},"p":{"a":1,"k":[{"i":{"x":0.833,"y":0.583},"o":{"x":0.167,"y":0.167},"t":11,"s":[278.886,263.108,0],"to":[-0.396,0.093,0],"ti":[2.046,0.295,0]},{"i":{"x":0.833,"y":0.762},"o":{"x":0.167,"y":0.104},"t":12,"s":[276.51,263.668,0],"to":[-2.046,-0.295,0],"ti":[4.357,2.028,0]},{"i":{"x":0.833,"y":0.824},"o":{"x":0.167,"y":0.128},"t":13,"s":[266.607,261.339,0],"to":[-4.357,-2.028,0],"ti":[5.491,3.808,0]},{"i":{"x":0.833,"y":0.844},"o":{"x":0.167,"y":0.158},"t":14,"s":[250.369,251.499,0],"to":[-5.491,-3.808,0],"ti":[5.141,4.15,0]},{"i":{"x":0.833,"y":0.847},"o":{"x":0.167,"y":0.179},"t":15,"s":[233.659,238.49,0],"to":[-5.141,-4.15,0],"ti":[4.303,3.645,0]},{"i":{"x":0.833,"y":0.847},"o":{"x":0.167,"y":0.184},"t":16,"s":[219.52,226.601,0],"to":[-4.303,-3.645,0],"ti":[3.589,3.053,0]},{"i":{"x":0.833,"y":0.845},"o":{"x":0.167,"y":0.183},"t":17,"s":[207.84,216.618,0],"to":[-3.589,-3.053,0],"ti":[3.064,2.559,0]},{"i":{"x":0.833,"y":0.844},"o":{"x":0.167,"y":0.181},"t":18,"s":[197.984,208.283,0],"to":[-3.064,-2.559,0],"ti":[2.675,2.164,0]},{"i":{"x":0.833,"y":0.843},"o":{"x":0.167,"y":0.179},"t":19,"s":[189.458,201.262,0],"to":[-2.675,-2.164,0],"ti":[2.381,1.843,0]},{"i":{"x":0.833,"y":0.842},"o":{"x":0.167,"y":0.178},"t":20,"s":[181.933,195.3,0],"to":[-2.381,-1.843,0],"ti":[2.153,1.575,0]},{"i":{"x":0.833,"y":0.842},"o":{"x":0.167,"y":0.177},"t":21,"s":[175.17,190.206,0],"to":[-2.153,-1.575,0],"ti":[1.971,1.344,0]},{"i":{"x":0.833,"y":0.841},"o":{"x":0.167,"y":0.176},"t":22,"s":[169.014,185.852,0],"to":[-1.971,-1.344,0],"ti":[1.824,1.14,0]},{"i":{"x":0.833,"y":0.841},"o":{"x":0.167,"y":0.175},"t":23,"s":[163.346,182.143,0],"to":[-1.824,-1.14,0],"ti":[1.703,0.954,0]},{"i":{"x":0.833,"y":0.84},"o":{"x":0.167,"y":0.175},"t":24,"s":[158.073,179.012,0],"to":[-1.703,-0.954,0],"ti":[1.601,0.778,0]},{"i":{"x":0.833,"y":0.84},"o":{"x":0.167,"y":0.174},"t":25,"s":[153.127,176.419,0],"to":[-1.601,-0.778,0],"ti":[1.512,0.605,0]},{"i":{"x":0.833,"y":0.84},"o":{"x":0.167,"y":0.174},"t":26,"s":[148.464,174.344,0],"to":[-1.512,-0.605,0],"ti":[1.427,0.431,0]},{"i":{"x":0.833,"y":0.84},"o":{"x":0.167,"y":0.174},"t":27,"s":[144.054,172.787,0],"to":[-1.427,-0.431,0],"ti":[1.341,0.252,0]},{"i":{"x":0.833,"y":0.841},"o":{"x":0.167,"y":0.174},"t":28,"s":[139.899,171.759,0],"to":[-1.341,-0.252,0],"ti":[1.243,0.072,0]},{"i":{"x":0.833,"y":0.841},"o":{"x":0.167,"y":0.175},"t":29,"s":[136.007,171.274,0],"to":[-1.243,-0.072,0],"ti":[1.123,-0.099,0]},{"i":{"x":0.833,"y":0.842},"o":{"x":0.167,"y":0.176},"t":30,"s":[132.443,171.328,0],"to":[-1.123,0.099,0],"ti":[0.987,-0.242,0]},{"i":{"x":0.833,"y":0.842},"o":{"x":0.167,"y":0.176},"t":31,"s":[129.266,171.868,0],"to":[-0.987,0.242,0],"ti":[0.844,-0.342,0]},{"i":{"x":0.833,"y":0.842},"o":{"x":0.167,"y":0.177},"t":32,"s":[126.522,172.779,0],"to":[-0.844,0.342,0],"ti":[0.71,-0.397,0]},{"i":{"x":0.833,"y":0.843},"o":{"x":0.167,"y":0.177},"t":33,"s":[124.202,173.921,0],"to":[-0.71,0.397,0],"ti":[0.594,-0.415,0]},{"i":{"x":0.833,"y":0.843},"o":{"x":0.167,"y":0.177},"t":34,"s":[122.26,175.163,0],"to":[-0.594,0.415,0],"ti":[0.499,-0.407,0]},{"i":{"x":0.833,"y":0.843},"o":{"x":0.167,"y":0.177},"t":35,"s":[120.635,176.409,0],"to":[-0.499,0.407,0],"ti":[0.424,-0.386,0]},{"i":{"x":0.833,"y":0.843},"o":{"x":0.167,"y":0.177},"t":36,"s":[119.263,177.604,0],"to":[-0.424,0.386,0],"ti":[0.362,-0.359,0]},{"i":{"x":0.833,"y":0.843},"o":{"x":0.167,"y":0.177},"t":37,"s":[118.093,178.724,0],"to":[-0.362,0.359,0],"ti":[0.312,-0.329,0]},{"i":{"x":0.833,"y":0.843},"o":{"x":0.167,"y":0.177},"t":38,"s":[117.089,179.757,0],"to":[-0.312,0.329,0],"ti":[0.271,-0.298,0]},{"i":{"x":0.833,"y":0.843},"o":{"x":0.167,"y":0.177},"t":39,"s":[116.22,180.697,0],"to":[-0.271,0.298,0],"ti":[0.237,-0.267,0]},{"i":{"x":0.833,"y":0.843},"o":{"x":0.167,"y":0.177},"t":40,"s":[115.461,181.543,0],"to":[-0.237,0.267,0],"ti":[0.208,-0.237,0]},{"i":{"x":0.833,"y":0.844},"o":{"x":0.167,"y":0.178},"t":41,"s":[114.796,182.298,0],"to":[-0.208,0.237,0],"ti":[0.183,-0.208,0]},{"i":{"x":0.833,"y":0.844},"o":{"x":0.167,"y":0.178},"t":42,"s":[114.21,182.964,0],"to":[-0.183,0.208,0],"ti":[0.161,-0.181,0]},{"i":{"x":0.833,"y":0.845},"o":{"x":0.167,"y":0.179},"t":43,"s":[113.696,183.547,0],"to":[-0.161,0.181,0],"ti":[0.141,-0.155,0]},{"i":{"x":0.833,"y":0.846},"o":{"x":0.167,"y":0.18},"t":44,"s":[113.244,184.051,0],"to":[-0.141,0.155,0],"ti":[0.122,-0.131,0]},{"i":{"x":0.833,"y":0.847},"o":{"x":0.167,"y":0.182},"t":45,"s":[112.85,184.48,0],"to":[-0.122,0.131,0],"ti":[0.105,-0.107,0]},{"i":{"x":0.833,"y":0.849},"o":{"x":0.167,"y":0.183},"t":46,"s":[112.51,184.835,0],"to":[-0.105,0.107,0],"ti":[0.089,-0.086,0]},{"i":{"x":0.833,"y":0.851},"o":{"x":0.167,"y":0.186},"t":47,"s":[112.219,185.124,0],"to":[-0.089,0.086,0],"ti":[0.074,-0.066,0]},{"i":{"x":0.833,"y":0.855},"o":{"x":0.167,"y":0.19},"t":48,"s":[111.973,185.351,0],"to":[-0.074,0.066,0],"ti":[0.06,-0.046,0]},{"i":{"x":0.833,"y":0.861},"o":{"x":0.167,"y":0.197},"t":49,"s":[111.772,185.518,0],"to":[-0.06,0.046,0],"ti":[0.046,-0.026,0]},{"i":{"x":0.833,"y":0.855},"o":{"x":0.167,"y":0.208},"t":50,"s":[111.612,185.626,0],"to":[-0.046,0.026,0],"ti":[0.035,-0.013,0]},{"i":{"x":0.833,"y":0.838},"o":{"x":0.167,"y":0.196},"t":51,"s":[111.493,185.674,0],"to":[-0.035,0.013,0],"ti":[0.027,-0.015,0]},{"i":{"x":0.833,"y":0.831},"o":{"x":0.167,"y":0.171},"t":52,"s":[111.404,185.706,0],"to":[-0.027,0.015,0],"ti":[0.021,-0.022,0]},{"i":{"x":0.833,"y":0.827},"o":{"x":0.167,"y":0.164},"t":53,"s":[111.333,185.761,0],"to":[-0.021,0.022,0],"ti":[0.015,-0.028,0]},{"i":{"x":0.833,"y":0.827},"o":{"x":0.167,"y":0.161},"t":54,"s":[111.28,185.837,0],"to":[-0.015,0.028,0],"ti":[0.01,-0.033,0]},{"i":{"x":0.833,"y":0.828},"o":{"x":0.167,"y":0.16},"t":55,"s":[111.243,185.929,0],"to":[-0.01,0.033,0],"ti":[0.006,-0.036,0]},{"i":{"x":0.833,"y":0.831},"o":{"x":0.167,"y":0.162},"t":56,"s":[111.219,186.034,0],"to":[-0.006,0.036,0],"ti":[0.003,-0.038,0]},{"i":{"x":0.833,"y":0.834},"o":{"x":0.167,"y":0.164},"t":57,"s":[111.207,186.148,0],"to":[-0.003,0.038,0],"ti":[0,-0.039,0]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.168},"t":58,"s":[111.203,186.265,0],"to":[0,0.039,0],"ti":[0,-0.019,0]},{"t":59,"s":[111.205,186.381,0]}],"ix":2,"l":2},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":1,"k":[{"i":{"x":[0.833,0.833,0.833],"y":[0.894,0.956,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.167,0.167,0]},"t":11,"s":[70.371,73.099,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.011,0.757,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.387,-0.094,0]},"t":12,"s":[81.451,49.764,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.535,0.952,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.009,0.127,0]},"t":13,"s":[84.489,60.747,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.814,1.157,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.102,-0.112,0]},"t":14,"s":[81.065,81.842,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.818,1.017,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.151,0.054,0]},"t":15,"s":[65.388,72.828,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.16,0.963,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.154,0.014,0]},"t":16,"s":[46.083,98.818,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.958,0.997,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.055,-0.066,0]},"t":17,"s":[23.236,67.65,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.924,0.911,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.086,-0.003,0]},"t":18,"s":[90.073,84.988,100]},{"i":{"x":[0.833,0.833,0.833],"y":[-0.171,1.805,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.806,1.29,0]},"t":19,"s":[57.21,68.22,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.965,1.029,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.09,0.076,0]},"t":20,"s":[60.29,67.062,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.989,0.774,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.06,0.022,0]},"t":21,"s":[100.479,79.398,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.87,1.114,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.013,0.132,0]},"t":22,"s":[77.165,62.735,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.823,0.904,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.233,0.048,0]},"t":23,"s":[97.376,34.245,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.123,1.029,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.158,0.644,0]},"t":24,"s":[108.611,101.546,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.977,0.834,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.05,0.021,0]},"t":25,"s":[121.197,111.543,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.983,1.031,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.031,0.167,0]},"t":26,"s":[90.035,98.098,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.835,0.878,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.021,0.023,0]},"t":27,"s":[112.765,84.756,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.751,1.24,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.168,0.262,0]},"t":28,"s":[94.608,103.076,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.906,0.913,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.125,0.062,0]},"t":29,"s":[76.78,111.632,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.547,-0.27,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.74,2.198,0]},"t":30,"s":[41.278,78.465,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.914,1.188,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.072,0.089,0]},"t":31,"s":[36.77,77.159,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.441,0.893,1]},"o":{"x":[0.167,0.167,0.167],"y":[2.649,0.058,0]},"t":32,"s":[70.888,58.554,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.52,1.21,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.098,0.377,0]},"t":33,"s":[71.996,119.193,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.977,0.952,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.101,0.06,0]},"t":34,"s":[78.321,136.39,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.766,1.004,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.031,-0.115,0]},"t":35,"s":[108.467,75.868,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.959,0.964,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.129,0.004,0]},"t":36,"s":[86.503,101.319,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.84,0.825,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.079,-0.062,0]},"t":37,"s":[46.776,74.608,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.98,0.909,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.174,0.159,0]},"t":38,"s":[67.187,89.94,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.699,3.529,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.027,1.019,0]},"t":39,"s":[85.904,106.857,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.914,0.895,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.115,0.081,0]},"t":40,"s":[71.727,108.364,100]},{"i":{"x":[0.833,0.833,0.833],"y":[2.929,0.834,1]},"o":{"x":[0.167,0.167,0.167],"y":[2.984,0.401,0]},"t":41,"s":[34.691,61.105,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.084,1.299,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.08,0.168,0]},"t":42,"s":[33.627,48.718,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.001,0.999,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.042,0.065,0]},"t":43,"s":[59.325,36.458,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.944,0.928,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.001,-0.001,0]},"t":44,"s":[7.81,92.753,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.889,0.958,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.168,-0.538,0]},"t":45,"s":[59.941,37.215,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.33,1.334,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.335,-0.083,0]},"t":46,"s":[42.632,44.663,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.973,0.992,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.067,0.067,0]},"t":47,"s":[36.89,40.928,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.915,0.649,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.04,-0.009,0]},"t":48,"s":[65.36,59.633,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.366,0.982,1]},"o":{"x":[0.167,0.167,0.167],"y":[3.347,0.109,0]},"t":49,"s":[46.071,42.784,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.284,0.916,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.096,-0.023,0]},"t":50,"s":[45.578,-11.282,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.976,-5.807,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.094,7.457,0]},"t":51,"s":[42.321,31.087,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.91,0.98,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.033,0.084,0]},"t":52,"s":[17.582,31.566,100]},{"i":{"x":[0.833,0.833,0.833],"y":[4.784,0.838,1]},"o":{"x":[0.167,0.167,0.167],"y":[1.171,-0.026,0]},"t":53,"s":[35.243,70.197,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.95,1.095,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.082,0.171,0]},"t":54,"s":[36.597,40.776,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.762,1.02,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.123,0.044,0]},"t":55,"s":[-26.212,12.89,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.889,0.94,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.128,0.016,0]},"t":56,"s":[-0.792,72.669,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.804,0.91,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.335,-0.211,0]},"t":57,"s":[46.469,-1.813,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.833,0.833,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.145,1.163,0]},"t":58,"s":[62.111,19.267,100]},{"t":59,"s":[83.28,20.895,100]}],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[0.012,10.621],[-5.695,5.34],[-2.557,-7.377],[3.15,-2.096]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[1,0.8941176470588236,0.11372549019607843,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[-3.421,4.043],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Rectangle 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":11,"op":60,"st":11,"bm":0},{"ddd":0,"ind":41,"ty":4,"nm":"Shape Layer 11","sr":1,"ks":{"o":{"a":1,"k":[{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":5,"s":[0]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":10,"s":[100]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":32,"s":[100]},{"t":48,"s":[0]}],"ix":11},"r":{"a":0,"k":0,"ix":10,"x":"var $bm_rt;\n$bm_rt = $bm_mul(time, 300);"},"p":{"a":1,"k":[{"i":{"x":0.833,"y":0.595},"o":{"x":0.167,"y":0.167},"t":5,"s":[280.895,263.305,0],"to":[-0.567,0.175,0],"ti":[2.713,-0.992,0]},{"i":{"x":0.833,"y":0.764},"o":{"x":0.167,"y":0.105},"t":6,"s":[277.49,264.357,0],"to":[-2.713,0.992,0],"ti":[6.007,-2.449,0]},{"i":{"x":0.833,"y":0.824},"o":{"x":0.167,"y":0.129},"t":7,"s":[264.618,269.257,0],"to":[-6.007,2.449,0],"ti":[8.091,-3.591,0]},{"i":{"x":0.833,"y":0.844},"o":{"x":0.167,"y":0.158},"t":8,"s":[241.447,279.054,0],"to":[-8.091,3.591,0],"ti":[7.873,-3.782,0]},{"i":{"x":0.833,"y":0.847},"o":{"x":0.167,"y":0.179},"t":9,"s":[216.07,290.801,0],"to":[-7.873,3.782,0],"ti":[6.648,-3.432,0]},{"i":{"x":0.833,"y":0.846},"o":{"x":0.167,"y":0.183},"t":10,"s":[194.207,301.746,0],"to":[-6.648,3.432,0],"ti":[5.503,-3.025,0]},{"i":{"x":0.833,"y":0.845},"o":{"x":0.167,"y":0.182},"t":11,"s":[176.185,311.391,0],"to":[-5.503,3.025,0],"ti":[4.615,-2.683,0]},{"i":{"x":0.833,"y":0.844},"o":{"x":0.167,"y":0.18},"t":12,"s":[161.19,319.898,0],"to":[-4.615,2.683,0],"ti":[3.935,-2.407,0]},{"i":{"x":0.833,"y":0.843},"o":{"x":0.167,"y":0.179},"t":13,"s":[148.493,327.489,0],"to":[-3.935,2.407,0],"ti":[3.399,-2.181,0]},{"i":{"x":0.833,"y":0.843},"o":{"x":0.167,"y":0.178},"t":14,"s":[137.583,334.338,0],"to":[-3.399,2.181,0],"ti":[2.966,-1.993,0]},{"i":{"x":0.833,"y":0.842},"o":{"x":0.167,"y":0.177},"t":15,"s":[128.098,340.575,0],"to":[-2.966,1.993,0],"ti":[2.607,-1.832,0]},{"i":{"x":0.833,"y":0.842},"o":{"x":0.167,"y":0.176},"t":16,"s":[119.785,346.294,0],"to":[-2.607,1.832,0],"ti":[2.302,-1.693,0]},{"i":{"x":0.833,"y":0.842},"o":{"x":0.167,"y":0.176},"t":17,"s":[112.455,351.568,0],"to":[-2.302,1.693,0],"ti":[2.037,-1.57,0]},{"i":{"x":0.833,"y":0.841},"o":{"x":0.167,"y":0.176},"t":18,"s":[105.973,356.452,0],"to":[-2.037,1.57,0],"ti":[1.803,-1.461,0]},{"i":{"x":0.833,"y":0.841},"o":{"x":0.167,"y":0.176},"t":19,"s":[100.233,360.99,0],"to":[-1.803,1.461,0],"ti":[1.593,-1.362,0]},{"i":{"x":0.833,"y":0.841},"o":{"x":0.167,"y":0.176},"t":20,"s":[95.155,365.216,0],"to":[-1.593,1.362,0],"ti":[1.401,-1.271,0]},{"i":{"x":0.833,"y":0.842},"o":{"x":0.167,"y":0.176},"t":21,"s":[90.677,369.16,0],"to":[-1.401,1.271,0],"ti":[1.224,-1.188,0]},{"i":{"x":0.833,"y":0.841},"o":{"x":0.167,"y":0.176},"t":22,"s":[86.752,372.844,0],"to":[-1.224,1.188,0],"ti":[1.07,-1.108,0]},{"i":{"x":0.833,"y":0.841},"o":{"x":0.167,"y":0.175},"t":23,"s":[83.332,376.285,0],"to":[-1.07,1.108,0],"ti":[0.937,-1.031,0]},{"i":{"x":0.833,"y":0.841},"o":{"x":0.167,"y":0.175},"t":24,"s":[80.332,379.49,0],"to":[-0.937,1.031,0],"ti":[0.818,-0.958,0]},{"i":{"x":0.833,"y":0.841},"o":{"x":0.167,"y":0.176},"t":25,"s":[77.708,382.471,0],"to":[-0.818,0.958,0],"ti":[0.71,-0.889,0]},{"i":{"x":0.833,"y":0.842},"o":{"x":0.167,"y":0.176},"t":26,"s":[75.425,385.239,0],"to":[-0.71,0.889,0],"ti":[0.612,-0.822,0]},{"i":{"x":0.833,"y":0.842},"o":{"x":0.167,"y":0.176},"t":27,"s":[73.45,387.804,0],"to":[-0.612,0.822,0],"ti":[0.523,-0.758,0]},{"i":{"x":0.833,"y":0.842},"o":{"x":0.167,"y":0.176},"t":28,"s":[71.754,390.173,0],"to":[-0.523,0.758,0],"ti":[0.442,-0.696,0]},{"i":{"x":0.833,"y":0.842},"o":{"x":0.167,"y":0.177},"t":29,"s":[70.313,392.354,0],"to":[-0.442,0.696,0],"ti":[0.369,-0.637,0]},{"i":{"x":0.833,"y":0.843},"o":{"x":0.167,"y":0.177},"t":30,"s":[69.102,394.352,0],"to":[-0.369,0.637,0],"ti":[0.304,-0.579,0]},{"i":{"x":0.833,"y":0.843},"o":{"x":0.167,"y":0.177},"t":31,"s":[68.096,396.175,0],"to":[-0.304,0.579,0],"ti":[0.245,-0.524,0]},{"i":{"x":0.833,"y":0.844},"o":{"x":0.167,"y":0.178},"t":32,"s":[67.277,397.829,0],"to":[-0.245,0.524,0],"ti":[0.193,-0.471,0]},{"i":{"x":0.833,"y":0.844},"o":{"x":0.167,"y":0.178},"t":33,"s":[66.624,399.32,0],"to":[-0.193,0.471,0],"ti":[0.147,-0.42,0]},{"i":{"x":0.833,"y":0.845},"o":{"x":0.167,"y":0.179},"t":34,"s":[66.118,400.656,0],"to":[-0.147,0.42,0],"ti":[0.105,-0.37,0]},{"i":{"x":0.833,"y":0.846},"o":{"x":0.167,"y":0.18},"t":35,"s":[65.744,401.84,0],"to":[-0.105,0.37,0],"ti":[0.069,-0.322,0]},{"i":{"x":0.833,"y":0.847},"o":{"x":0.167,"y":0.181},"t":36,"s":[65.487,402.877,0],"to":[-0.069,0.322,0],"ti":[0.037,-0.276,0]},{"i":{"x":0.833,"y":0.848},"o":{"x":0.167,"y":0.183},"t":37,"s":[65.332,403.773,0],"to":[-0.037,0.276,0],"ti":[0.009,-0.231,0]},{"i":{"x":0.833,"y":0.849},"o":{"x":0.167,"y":0.185},"t":38,"s":[65.265,404.532,0],"to":[-0.009,0.231,0],"ti":[-0.014,-0.188,0]},{"i":{"x":0.833,"y":0.851},"o":{"x":0.167,"y":0.187},"t":39,"s":[65.276,405.16,0],"to":[0.014,0.188,0],"ti":[-0.034,-0.146,0]},{"i":{"x":0.833,"y":0.852},"o":{"x":0.167,"y":0.189},"t":40,"s":[65.351,405.66,0],"to":[0.034,0.146,0],"ti":[-0.051,-0.106,0]},{"i":{"x":0.833,"y":0.849},"o":{"x":0.167,"y":0.19},"t":41,"s":[65.481,406.037,0],"to":[0.051,0.106,0],"ti":[-0.064,-0.066,0]},{"i":{"x":0.833,"y":0.838},"o":{"x":0.167,"y":0.186},"t":42,"s":[65.655,406.293,0],"to":[0.064,0.066,0],"ti":[-0.074,-0.028,0]},{"i":{"x":0.833,"y":0.824},"o":{"x":0.167,"y":0.172},"t":43,"s":[65.864,406.433,0],"to":[0.074,0.028,0],"ti":[-0.081,0.01,0]},{"i":{"x":0.833,"y":0.827},"o":{"x":0.167,"y":0.158},"t":44,"s":[66.098,406.459,0],"to":[0.081,-0.01,0],"ti":[-0.083,0.038,0]},{"i":{"x":0.833,"y":0.839},"o":{"x":0.167,"y":0.161},"t":45,"s":[66.349,406.373,0],"to":[0.083,-0.038,0],"ti":[-0.078,0.048,0]},{"i":{"x":0.833,"y":0.84},"o":{"x":0.167,"y":0.172},"t":46,"s":[66.596,406.231,0],"to":[0.078,-0.048,0],"ti":[-0.069,0.051,0]},{"i":{"x":0.833,"y":0.841},"o":{"x":0.167,"y":0.174},"t":47,"s":[66.819,406.083,0],"to":[0.069,-0.051,0],"ti":[-0.057,0.054,0]},{"i":{"x":0.833,"y":0.84},"o":{"x":0.167,"y":0.175},"t":48,"s":[67.01,405.928,0],"to":[0.057,-0.054,0],"ti":[-0.042,0.058,0]},{"i":{"x":0.833,"y":0.835},"o":{"x":0.167,"y":0.174},"t":49,"s":[67.16,405.761,0],"to":[0.042,-0.058,0],"ti":[-0.023,0.063,0]},{"i":{"x":0.833,"y":0.825},"o":{"x":0.167,"y":0.168},"t":50,"s":[67.259,405.58,0],"to":[0.023,-0.063,0],"ti":[-0.001,0.07,0]},{"i":{"x":0.833,"y":0.82},"o":{"x":0.167,"y":0.159},"t":51,"s":[67.298,405.38,0],"to":[0.001,-0.07,0],"ti":[0.023,0.076,0]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.155},"t":52,"s":[67.266,405.158,0],"to":[-0.023,-0.076,0],"ti":[0.046,0.073,0]},{"i":{"x":0.833,"y":0.832},"o":{"x":0.167,"y":0.166},"t":53,"s":[67.158,404.922,0],"to":[-0.046,-0.073,0],"ti":[0.062,0.06,0]},{"i":{"x":0.833,"y":0.831},"o":{"x":0.167,"y":0.166},"t":54,"s":[66.993,404.72,0],"to":[-0.062,-0.06,0],"ti":[0.076,0.046,0]},{"i":{"x":0.833,"y":0.831},"o":{"x":0.167,"y":0.165},"t":55,"s":[66.783,404.561,0],"to":[-0.076,-0.046,0],"ti":[0.086,0.031,0]},{"i":{"x":0.833,"y":0.831},"o":{"x":0.167,"y":0.164},"t":56,"s":[66.539,404.446,0],"to":[-0.086,-0.031,0],"ti":[0.093,0.016,0]},{"i":{"x":0.833,"y":0.831},"o":{"x":0.167,"y":0.164},"t":57,"s":[66.269,404.376,0],"to":[-0.093,-0.016,0],"ti":[0.097,0,0]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.164},"t":58,"s":[65.982,404.352,0],"to":[-0.097,0,0],"ti":[0.049,-0.004,0]},{"t":59,"s":[65.686,404.375,0]}],"ix":2,"l":2},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":1,"k":[{"i":{"x":[0.833,0.833,0.833],"y":[0.938,0.898,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.167,0.167,0]},"t":5,"s":[49.45,148.28,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.038,1.014,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.246,0.447,0]},"t":6,"s":[96.233,59.296,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.894,0.934,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.026,0.012,0]},"t":7,"s":[84.39,38.927,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.66,0.535,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.391,-0.32,0]},"t":8,"s":[101.568,62.764,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.929,0.999,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.074,0.102,0]},"t":9,"s":[106.222,57.834,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.162,0.853,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.472,-0.001,0]},"t":10,"s":[64.691,35.274,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.955,0.795,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.093,0.193,0]},"t":11,"s":[70.923,57.653,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.827,1.022,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.098,0.141,0]},"t":12,"s":[127.324,74.727,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.934,0.941,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.161,0.018,0]},"t":13,"s":[101.458,99.601,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.386,1.222,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.314,-0.197,0]},"t":14,"s":[73.693,68.044,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.064,0.905,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.069,0.061,0]},"t":15,"s":[79.522,77.427,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.928,1.413,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.036,0.681,0]},"t":16,"s":[46.671,43.016,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.358,0.941,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.543,0.069,0]},"t":17,"s":[104.84,38.219,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.006,1.25,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.096,-0.2,0]},"t":18,"s":[97.095,66.774,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.947,0.896,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.006,0.063,0]},"t":19,"s":[45.158,58.378,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.954,1.295,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.142,0.42,0]},"t":20,"s":[100.992,91.978,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.782,0.93,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.105,0.065,0]},"t":21,"s":[80.364,100.301,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.813,0.641,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.135,-0.451,0]},"t":22,"s":[89.494,62.471,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.187,0.991,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.15,0.109,0]},"t":23,"s":[104.25,68.373,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.922,0.748,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.058,-0.01,0]},"t":24,"s":[122.573,87.899,100]},{"i":{"x":[0.833,0.833,0.833],"y":[-0.546,1.004,1]},"o":{"x":[0.167,0.167,0.167],"y":[-1.303,0.124,0]},"t":25,"s":[63.056,70.386,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.939,0.876,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.088,0.004,0]},"t":26,"s":[66.633,34.869,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.782,0.805,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.233,0.254,0]},"t":27,"s":[129.403,72.011,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.806,1.026,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.135,0.145,0]},"t":28,"s":[112.863,90.111,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.928,0.817,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.146,0.02,0]},"t":29,"s":[86.228,114.396,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.021,0.937,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.514,0.153,0]},"t":30,"s":[50.891,82.427,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.248,0.388,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.017,-0.251,0]},"t":31,"s":[55.82,44.045,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.895,0.966,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.062,0.096,0]},"t":32,"s":[49.632,53.61,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.992,0.911,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.404,-0.058,0]},"t":33,"s":[74.255,114.253,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.123,0.931,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.009,1.243,0]},"t":34,"s":[80.651,78.395,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.943,0.169,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.092,-0.398,0]},"t":35,"s":[74.861,75.817,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.802,1.723,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.176,0.093,0]},"t":36,"s":[19.702,76.263,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.938,1.043,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.144,0.075,0]},"t":37,"s":[37.393,80.265,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.507,0.949,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.243,0.028,0]},"t":38,"s":[61.705,41.546,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.85,0.881,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.1,-0.133,0]},"t":39,"s":[55.488,100.178,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.039,0.614,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.187,0.279,0]},"t":40,"s":[24.961,77.623,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.03,0.973,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.026,0.106,0]},"t":41,"s":[0.375,68.025,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.01,1.132,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.022,-0.04,0]},"t":42,"s":[36.365,33.155,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.865,0.968,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.009,0.051,0]},"t":43,"s":[-12.521,56.708,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.071,0.909,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.219,-0.051,0]},"t":44,"s":[42.47,-4.293,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.953,1.38,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.038,0.941,0]},"t":45,"s":[76.363,33.422,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.979,0.988,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.108,0.068,0]},"t":46,"s":[13.419,37.087,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.943,1.072,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.028,-0.014,0]},"t":47,"s":[40.835,16.71,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.911,1.007,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.176,0.039,0]},"t":48,"s":[20.376,34.131,100]},{"i":{"x":[0.833,0.833,0.833],"y":[3.32,0.906,1]},"o":{"x":[0.167,0.167,0.167],"y":[1.367,0.007,0]},"t":49,"s":[26.948,1.687,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.176,0.438,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.08,0.755,0]},"t":50,"s":[27.375,36.914,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.984,0.868,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.057,0.098,0]},"t":51,"s":[15.071,41.282,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.862,1.428,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.019,0.226,0]},"t":52,"s":[53.354,66.388,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.873,0.951,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.21,0.07,0]},"t":53,"s":[22.228,81.077,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.145,0.971,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.242,-0.12,0]},"t":54,"s":[1.727,-9.051,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.874,0.919,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.053,-0.044,0]},"t":55,"s":[-9.032,27.824,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.791,-3.351,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.245,-3.045,0]},"t":56,"s":[20.502,3.731,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.021,0.93,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.139,0.085,0]},"t":57,"s":[35.73,4.373,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.833,0.833,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.017,-0.444,0]},"t":58,"s":[58.616,37.243,100]},{"t":59,"s":[30.021,32.047,100]}],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[0.012,10.621],[-5.695,5.34],[-2.557,-7.377],[3.15,-2.096]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0.3137254901960784,0.8901960784313725,0.7607843137254902,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[-3.421,4.043],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Rectangle 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":5,"op":60,"st":5,"bm":0},{"ddd":0,"ind":42,"ty":4,"nm":"Shape Layer 10","sr":1,"ks":{"o":{"a":1,"k":[{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":8,"s":[0]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":13,"s":[100]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":35,"s":[100]},{"t":51,"s":[0]}],"ix":11},"r":{"a":0,"k":0,"ix":10,"x":"var $bm_rt;\n$bm_rt = $bm_mul(time, 300);"},"p":{"a":1,"k":[{"i":{"x":0.833,"y":0.609},"o":{"x":0.167,"y":0.167},"t":8,"s":[272.114,255.071,0],"to":[-0.266,0.053,0],"ti":[1.216,-0.382,0]},{"i":{"x":0.833,"y":0.765},"o":{"x":0.167,"y":0.106},"t":9,"s":[270.517,255.387,0],"to":[-1.216,0.382,0],"ti":[2.644,-1.017,0]},{"i":{"x":0.833,"y":0.824},"o":{"x":0.167,"y":0.129},"t":10,"s":[264.819,257.361,0],"to":[-2.644,1.017,0],"ti":[3.502,-1.605,0]},{"i":{"x":0.833,"y":0.844},"o":{"x":0.167,"y":0.158},"t":11,"s":[254.654,261.49,0],"to":[-3.502,1.605,0],"ti":[3.128,-2.062,0]},{"i":{"x":0.833,"y":0.852},"o":{"x":0.167,"y":0.179},"t":12,"s":[243.809,266.989,0],"to":[-3.128,2.062,0],"ti":[0.929,-2.4,0]},{"i":{"x":0.833,"y":0.84},"o":{"x":0.167,"y":0.191},"t":13,"s":[235.889,273.86,0],"to":[-0.929,2.4,0],"ti":[-1.493,-1.834,0]},{"i":{"x":0.833,"y":0.845},"o":{"x":0.167,"y":0.174},"t":14,"s":[238.235,281.389,0],"to":[1.493,1.834,0],"ti":[-2.114,-0.944,0]},{"i":{"x":0.833,"y":0.844},"o":{"x":0.167,"y":0.181},"t":15,"s":[244.846,284.864,0],"to":[2.114,0.944,0],"ti":[-1.909,-0.642,0]},{"i":{"x":0.833,"y":0.843},"o":{"x":0.167,"y":0.179},"t":16,"s":[250.917,287.051,0],"to":[1.909,0.642,0],"ti":[-1.697,-0.51,0]},{"i":{"x":0.833,"y":0.842},"o":{"x":0.167,"y":0.177},"t":17,"s":[256.301,288.717,0],"to":[1.697,0.51,0],"ti":[-1.516,-0.438,0]},{"i":{"x":0.833,"y":0.842},"o":{"x":0.167,"y":0.176},"t":18,"s":[261.1,290.112,0],"to":[1.516,0.438,0],"ti":[-1.362,-0.392,0]},{"i":{"x":0.833,"y":0.841},"o":{"x":0.167,"y":0.176},"t":19,"s":[265.397,291.343,0],"to":[1.362,0.392,0],"ti":[-1.229,-0.362,0]},{"i":{"x":0.833,"y":0.841},"o":{"x":0.167,"y":0.175},"t":20,"s":[269.269,292.467,0],"to":[1.229,0.362,0],"ti":[-1.114,-0.341,0]},{"i":{"x":0.833,"y":0.841},"o":{"x":0.167,"y":0.175},"t":21,"s":[272.774,293.517,0],"to":[1.114,0.341,0],"ti":[-1.012,-0.326,0]},{"i":{"x":0.833,"y":0.84},"o":{"x":0.167,"y":0.175},"t":22,"s":[275.955,294.515,0],"to":[1.012,0.326,0],"ti":[-0.921,-0.314,0]},{"i":{"x":0.833,"y":0.84},"o":{"x":0.167,"y":0.174},"t":23,"s":[278.847,295.472,0],"to":[0.921,0.314,0],"ti":[-0.839,-0.305,0]},{"i":{"x":0.833,"y":0.84},"o":{"x":0.167,"y":0.174},"t":24,"s":[281.482,296.4,0],"to":[0.839,0.305,0],"ti":[-0.762,-0.298,0]},{"i":{"x":0.833,"y":0.84},"o":{"x":0.167,"y":0.174},"t":25,"s":[283.879,297.304,0],"to":[0.762,0.298,0],"ti":[-0.69,-0.293,0]},{"i":{"x":0.833,"y":0.841},"o":{"x":0.167,"y":0.174},"t":26,"s":[286.053,298.19,0],"to":[0.69,0.293,0],"ti":[-0.621,-0.288,0]},{"i":{"x":0.833,"y":0.841},"o":{"x":0.167,"y":0.175},"t":27,"s":[288.016,299.06,0],"to":[0.621,0.288,0],"ti":[-0.554,-0.284,0]},{"i":{"x":0.833,"y":0.842},"o":{"x":0.167,"y":0.175},"t":28,"s":[289.778,299.918,0],"to":[0.554,0.284,0],"ti":[-0.486,-0.279,0]},{"i":{"x":0.833,"y":0.843},"o":{"x":0.167,"y":0.176},"t":29,"s":[291.338,300.763,0],"to":[0.486,0.279,0],"ti":[-0.419,-0.273,0]},{"i":{"x":0.833,"y":0.844},"o":{"x":0.167,"y":0.177},"t":30,"s":[292.695,301.592,0],"to":[0.419,0.273,0],"ti":[-0.351,-0.266,0]},{"i":{"x":0.833,"y":0.845},"o":{"x":0.167,"y":0.178},"t":31,"s":[293.849,302.402,0],"to":[0.351,0.266,0],"ti":[-0.283,-0.258,0]},{"i":{"x":0.833,"y":0.847},"o":{"x":0.167,"y":0.18},"t":32,"s":[294.801,303.189,0],"to":[0.283,0.258,0],"ti":[-0.215,-0.245,0]},{"i":{"x":0.833,"y":0.849},"o":{"x":0.167,"y":0.183},"t":33,"s":[295.549,303.949,0],"to":[0.215,0.245,0],"ti":[-0.147,-0.225,0]},{"i":{"x":0.833,"y":0.851},"o":{"x":0.167,"y":0.186},"t":34,"s":[296.09,304.662,0],"to":[0.147,0.225,0],"ti":[-0.082,-0.198,0]},{"i":{"x":0.833,"y":0.852},"o":{"x":0.167,"y":0.19},"t":35,"s":[296.429,305.301,0],"to":[0.082,0.198,0],"ti":[-0.024,-0.164,0]},{"i":{"x":0.833,"y":0.844},"o":{"x":0.167,"y":0.191},"t":36,"s":[296.583,305.847,0],"to":[0.024,0.164,0],"ti":[0.026,-0.131,0]},{"i":{"x":0.833,"y":0.831},"o":{"x":0.167,"y":0.179},"t":37,"s":[296.574,306.285,0],"to":[-0.026,0.131,0],"ti":[0.067,-0.108,0]},{"i":{"x":0.833,"y":0.829},"o":{"x":0.167,"y":0.164},"t":38,"s":[296.425,306.636,0],"to":[-0.067,0.108,0],"ti":[0.097,-0.091,0]},{"i":{"x":0.833,"y":0.83},"o":{"x":0.167,"y":0.163},"t":39,"s":[296.17,306.934,0],"to":[-0.097,0.091,0],"ti":[0.117,-0.076,0]},{"i":{"x":0.833,"y":0.832},"o":{"x":0.167,"y":0.163},"t":40,"s":[295.842,307.183,0],"to":[-0.117,0.076,0],"ti":[0.129,-0.065,0]},{"i":{"x":0.833,"y":0.834},"o":{"x":0.167,"y":0.165},"t":41,"s":[295.468,307.393,0],"to":[-0.129,0.065,0],"ti":[0.134,-0.057,0]},{"i":{"x":0.833,"y":0.836},"o":{"x":0.167,"y":0.167},"t":42,"s":[295.071,307.574,0],"to":[-0.134,0.057,0],"ti":[0.133,-0.051,0]},{"i":{"x":0.833,"y":0.838},"o":{"x":0.167,"y":0.169},"t":43,"s":[294.667,307.733,0],"to":[-0.133,0.051,0],"ti":[0.128,-0.046,0]},{"i":{"x":0.833,"y":0.84},"o":{"x":0.167,"y":0.171},"t":44,"s":[294.272,307.878,0],"to":[-0.128,0.046,0],"ti":[0.12,-0.044,0]},{"i":{"x":0.833,"y":0.842},"o":{"x":0.167,"y":0.174},"t":45,"s":[293.898,308.012,0],"to":[-0.12,0.044,0],"ti":[0.108,-0.042,0]},{"i":{"x":0.833,"y":0.844},"o":{"x":0.167,"y":0.176},"t":46,"s":[293.554,308.14,0],"to":[-0.108,0.042,0],"ti":[0.094,-0.041,0]},{"i":{"x":0.833,"y":0.84},"o":{"x":0.167,"y":0.179},"t":47,"s":[293.249,308.264,0],"to":[-0.094,0.041,0],"ti":[0.08,-0.044,0]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.174},"t":48,"s":[292.988,308.385,0],"to":[-0.08,0.044,0],"ti":[0.068,-0.055,0]},{"i":{"x":0.833,"y":0.831},"o":{"x":0.167,"y":0.166},"t":49,"s":[292.767,308.528,0],"to":[-0.068,0.055,0],"ti":[0.056,-0.069,0]},{"i":{"x":0.833,"y":0.829},"o":{"x":0.167,"y":0.164},"t":50,"s":[292.579,308.714,0],"to":[-0.056,0.069,0],"ti":[0.044,-0.082,0]},{"i":{"x":0.833,"y":0.828},"o":{"x":0.167,"y":0.162},"t":51,"s":[292.428,308.941,0],"to":[-0.044,0.082,0],"ti":[0.03,-0.094,0]},{"i":{"x":0.833,"y":0.827},"o":{"x":0.167,"y":0.161},"t":52,"s":[292.318,309.205,0],"to":[-0.03,0.094,0],"ti":[0.016,-0.105,0]},{"i":{"x":0.833,"y":0.827},"o":{"x":0.167,"y":0.161},"t":53,"s":[292.249,309.504,0],"to":[-0.016,0.105,0],"ti":[0.001,-0.114,0]},{"i":{"x":0.833,"y":0.827},"o":{"x":0.167,"y":0.161},"t":54,"s":[292.223,309.833,0],"to":[-0.001,0.114,0],"ti":[-0.013,-0.122,0]},{"i":{"x":0.833,"y":0.828},"o":{"x":0.167,"y":0.161},"t":55,"s":[292.242,310.189,0],"to":[0.013,0.122,0],"ti":[-0.028,-0.128,0]},{"i":{"x":0.833,"y":0.829},"o":{"x":0.167,"y":0.162},"t":56,"s":[292.304,310.564,0],"to":[0.028,0.128,0],"ti":[-0.042,-0.131,0]},{"i":{"x":0.833,"y":0.83},"o":{"x":0.167,"y":0.163},"t":57,"s":[292.408,310.955,0],"to":[0.042,0.131,0],"ti":[-0.055,-0.133,0]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.164},"t":58,"s":[292.554,311.353,0],"to":[0.055,0.133,0],"ti":[-0.031,-0.067,0]},{"t":59,"s":[292.738,311.753,0]}],"ix":2,"l":2},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":1,"k":[{"i":{"x":[0.833,0.833,0.833],"y":[0.866,0.988,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.167,0.167,0]},"t":8,"s":[127.438,90.591,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.119,0.935,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.22,-0.014,0]},"t":9,"s":[90.096,28.016,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.948,1.126,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.049,-0.3,0]},"t":10,"s":[67.312,81.384,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.746,0.942,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.141,0.05,0]},"t":11,"s":[122.538,69.77,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.94,1.165,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.124,-0.191,0]},"t":12,"s":[102.02,98.906,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.968,1.084,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.21,0.055,0]},"t":13,"s":[60.035,90.043,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.429,0.887,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.051,0.042,0]},"t":14,"s":[71.968,116.451,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.886,0.984,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.07,0.318,0]},"t":15,"s":[64.586,63.443,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.12,0.842,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.307,-0.02,0]},"t":16,"s":[109.991,44.631,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.887,0.933,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.049,0.176,0]},"t":17,"s":[126.937,59.824,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.971,1.735,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.316,-0.347,0]},"t":18,"s":[85.604,73.442,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.831,0.945,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.045,0.075,0]},"t":19,"s":[70.771,70.802,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.913,0.639,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.164,-0.162,0]},"t":20,"s":[80.389,96.73,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.179,0.905,1]},"o":{"x":[0.167,0.167,0.167],"y":[1.79,0.108,0]},"t":21,"s":[90.289,87.923,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.589,1.199,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.093,0.673,0]},"t":22,"s":[90.772,58.569,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.878,1.108,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.073,0.059,0]},"t":23,"s":[95.049,54.421,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.33,0.947,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.262,0.047,0]},"t":24,"s":[60.54,68.48,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.901,0.927,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.067,-0.148,0]},"t":25,"s":[44.437,36.222,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.929,4.165,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.525,-0.607,0]},"t":26,"s":[124.279,47.844,100]},{"i":{"x":[0.833,0.833,0.833],"y":[-1.352,0.913,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.486,0.081,0]},"t":27,"s":[139.353,46.441,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.883,2.129,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.086,1.924,0]},"t":28,"s":[137.147,101.148,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.867,0.914,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.29,0.078,0]},"t":29,"s":[77.097,103.625,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.16,2.38,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.222,2.472,0]},"t":30,"s":[52.92,67.602,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.876,0.839,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.055,0.079,0]},"t":31,"s":[38.391,66.346,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.058,0.973,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.255,0.173,0]},"t":32,"s":[80.849,88.417,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.897,0.752,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.034,-0.04,0]},"t":33,"s":[101.514,109.026,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.242,1.058,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.446,0.126,0]},"t":34,"s":[66.389,95.048,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.892,1.025,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.062,0.034,0]},"t":35,"s":[58.31,67.448,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.044,0.974,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.364,0.019,0]},"t":36,"s":[89.867,114.12,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.587,0.887,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.029,-0.038,0]},"t":37,"s":[99.232,53.341,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.912,1.104,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.104,0.319,0]},"t":38,"s":[84.926,94.953,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.439,0.867,1]},"o":{"x":[0.167,0.167,0.167],"y":[1.495,0.046,0]},"t":39,"s":[28.265,109.679,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.035,0.952,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.07,0.223,0]},"t":40,"s":[24.92,76.564,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.948,1.618,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.025,-0.113,0]},"t":41,"s":[45.873,56.751,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.603,0.985,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.138,0.073,0]},"t":42,"s":[16.102,65.143,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.028,0.911,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.105,-0.019,0]},"t":43,"s":[27.313,-5.503,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.923,1.561,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.021,1.347,0]},"t":44,"s":[69.528,52.086,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.395,0.972,1]},"o":{"x":[0.167,0.167,0.167],"y":[-1.056,0.073,0]},"t":45,"s":[13.347,55.885,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.991,0.96,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.069,-0.041,0]},"t":46,"s":[17.457,26.517,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.906,0.989,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.01,-0.077,0]},"t":47,"s":[-6.114,46.16,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.079,1.13,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.74,-0.013,0]},"t":48,"s":[14.941,35.961,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.225,0.927,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.041,0.051,0]},"t":49,"s":[17.614,44.753,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.151,-0.369,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.061,-0.61,0]},"t":50,"s":[12.402,22.217,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.952,0.988,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.054,0.089,0]},"t":51,"s":[31.658,24.926,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.879,0.864,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.115,-0.014,0]},"t":52,"s":[-22.38,66.727,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.835,0.983,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.269,0.215,0]},"t":53,"s":[0.27,30.796,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.829,1.068,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.168,-0.021,0]},"t":54,"s":[10.423,7.981,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.817,0.983,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.162,0.038,0]},"t":55,"s":[20.367,26.148,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.012,0.9,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.153,-0.021,0]},"t":56,"s":[30.864,-6.905,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.986,1.056,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.011,0.499,0]},"t":57,"s":[43.453,19.525,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.833,0.833,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.017,0.034,0]},"t":58,"s":[29.038,24.824,100]},{"t":59,"s":[41.021,15.933,100]}],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[0.012,10.621],[-5.695,5.34],[-2.557,-7.377],[3.15,-2.096]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[1,0.8941176470588236,0.11372549019607843,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[-3.421,4.043],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Rectangle 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":8,"op":60,"st":8,"bm":0},{"ddd":0,"ind":43,"ty":4,"nm":"Shape Layer 9","sr":1,"ks":{"o":{"a":1,"k":[{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":10,"s":[0]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":15,"s":[100]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":37,"s":[100]},{"t":53,"s":[0]}],"ix":11},"r":{"a":0,"k":0,"ix":10,"x":"var $bm_rt;\n$bm_rt = $bm_mul(time, 300);"},"p":{"a":1,"k":[{"i":{"x":0.833,"y":0.607},"o":{"x":0.167,"y":0.167},"t":10,"s":[279.864,255.271,0],"to":[-0.343,0.177,0],"ti":[1.691,-0.679,0]},{"i":{"x":0.833,"y":0.764},"o":{"x":0.167,"y":0.106},"t":11,"s":[277.804,256.336,0],"to":[-1.691,0.679,0],"ti":[3.887,-1.09,0]},{"i":{"x":0.833,"y":0.843},"o":{"x":0.167,"y":0.129},"t":12,"s":[269.719,259.347,0],"to":[-3.887,1.09,0],"ti":[3.906,1.153,0]},{"i":{"x":0.833,"y":0.826},"o":{"x":0.167,"y":0.178},"t":13,"s":[254.482,262.875,0],"to":[-3.906,-1.153,0],"ti":[0.302,3.979,0]},{"i":{"x":0.833,"y":0.848},"o":{"x":0.167,"y":0.16},"t":14,"s":[246.284,252.432,0],"to":[-0.302,-3.979,0],"ti":[-2.124,4.016,0]},{"i":{"x":0.833,"y":0.847},"o":{"x":0.167,"y":0.184},"t":15,"s":[252.67,238.999,0],"to":[2.124,-4.016,0],"ti":[-1.967,3.264,0]},{"i":{"x":0.833,"y":0.845},"o":{"x":0.167,"y":0.182},"t":16,"s":[259.026,228.335,0],"to":[1.967,-3.264,0],"ti":[-1.669,2.774,0]},{"i":{"x":0.833,"y":0.844},"o":{"x":0.167,"y":0.18},"t":17,"s":[264.471,219.412,0],"to":[1.669,-2.774,0],"ti":[-1.398,2.426,0]},{"i":{"x":0.833,"y":0.843},"o":{"x":0.167,"y":0.179},"t":18,"s":[269.041,211.694,0],"to":[1.398,-2.426,0],"ti":[-1.168,2.169,0]},{"i":{"x":0.833,"y":0.842},"o":{"x":0.167,"y":0.177},"t":19,"s":[272.862,204.857,0],"to":[1.168,-2.169,0],"ti":[-0.974,1.973,0]},{"i":{"x":0.833,"y":0.841},"o":{"x":0.167,"y":0.176},"t":20,"s":[276.051,198.681,0],"to":[0.974,-1.973,0],"ti":[-0.806,1.82,0]},{"i":{"x":0.833,"y":0.841},"o":{"x":0.167,"y":0.175},"t":21,"s":[278.705,193.017,0],"to":[0.806,-1.82,0],"ti":[-0.644,1.685,0]},{"i":{"x":0.833,"y":0.841},"o":{"x":0.167,"y":0.176},"t":22,"s":[280.888,187.764,0],"to":[0.644,-1.685,0],"ti":[-0.482,1.558,0]},{"i":{"x":0.833,"y":0.841},"o":{"x":0.167,"y":0.175},"t":23,"s":[282.57,182.907,0],"to":[0.482,-1.558,0],"ti":[-0.333,1.443,0]},{"i":{"x":0.833,"y":0.84},"o":{"x":0.167,"y":0.175},"t":24,"s":[283.781,178.414,0],"to":[0.333,-1.443,0],"ti":[-0.197,1.336,0]},{"i":{"x":0.833,"y":0.84},"o":{"x":0.167,"y":0.174},"t":25,"s":[284.566,174.251,0],"to":[0.197,-1.336,0],"ti":[-0.074,1.237,0]},{"i":{"x":0.833,"y":0.84},"o":{"x":0.167,"y":0.174},"t":26,"s":[284.965,170.396,0],"to":[0.074,-1.237,0],"ti":[0.038,1.14,0]},{"i":{"x":0.833,"y":0.84},"o":{"x":0.167,"y":0.174},"t":27,"s":[285.012,166.831,0],"to":[-0.038,-1.14,0],"ti":[0.139,1.044,0]},{"i":{"x":0.833,"y":0.839},"o":{"x":0.167,"y":0.174},"t":28,"s":[284.739,163.555,0],"to":[-0.139,-1.044,0],"ti":[0.228,0.947,0]},{"i":{"x":0.833,"y":0.84},"o":{"x":0.167,"y":0.173},"t":29,"s":[284.178,160.569,0],"to":[-0.228,-0.947,0],"ti":[0.304,0.848,0]},{"i":{"x":0.833,"y":0.84},"o":{"x":0.167,"y":0.174},"t":30,"s":[283.368,157.874,0],"to":[-0.304,-0.848,0],"ti":[0.363,0.748,0]},{"i":{"x":0.833,"y":0.84},"o":{"x":0.167,"y":0.174},"t":31,"s":[282.355,155.48,0],"to":[-0.363,-0.748,0],"ti":[0.405,0.649,0]},{"i":{"x":0.833,"y":0.841},"o":{"x":0.167,"y":0.174},"t":32,"s":[281.189,153.386,0],"to":[-0.405,-0.649,0],"ti":[0.428,0.554,0]},{"i":{"x":0.833,"y":0.841},"o":{"x":0.167,"y":0.175},"t":33,"s":[279.927,151.584,0],"to":[-0.428,-0.554,0],"ti":[0.433,0.464,0]},{"i":{"x":0.833,"y":0.842},"o":{"x":0.167,"y":0.176},"t":34,"s":[278.623,150.062,0],"to":[-0.433,-0.464,0],"ti":[0.424,0.383,0]},{"i":{"x":0.833,"y":0.843},"o":{"x":0.167,"y":0.177},"t":35,"s":[277.326,148.798,0],"to":[-0.424,-0.383,0],"ti":[0.403,0.31,0]},{"i":{"x":0.833,"y":0.844},"o":{"x":0.167,"y":0.178},"t":36,"s":[276.078,147.765,0],"to":[-0.403,-0.31,0],"ti":[0.372,0.245,0]},{"i":{"x":0.833,"y":0.845},"o":{"x":0.167,"y":0.179},"t":37,"s":[274.911,146.939,0],"to":[-0.372,-0.245,0],"ti":[0.335,0.19,0]},{"i":{"x":0.833,"y":0.847},"o":{"x":0.167,"y":0.181},"t":38,"s":[273.847,146.293,0],"to":[-0.335,-0.19,0],"ti":[0.294,0.141,0]},{"i":{"x":0.833,"y":0.849},"o":{"x":0.167,"y":0.183},"t":39,"s":[272.9,145.802,0],"to":[-0.294,-0.141,0],"ti":[0.25,0.1,0]},{"i":{"x":0.833,"y":0.852},"o":{"x":0.167,"y":0.186},"t":40,"s":[272.082,145.445,0],"to":[-0.25,-0.1,0],"ti":[0.205,0.064,0]},{"i":{"x":0.833,"y":0.855},"o":{"x":0.167,"y":0.19},"t":41,"s":[271.398,145.204,0],"to":[-0.205,-0.064,0],"ti":[0.16,0.034,0]},{"i":{"x":0.833,"y":0.861},"o":{"x":0.167,"y":0.196},"t":42,"s":[270.85,145.06,0],"to":[-0.16,-0.034,0],"ti":[0.115,0.009,0]},{"i":{"x":0.833,"y":0.868},"o":{"x":0.167,"y":0.208},"t":43,"s":[270.438,144.999,0],"to":[-0.115,-0.009,0],"ti":[0.071,-0.011,0]},{"i":{"x":0.833,"y":0.86},"o":{"x":0.167,"y":0.226},"t":44,"s":[270.159,145.005,0],"to":[-0.071,0.011,0],"ti":[0.029,-0.027,0]},{"i":{"x":0.833,"y":0.798},"o":{"x":0.167,"y":0.205},"t":45,"s":[270.01,145.067,0],"to":[-0.029,0.027,0],"ti":[-0.01,-0.039,0]},{"i":{"x":0.833,"y":0.789},"o":{"x":0.167,"y":0.142},"t":46,"s":[269.983,145.17,0],"to":[0.01,0.039,0],"ti":[-0.046,-0.046,0]},{"i":{"x":0.833,"y":0.804},"o":{"x":0.167,"y":0.138},"t":47,"s":[270.07,145.3,0],"to":[0.046,0.046,0],"ti":[-0.08,-0.05,0]},{"i":{"x":0.833,"y":0.814},"o":{"x":0.167,"y":0.145},"t":48,"s":[270.262,145.447,0],"to":[0.08,0.05,0],"ti":[-0.111,-0.049,0]},{"i":{"x":0.833,"y":0.827},"o":{"x":0.167,"y":0.151},"t":49,"s":[270.551,145.599,0],"to":[0.111,0.049,0],"ti":[-0.133,-0.043,0]},{"i":{"x":0.833,"y":0.835},"o":{"x":0.167,"y":0.161},"t":50,"s":[270.927,145.744,0],"to":[0.133,0.043,0],"ti":[-0.141,-0.026,0]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.168},"t":51,"s":[271.347,145.854,0],"to":[0.141,0.026,0],"ti":[-0.142,-0.004,0]},{"i":{"x":0.833,"y":0.831},"o":{"x":0.167,"y":0.166},"t":52,"s":[271.77,145.903,0],"to":[0.142,0.004,0],"ti":[-0.143,0.021,0]},{"i":{"x":0.833,"y":0.832},"o":{"x":0.167,"y":0.165},"t":53,"s":[272.2,145.876,0],"to":[0.143,-0.021,0],"ti":[-0.142,0.042,0]},{"i":{"x":0.833,"y":0.832},"o":{"x":0.167,"y":0.165},"t":54,"s":[272.629,145.78,0],"to":[0.142,-0.042,0],"ti":[-0.137,0.061,0]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.166},"t":55,"s":[273.049,145.622,0],"to":[0.137,-0.061,0],"ti":[-0.13,0.077,0]},{"i":{"x":0.833,"y":0.834},"o":{"x":0.167,"y":0.166},"t":56,"s":[273.452,145.412,0],"to":[0.13,-0.077,0],"ti":[-0.12,0.091,0]},{"i":{"x":0.833,"y":0.835},"o":{"x":0.167,"y":0.167},"t":57,"s":[273.829,145.157,0],"to":[0.12,-0.091,0],"ti":[-0.109,0.102,0]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.168},"t":58,"s":[274.175,144.866,0],"to":[0.109,-0.102,0],"ti":[-0.051,0.054,0]},{"t":59,"s":[274.483,144.545,0]}],"ix":2,"l":2},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":1,"k":[{"i":{"x":[0.833,0.833,0.833],"y":[0.838,1.875,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.167,0.167,0]},"t":10,"s":[109.864,70.089,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.856,0.69,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.172,0.076,0]},"t":11,"s":[86.733,68.252,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.028,0.993,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.199,0.114,0]},"t":12,"s":[64.924,89.385,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.797,0.9,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.021,-0.008,0]},"t":13,"s":[49.171,146.99,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.92,0.755,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.141,0.509,0]},"t":14,"s":[70.147,94.506,100]},{"i":{"x":[0.833,0.833,0.833],"y":[2.944,1.08,1]},"o":{"x":[0.167,0.167,0.167],"y":[-1.941,0.126,0]},"t":15,"s":[100.345,84.223,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.003,0.981,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.08,0.041,0]},"t":16,"s":[99.102,64.322,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.843,0.917,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.002,-0.024,0]},"t":17,"s":[129.334,103.257,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.977,-0.949,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.177,-21.258,0]},"t":18,"s":[98.181,73.031,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.969,0.966,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.032,0.087,0]},"t":19,"s":[70.521,73.149,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.819,0.396,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.048,-0.057,0]},"t":20,"s":[90.481,75.792,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.879,0.99,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.155,0.097,0]},"t":21,"s":[77.84,74.224,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.071,0.857,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.269,-0.012,0]},"t":22,"s":[63.067,64.426,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.698,0.968,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.038,0.2,0]},"t":23,"s":[56.422,72.999,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.807,0.455,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.115,-0.052,0]},"t":24,"s":[68.741,79.126,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.911,0.955,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.147,0.098,0]},"t":25,"s":[101.082,75.355,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.698,0.664,1]},"o":{"x":[0.167,0.167,0.167],"y":[1.265,-0.1,0]},"t":26,"s":[143.609,54.475,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.73,1.014,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.074,0.111,0]},"t":27,"s":[146.608,63.972,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.888,1.027,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.121,0.012,0]},"t":28,"s":[118.472,92.79,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.05,0.968,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.324,0.02,0]},"t":29,"s":[55.394,59.194,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.866,0.94,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.031,-0.051,0]},"t":30,"s":[33.53,103.661,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.028,0.632,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.22,-0.21,0]},"t":31,"s":[68.551,76.112,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.9,1.084,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.021,0.108,0]},"t":32,"s":[89.966,83.951,100]},{"i":{"x":[0.833,0.833,0.833],"y":[2.007,1.005,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.497,0.042,0]},"t":33,"s":[61.396,110.752,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.966,0.971,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.077,0.004,0]},"t":34,"s":[55.64,56.936,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.958,0.958,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.058,-0.045,0]},"t":35,"s":[130.926,113.733,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.106,0.944,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.086,-0.085,0]},"t":36,"s":[86.439,76.969,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.866,0.598,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.047,-0.169,0]},"t":37,"s":[108.294,95.156,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.04,0.836,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.221,0.105,0]},"t":38,"s":[58.545,89.141,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.947,0.959,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.027,0.17,0]},"t":39,"s":[28.376,66.139,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.89,1.228,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.148,-0.079,0]},"t":40,"s":[72.983,43.989,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.128,1.021,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.339,0.061,0]},"t":41,"s":[56.935,55.362,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.199,0.907,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.05,0.017,0]},"t":42,"s":[51.706,12.909,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.938,1.369,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.059,0.774,0]},"t":43,"s":[64.946,65.932,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.252,1.001,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.238,0.068,0]},"t":44,"s":[20.13,72.328,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.97,0.949,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.063,0.001,0]},"t":45,"s":[31.738,37.588,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.893,1.019,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.046,-0.129,0]},"t":46,"s":[-14.966,72.869,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.07,1.207,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.373,0.016,0]},"t":47,"s":[15.041,59.042,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.075,0.894,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.038,0.059,0]},"t":48,"s":[23.68,76.032,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.071,0.973,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.039,0.396,0]},"t":49,"s":[7.79,16.858,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.941,0.564,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.038,-0.041,0]},"t":50,"s":[37.924,1.088,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.813,0.957,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.205,0.103,0]},"t":51,"s":[-17.733,11.662,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.814,1.008,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.15,-0.09,0]},"t":52,"s":[-1.626,56.352,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.897,1.062,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.151,0.008,0]},"t":53,"s":[18.416,34.888,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.817,0.918,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.441,0.035,0]},"t":54,"s":[43.027,58.529,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.877,3.279,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.153,-7.35,0]},"t":55,"s":[48.754,17.354,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.206,0.776,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.259,0.08,0]},"t":56,"s":[55.61,17.816,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.986,1.061,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.059,0.133,0]},"t":57,"s":[58.864,4.732,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.833,0.833,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.017,0.035,0]},"t":58,"s":[47.582,-17.422,100]},{"t":59,"s":[56.939,20.914,100]}],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[0.012,10.621],[-5.695,5.34],[-2.557,-7.377],[3.15,-2.096]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[1,0.8941176470588236,0.11372549019607843,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[-3.421,4.043],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Rectangle 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":10,"op":60,"st":10,"bm":0},{"ddd":0,"ind":44,"ty":4,"nm":"Shape Layer 8","sr":1,"ks":{"o":{"a":1,"k":[{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":9,"s":[0]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":14,"s":[100]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":36,"s":[100]},{"t":52,"s":[0]}],"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.833,"y":0.626},"o":{"x":0.167,"y":0.167},"t":9,"s":[278.864,255.82,0],"to":[-0.509,0.141,0],"ti":[2.192,-0.891,0]},{"i":{"x":0.833,"y":0.768},"o":{"x":0.167,"y":0.107},"t":10,"s":[275.812,256.665,0],"to":[-2.192,0.891,0],"ti":[4.157,-2.88,0]},{"i":{"x":0.833,"y":0.827},"o":{"x":0.167,"y":0.13},"t":11,"s":[265.713,261.168,0],"to":[-4.157,2.88,0],"ti":[3.28,-5.54,0]},{"i":{"x":0.833,"y":0.844},"o":{"x":0.167,"y":0.161},"t":12,"s":[250.868,273.944,0],"to":[-3.28,5.54,0],"ti":[-0.211,-6.285,0]},{"i":{"x":0.833,"y":0.848},"o":{"x":0.167,"y":0.179},"t":13,"s":[246.03,294.41,0],"to":[0.211,6.285,0],"ti":[-2.493,-4.913,0]},{"i":{"x":0.833,"y":0.847},"o":{"x":0.167,"y":0.185},"t":14,"s":[252.134,311.655,0],"to":[2.493,4.913,0],"ti":[-2.968,-3.51,0]},{"i":{"x":0.833,"y":0.846},"o":{"x":0.167,"y":0.184},"t":15,"s":[260.991,323.886,0],"to":[2.968,3.51,0],"ti":[-2.895,-2.554,0]},{"i":{"x":0.833,"y":0.845},"o":{"x":0.167,"y":0.182},"t":16,"s":[269.941,332.712,0],"to":[2.895,2.554,0],"ti":[-2.702,-1.885,0]},{"i":{"x":0.833,"y":0.844},"o":{"x":0.167,"y":0.18},"t":17,"s":[278.362,339.211,0],"to":[2.702,1.885,0],"ti":[-2.495,-1.387,0]},{"i":{"x":0.833,"y":0.843},"o":{"x":0.167,"y":0.179},"t":18,"s":[286.152,344.024,0],"to":[2.495,1.387,0],"ti":[-2.299,-0.994,0]},{"i":{"x":0.833,"y":0.842},"o":{"x":0.167,"y":0.178},"t":19,"s":[293.332,347.535,0],"to":[2.299,0.994,0],"ti":[-2.119,-0.668,0]},{"i":{"x":0.833,"y":0.842},"o":{"x":0.167,"y":0.177},"t":20,"s":[299.949,349.988,0],"to":[2.119,0.668,0],"ti":[-1.954,-0.387,0]},{"i":{"x":0.833,"y":0.841},"o":{"x":0.167,"y":0.176},"t":21,"s":[306.048,351.543,0],"to":[1.954,0.387,0],"ti":[-1.795,-0.137,0]},{"i":{"x":0.833,"y":0.841},"o":{"x":0.167,"y":0.175},"t":22,"s":[311.672,352.309,0],"to":[1.795,0.137,0],"ti":[-1.633,0.088,0]},{"i":{"x":0.833,"y":0.84},"o":{"x":0.167,"y":0.175},"t":23,"s":[316.816,352.363,0],"to":[1.633,-0.088,0],"ti":[-1.471,0.288,0]},{"i":{"x":0.833,"y":0.84},"o":{"x":0.167,"y":0.174},"t":24,"s":[321.472,351.78,0],"to":[1.471,-0.288,0],"ti":[-1.306,0.458,0]},{"i":{"x":0.833,"y":0.84},"o":{"x":0.167,"y":0.173},"t":25,"s":[325.642,350.637,0],"to":[1.306,-0.458,0],"ti":[-1.135,0.591,0]},{"i":{"x":0.833,"y":0.84},"o":{"x":0.167,"y":0.174},"t":26,"s":[329.308,349.031,0],"to":[1.135,-0.591,0],"ti":[-0.968,0.681,0]},{"i":{"x":0.833,"y":0.84},"o":{"x":0.167,"y":0.173},"t":27,"s":[332.455,347.089,0],"to":[0.968,-0.681,0],"ti":[-0.817,0.731,0]},{"i":{"x":0.833,"y":0.84},"o":{"x":0.167,"y":0.173},"t":28,"s":[335.117,344.942,0],"to":[0.817,-0.731,0],"ti":[-0.687,0.746,0]},{"i":{"x":0.833,"y":0.84},"o":{"x":0.167,"y":0.173},"t":29,"s":[337.355,342.705,0],"to":[0.687,-0.746,0],"ti":[-0.581,0.735,0]},{"i":{"x":0.833,"y":0.84},"o":{"x":0.167,"y":0.174},"t":30,"s":[339.24,340.468,0],"to":[0.581,-0.735,0],"ti":[-0.497,0.706,0]},{"i":{"x":0.833,"y":0.84},"o":{"x":0.167,"y":0.174},"t":31,"s":[340.842,338.297,0],"to":[0.497,-0.706,0],"ti":[-0.432,0.666,0]},{"i":{"x":0.833,"y":0.84},"o":{"x":0.167,"y":0.174},"t":32,"s":[342.224,336.234,0],"to":[0.432,-0.666,0],"ti":[-0.383,0.622,0]},{"i":{"x":0.833,"y":0.84},"o":{"x":0.167,"y":0.174},"t":33,"s":[343.437,334.299,0],"to":[0.383,-0.622,0],"ti":[-0.345,0.576,0]},{"i":{"x":0.833,"y":0.84},"o":{"x":0.167,"y":0.174},"t":34,"s":[344.521,332.502,0],"to":[0.345,-0.576,0],"ti":[-0.315,0.529,0]},{"i":{"x":0.833,"y":0.839},"o":{"x":0.167,"y":0.174},"t":35,"s":[345.505,330.845,0],"to":[0.315,-0.529,0],"ti":[-0.292,0.487,0]},{"i":{"x":0.833,"y":0.837},"o":{"x":0.167,"y":0.173},"t":36,"s":[346.41,329.327,0],"to":[0.292,-0.487,0],"ti":[-0.279,0.457,0]},{"i":{"x":0.833,"y":0.837},"o":{"x":0.167,"y":0.17},"t":37,"s":[347.258,327.924,0],"to":[0.279,-0.457,0],"ti":[-0.273,0.434,0]},{"i":{"x":0.833,"y":0.837},"o":{"x":0.167,"y":0.17},"t":38,"s":[348.086,326.587,0],"to":[0.273,-0.434,0],"ti":[-0.265,0.411,0]},{"i":{"x":0.833,"y":0.838},"o":{"x":0.167,"y":0.171},"t":39,"s":[348.894,325.317,0],"to":[0.265,-0.411,0],"ti":[-0.255,0.387,0]},{"i":{"x":0.833,"y":0.839},"o":{"x":0.167,"y":0.172},"t":40,"s":[349.675,324.119,0],"to":[0.255,-0.387,0],"ti":[-0.243,0.361,0]},{"i":{"x":0.833,"y":0.84},"o":{"x":0.167,"y":0.172},"t":41,"s":[350.425,322.996,0],"to":[0.243,-0.361,0],"ti":[-0.229,0.334,0]},{"i":{"x":0.833,"y":0.841},"o":{"x":0.167,"y":0.173},"t":42,"s":[351.135,321.951,0],"to":[0.229,-0.334,0],"ti":[-0.212,0.306,0]},{"i":{"x":0.833,"y":0.841},"o":{"x":0.167,"y":0.175},"t":43,"s":[351.799,320.989,0],"to":[0.212,-0.306,0],"ti":[-0.194,0.278,0]},{"i":{"x":0.833,"y":0.843},"o":{"x":0.167,"y":0.176},"t":44,"s":[352.409,320.113,0],"to":[0.194,-0.278,0],"ti":[-0.173,0.249,0]},{"i":{"x":0.833,"y":0.845},"o":{"x":0.167,"y":0.177},"t":45,"s":[352.961,319.322,0],"to":[0.173,-0.249,0],"ti":[-0.15,0.219,0]},{"i":{"x":0.833,"y":0.847},"o":{"x":0.167,"y":0.18},"t":46,"s":[353.448,318.619,0],"to":[0.15,-0.219,0],"ti":[-0.126,0.188,0]},{"i":{"x":0.833,"y":0.85},"o":{"x":0.167,"y":0.183},"t":47,"s":[353.864,318.007,0],"to":[0.126,-0.188,0],"ti":[-0.101,0.156,0]},{"i":{"x":0.833,"y":0.848},"o":{"x":0.167,"y":0.188},"t":48,"s":[354.205,317.489,0],"to":[0.101,-0.156,0],"ti":[-0.075,0.131,0]},{"i":{"x":0.833,"y":0.842},"o":{"x":0.167,"y":0.184},"t":49,"s":[354.467,317.068,0],"to":[0.075,-0.131,0],"ti":[-0.049,0.118,0]},{"i":{"x":0.833,"y":0.842},"o":{"x":0.167,"y":0.177},"t":50,"s":[354.652,316.704,0],"to":[0.049,-0.118,0],"ti":[-0.024,0.112,0]},{"i":{"x":0.833,"y":0.84},"o":{"x":0.167,"y":0.176},"t":51,"s":[354.762,316.358,0],"to":[0.024,-0.112,0],"ti":[0.001,0.104,0]},{"i":{"x":0.833,"y":0.836},"o":{"x":0.167,"y":0.174},"t":52,"s":[354.797,316.033,0],"to":[-0.001,-0.104,0],"ti":[0.025,0.094,0]},{"i":{"x":0.833,"y":0.832},"o":{"x":0.167,"y":0.17},"t":53,"s":[354.758,315.735,0],"to":[-0.025,-0.094,0],"ti":[0.049,0.084,0]},{"i":{"x":0.833,"y":0.828},"o":{"x":0.167,"y":0.165},"t":54,"s":[354.646,315.467,0],"to":[-0.049,-0.084,0],"ti":[0.071,0.072,0]},{"i":{"x":0.833,"y":0.826},"o":{"x":0.167,"y":0.162},"t":55,"s":[354.465,315.232,0],"to":[-0.071,-0.072,0],"ti":[0.091,0.06,0]},{"i":{"x":0.833,"y":0.826},"o":{"x":0.167,"y":0.16},"t":56,"s":[354.22,315.034,0],"to":[-0.091,-0.06,0],"ti":[0.109,0.047,0]},{"i":{"x":0.833,"y":0.827},"o":{"x":0.167,"y":0.16},"t":57,"s":[353.918,314.873,0],"to":[-0.109,-0.047,0],"ti":[0.124,0.033,0]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.161},"t":58,"s":[353.565,314.754,0],"to":[-0.124,-0.033,0],"ti":[0.066,0.013,0]},{"t":59,"s":[353.172,314.677,0]}],"ix":2,"l":2},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":1,"k":[{"i":{"x":[0.833,0.833,0.833],"y":[0.872,1.101,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.167,0.167,0]},"t":9,"s":[121.274,69.223,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.683,1.187,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.239,0.046,0]},"t":10,"s":[102.389,75.161,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.98,0.955,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.113,0.058,0]},"t":11,"s":[92.262,61.994,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.931,1.014,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.026,-0.097,0]},"t":12,"s":[63.863,104.769,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.274,0.951,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.401,0.012,0]},"t":13,"s":[85.432,85.001,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.092,1.206,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.094,-0.118,0]},"t":14,"s":[81.722,108.079,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.882,0.993,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.044,0.059,0]},"t":15,"s":[53.108,98.543,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.078,0.868,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.286,-0.008,0]},"t":16,"s":[113.303,131.662,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.91,0.819,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.04,0.226,0]},"t":17,"s":[138.039,101.322,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.965,1.01,1]},"o":{"x":[0.167,0.167,0.167],"y":[1.209,0.154,0]},"t":18,"s":[90.083,83.561,100]},{"i":{"x":[0.833,0.833,0.833],"y":[2.383,0.796,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.061,0.009,0]},"t":19,"s":[86.533,62.73,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.943,1.025,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.079,0.141,0]},"t":20,"s":[88.578,85.983,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.633,0.981,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.181,0.019,0]},"t":21,"s":[52.598,119.725,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.913,0.912,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.108,-0.024,0]},"t":22,"s":[63.951,75.737,100]},{"i":{"x":[0.833,0.833,0.833],"y":[-0.114,2.104,1]},"o":{"x":[0.167,0.167,0.167],"y":[1.9,1.706,0]},"t":23,"s":[102.646,109.959,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.968,0.914,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.09,0.077,0]},"t":24,"s":[104.421,111.717,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.984,1.195,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.051,2.682,0]},"t":25,"s":[126.369,86.671,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.465,2.183,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.02,0.058,0]},"t":26,"s":[112.748,85.867,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.895,1.029,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.071,0.078,0]},"t":27,"s":[123.717,88.551,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.153,0.911,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.407,0.021,0]},"t":28,"s":[51.588,47.768,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.924,2.015,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.054,1.345,0]},"t":29,"s":[33.003,102.64,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.199,1.013,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.892,0.077,0]},"t":30,"s":[85.701,106.264,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.078,0.966,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.059,0.011,0]},"t":31,"s":[81.198,58.493,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.008,0.991,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.04,-0.058,0]},"t":32,"s":[96.43,113.732,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.02,0.927,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.007,-0.01,0]},"t":33,"s":[67.01,81.239,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.963,0.771,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.016,-0.605,0]},"t":34,"s":[99.306,110.284,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.823,0.301,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.067,0.131,0]},"t":35,"s":[59.341,106.769,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.189,0.922,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.158,0.095,0]},"t":36,"s":[81.457,100.615,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.974,1.274,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.058,-1.127,0]},"t":37,"s":[106.222,55.153,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.965,0.719,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.038,0.064,0]},"t":38,"s":[25.147,58.283,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.948,0.958,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.061,0.119,0]},"t":39,"s":[80.819,44.863,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.833,0.839,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.137,-0.083,0]},"t":40,"s":[48.72,13.108,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.058,0.872,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.167,0.173,0]},"t":41,"s":[60.869,29.019,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.967,1.357,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.034,0.237,0]},"t":42,"s":[73.011,43.835,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.288,1.012,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.054,0.068,0]},"t":43,"s":[52.393,51.855,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.942,0.906,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.065,0.011,0]},"t":44,"s":[64.871,9.492,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.756,0.775,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.193,0.731,0]},"t":45,"s":[9.336,58.19,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.038,1.229,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.127,0.132,0]},"t":46,"s":[26.111,64.455,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.014,0.878,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.026,0.061,0]},"t":47,"s":[58.4,75.08,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.98,0.77,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.012,0.262,0]},"t":48,"s":[11.51,35.267,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.932,1.06,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.026,0.131,0]},"t":49,"s":[66.25,16.716,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.825,0.866,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.377,0.035,0]},"t":50,"s":[24.642,-15.996,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.942,0.933,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.159,0.221,0]},"t":51,"s":[32.168,40.121,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.214,0.377,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.193,-0.341,0]},"t":52,"s":[40.464,74.155,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.906,0.958,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.06,0.096,0]},"t":53,"s":[37.96,67.475,100]},{"i":{"x":[0.833,0.833,0.833],"y":[-0.566,1.064,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.711,-0.084,0]},"t":54,"s":[46.883,24.242,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.073,0.829,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.088,0.036,0]},"t":55,"s":[48.067,45.748,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.938,1.019,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.039,0.163,0]},"t":56,"s":[69.141,7.771,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.021,0.887,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.247,0.016,0]},"t":57,"s":[29.508,-32.064,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.833,0.833,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.017,0.316,0]},"t":58,"s":[39.499,17.057,100]},{"t":59,"s":[27.006,34.616,100]}],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"d":1,"ty":"el","s":{"a":0,"k":[10,10],"ix":2},"p":{"a":0,"k":[0,0],"ix":3},"nm":"Ellipse Path 1","mn":"ADBE Vector Shape - Ellipse","hd":false},{"ty":"fl","c":{"a":0,"k":[1,0.10196078431372549,0.3803921568627451,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Ellipse 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":9,"op":60,"st":9,"bm":0},{"ddd":0,"ind":45,"ty":4,"nm":"Shape Layer 7","sr":1,"ks":{"o":{"a":1,"k":[{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":5,"s":[0]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":10,"s":[100]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":32,"s":[100]},{"t":48,"s":[0]}],"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.833,"y":0.638},"o":{"x":0.167,"y":0.167},"t":5,"s":[269.945,256.942,0],"to":[-0.448,-0.21,0],"ti":[1.991,0.807,0]},{"i":{"x":0.833,"y":0.768},"o":{"x":0.167,"y":0.108},"t":6,"s":[267.255,255.684,0],"to":[-1.991,-0.807,0],"ti":[4.389,1.346,0]},{"i":{"x":0.833,"y":0.825},"o":{"x":0.167,"y":0.13},"t":7,"s":[257.999,252.103,0],"to":[-4.389,-1.346,0],"ti":[6.039,1.363,0]},{"i":{"x":0.833,"y":0.844},"o":{"x":0.167,"y":0.159},"t":8,"s":[240.923,247.608,0],"to":[-6.039,-1.363,0],"ti":[5.978,1.14,0]},{"i":{"x":0.833,"y":0.847},"o":{"x":0.167,"y":0.179},"t":9,"s":[221.767,243.923,0],"to":[-5.978,-1.14,0],"ti":[5.09,1.054,0]},{"i":{"x":0.833,"y":0.847},"o":{"x":0.167,"y":0.183},"t":10,"s":[205.053,240.767,0],"to":[-5.09,-1.054,0],"ti":[4.207,1.095,0]},{"i":{"x":0.833,"y":0.845},"o":{"x":0.167,"y":0.183},"t":11,"s":[191.227,237.6,0],"to":[-4.207,-1.095,0],"ti":[3.481,1.188,0]},{"i":{"x":0.833,"y":0.844},"o":{"x":0.167,"y":0.181},"t":12,"s":[179.812,234.195,0],"to":[-3.481,-1.188,0],"ti":[2.886,1.294,0]},{"i":{"x":0.833,"y":0.844},"o":{"x":0.167,"y":0.179},"t":13,"s":[170.338,230.471,0],"to":[-2.886,-1.294,0],"ti":[2.378,1.39,0]},{"i":{"x":0.833,"y":0.843},"o":{"x":0.167,"y":0.178},"t":14,"s":[162.496,226.433,0],"to":[-2.378,-1.39,0],"ti":[1.933,1.462,0]},{"i":{"x":0.833,"y":0.842},"o":{"x":0.167,"y":0.178},"t":15,"s":[156.068,222.132,0],"to":[-1.933,-1.462,0],"ti":[1.54,1.499,0]},{"i":{"x":0.833,"y":0.842},"o":{"x":0.167,"y":0.177},"t":16,"s":[150.896,217.662,0],"to":[-1.54,-1.499,0],"ti":[1.198,1.498,0]},{"i":{"x":0.833,"y":0.842},"o":{"x":0.167,"y":0.177},"t":17,"s":[146.827,213.136,0],"to":[-1.198,-1.498,0],"ti":[0.906,1.46,0]},{"i":{"x":0.833,"y":0.842},"o":{"x":0.167,"y":0.176},"t":18,"s":[143.71,208.674,0],"to":[-0.906,-1.46,0],"ti":[0.665,1.393,0]},{"i":{"x":0.833,"y":0.842},"o":{"x":0.167,"y":0.176},"t":19,"s":[141.391,204.376,0],"to":[-0.665,-1.393,0],"ti":[0.471,1.306,0]},{"i":{"x":0.833,"y":0.841},"o":{"x":0.167,"y":0.176},"t":20,"s":[139.72,200.316,0],"to":[-0.471,-1.306,0],"ti":[0.313,1.216,0]},{"i":{"x":0.833,"y":0.84},"o":{"x":0.167,"y":0.175},"t":21,"s":[138.565,196.541,0],"to":[-0.313,-1.216,0],"ti":[0.184,1.137,0]},{"i":{"x":0.833,"y":0.839},"o":{"x":0.167,"y":0.174},"t":22,"s":[137.84,193.02,0],"to":[-0.184,-1.137,0],"ti":[0.081,1.063,0]},{"i":{"x":0.833,"y":0.839},"o":{"x":0.167,"y":0.173},"t":23,"s":[137.463,189.72,0],"to":[-0.081,-1.063,0],"ti":[0.003,0.99,0]},{"i":{"x":0.833,"y":0.839},"o":{"x":0.167,"y":0.173},"t":24,"s":[137.352,186.641,0],"to":[-0.003,-0.99,0],"ti":[-0.055,0.919,0]},{"i":{"x":0.833,"y":0.839},"o":{"x":0.167,"y":0.173},"t":25,"s":[137.442,183.779,0],"to":[0.055,-0.919,0],"ti":[-0.097,0.849,0]},{"i":{"x":0.833,"y":0.84},"o":{"x":0.167,"y":0.173},"t":26,"s":[137.68,181.129,0],"to":[0.097,-0.849,0],"ti":[-0.126,0.781,0]},{"i":{"x":0.833,"y":0.84},"o":{"x":0.167,"y":0.173},"t":27,"s":[138.024,178.686,0],"to":[0.126,-0.781,0],"ti":[-0.145,0.716,0]},{"i":{"x":0.833,"y":0.84},"o":{"x":0.167,"y":0.174},"t":28,"s":[138.439,176.442,0],"to":[0.145,-0.716,0],"ti":[-0.156,0.653,0]},{"i":{"x":0.833,"y":0.841},"o":{"x":0.167,"y":0.174},"t":29,"s":[138.897,174.391,0],"to":[0.156,-0.653,0],"ti":[-0.159,0.592,0]},{"i":{"x":0.833,"y":0.841},"o":{"x":0.167,"y":0.175},"t":30,"s":[139.374,172.526,0],"to":[0.159,-0.592,0],"ti":[-0.157,0.533,0]},{"i":{"x":0.833,"y":0.842},"o":{"x":0.167,"y":0.176},"t":31,"s":[139.853,170.839,0],"to":[0.157,-0.533,0],"ti":[-0.15,0.477,0]},{"i":{"x":0.833,"y":0.843},"o":{"x":0.167,"y":0.176},"t":32,"s":[140.317,169.325,0],"to":[0.15,-0.477,0],"ti":[-0.139,0.422,0]},{"i":{"x":0.833,"y":0.844},"o":{"x":0.167,"y":0.178},"t":33,"s":[140.753,167.978,0],"to":[0.139,-0.422,0],"ti":[-0.125,0.37,0]},{"i":{"x":0.833,"y":0.845},"o":{"x":0.167,"y":0.179},"t":34,"s":[141.152,166.791,0],"to":[0.125,-0.37,0],"ti":[-0.109,0.319,0]},{"i":{"x":0.833,"y":0.847},"o":{"x":0.167,"y":0.181},"t":35,"s":[141.505,165.76,0],"to":[0.109,-0.319,0],"ti":[-0.091,0.27,0]},{"i":{"x":0.833,"y":0.85},"o":{"x":0.167,"y":0.183},"t":36,"s":[141.806,164.879,0],"to":[0.091,-0.27,0],"ti":[-0.072,0.222,0]},{"i":{"x":0.833,"y":0.853},"o":{"x":0.167,"y":0.187},"t":37,"s":[142.051,164.142,0],"to":[0.072,-0.222,0],"ti":[-0.052,0.176,0]},{"i":{"x":0.833,"y":0.858},"o":{"x":0.167,"y":0.192},"t":38,"s":[142.237,163.545,0],"to":[0.052,-0.176,0],"ti":[-0.032,0.132,0]},{"i":{"x":0.833,"y":0.865},"o":{"x":0.167,"y":0.201},"t":39,"s":[142.363,163.084,0],"to":[0.032,-0.132,0],"ti":[-0.012,0.09,0]},{"i":{"x":0.833,"y":0.873},"o":{"x":0.167,"y":0.218},"t":40,"s":[142.428,162.753,0],"to":[0.012,-0.09,0],"ti":[0.007,0.05,0]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.243},"t":41,"s":[142.433,162.544,0],"to":[-0.007,-0.05,0],"ti":[0.026,0.012,0]},{"i":{"x":0.833,"y":0.765},"o":{"x":0.167,"y":0.167},"t":42,"s":[142.383,162.452,0],"to":[-0.026,-0.012,0],"ti":[0.042,-0.024,0]},{"i":{"x":0.833,"y":0.791},"o":{"x":0.167,"y":0.129},"t":43,"s":[142.28,162.472,0],"to":[-0.042,0.024,0],"ti":[0.057,-0.059,0]},{"i":{"x":0.833,"y":0.816},"o":{"x":0.167,"y":0.139},"t":44,"s":[142.128,162.599,0],"to":[-0.057,0.059,0],"ti":[0.07,-0.085,0]},{"i":{"x":0.833,"y":0.828},"o":{"x":0.167,"y":0.152},"t":45,"s":[141.935,162.827,0],"to":[-0.07,0.085,0],"ti":[0.078,-0.097,0]},{"i":{"x":0.833,"y":0.83},"o":{"x":0.167,"y":0.162},"t":46,"s":[141.71,163.111,0],"to":[-0.078,0.097,0],"ti":[0.083,-0.101,0]},{"i":{"x":0.833,"y":0.832},"o":{"x":0.167,"y":0.164},"t":47,"s":[141.466,163.41,0],"to":[-0.083,0.101,0],"ti":[0.085,-0.104,0]},{"i":{"x":0.833,"y":0.835},"o":{"x":0.167,"y":0.166},"t":48,"s":[141.211,163.718,0],"to":[-0.085,0.104,0],"ti":[0.084,-0.104,0]},{"i":{"x":0.833,"y":0.837},"o":{"x":0.167,"y":0.168},"t":49,"s":[140.955,164.031,0],"to":[-0.084,0.104,0],"ti":[0.078,-0.103,0]},{"i":{"x":0.833,"y":0.829},"o":{"x":0.167,"y":0.171},"t":50,"s":[140.709,164.343,0],"to":[-0.078,0.103,0],"ti":[0.08,-0.102,0]},{"i":{"x":0.833,"y":0.824},"o":{"x":0.167,"y":0.163},"t":51,"s":[140.484,164.649,0],"to":[-0.08,0.102,0],"ti":[0.095,-0.103,0]},{"i":{"x":0.833,"y":0.828},"o":{"x":0.167,"y":0.159},"t":52,"s":[140.231,164.956,0],"to":[-0.095,0.103,0],"ti":[0.112,-0.102,0]},{"i":{"x":0.833,"y":0.831},"o":{"x":0.167,"y":0.161},"t":53,"s":[139.917,165.266,0],"to":[-0.112,0.102,0],"ti":[0.125,-0.098,0]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.164},"t":54,"s":[139.556,165.569,0],"to":[-0.125,0.098,0],"ti":[0.134,-0.092,0]},{"i":{"x":0.833,"y":0.835},"o":{"x":0.167,"y":0.166},"t":55,"s":[139.164,165.856,0],"to":[-0.134,0.092,0],"ti":[0.138,-0.083,0]},{"i":{"x":0.833,"y":0.837},"o":{"x":0.167,"y":0.168},"t":56,"s":[138.753,166.12,0],"to":[-0.138,0.083,0],"ti":[0.139,-0.072,0]},{"i":{"x":0.833,"y":0.839},"o":{"x":0.167,"y":0.17},"t":57,"s":[138.335,166.353,0],"to":[-0.139,0.072,0],"ti":[0.135,-0.059,0]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.172},"t":58,"s":[137.921,166.55,0],"to":[-0.135,0.059,0],"ti":[0.067,-0.026,0]},{"t":59,"s":[137.522,166.706,0]}],"ix":2,"l":2},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":1,"k":[{"i":{"x":[0.833,0.833,0.833],"y":[1.747,0.81,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.167,0.167,0]},"t":5,"s":[67.084,129.75,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.927,0.854,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.075,0.148,0]},"t":6,"s":[62.33,109.198,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.16,1.108,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.593,0.195,0]},"t":7,"s":[109.714,82.895,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.95,1.011,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.055,0.047,0]},"t":8,"s":[103.874,63.18,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.652,0.949,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.126,0.009,0]},"t":9,"s":[120.913,108.471,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.996,0.969,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.11,-0.133,0]},"t":10,"s":[114.137,57.365,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.991,1.188,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.004,-0.05,0]},"t":11,"s":[92.637,77.058,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.717,0.892,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.01,0.058,0]},"t":12,"s":[113.058,64.766,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.956,0.862,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.118,0.369,0]},"t":13,"s":[94.823,104.736,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.686,1.222,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.095,0.21,0]},"t":14,"s":[51.043,116.383,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.935,0.877,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.113,0.061,0]},"t":15,"s":[71.52,124.057,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.826,0.869,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.292,0.259,0]},"t":16,"s":[128.168,95.925,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.867,1.124,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.16,0.23,0]},"t":17,"s":[115.579,82.608,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.512,0.751,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.223,0.05,0]},"t":18,"s":[101.896,75.048,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.973,1.014,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.1,0.125,0]},"t":19,"s":[93.758,93.897,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.898,0.923,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.041,0.012,0]},"t":20,"s":[54.25,131.368,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.724,0.144,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.465,-1.005,0]},"t":21,"s":[80.758,87.771,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.924,1.014,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.119,0.092,0]},"t":22,"s":[86.54,91.108,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.904,0.995,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.912,0.012,0]},"t":23,"s":[99.918,122.044,100]},{"i":{"x":[0.833,0.833,0.833],"y":[11.64,0.966,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.63,-0.006,0]},"t":24,"s":[98.798,85.895,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.179,0.764,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.083,-0.058,0]},"t":25,"s":[98.627,119.725,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.897,1,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.057,0.129,0]},"t":26,"s":[120.625,99.715,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.129,0.846,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.428,0,0]},"t":27,"s":[51.46,62.943,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.842,1.057,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.051,0.182,0]},"t":28,"s":[34.76,99.837,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.887,0.971,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.176,0.034,0]},"t":29,"s":[77.215,131.147,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.072,1,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.319,-0.044,0]},"t":30,"s":[115.216,78.51,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.783,0.915,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.039,0,0]},"t":31,"s":[128.632,112.897,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.926,4.711,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.135,4.172,0]},"t":32,"s":[103.693,78.444,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.008,0.987,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.662,0.082,0]},"t":33,"s":[63.665,77.742,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.628,0.82,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.007,-0.016,0]},"t":34,"s":[68.138,109.713,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.039,0.849,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.074,0.155,0]},"t":35,"s":[63.26,82.764,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.925,1.07,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.027,0.186,0]},"t":36,"s":[104.89,51.456,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.219,1.029,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.727,0.038,0]},"t":37,"s":[43.633,26.02,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.892,0.966,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.06,0.022,0]},"t":38,"s":[49.933,72.695,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.243,0.988,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.367,-0.056,0]},"t":39,"s":[27.075,9.722,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.832,0.92,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.062,-0.015,0]},"t":40,"s":[20.365,47.293,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.936,3.513,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.165,-2.021,0]},"t":41,"s":[46.638,15.298,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.362,0.978,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.284,0.081,0]},"t":42,"s":[73.418,16.565,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.887,0.789,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.096,-0.029,0]},"t":43,"s":[67.341,-22.905,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.174,0.978,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.315,0.138,0]},"t":44,"s":[26.919,6.334,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.937,0.904,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.056,-0.031,0]},"t":45,"s":[12.404,51.222,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.142,1.239,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.264,0.63,0]},"t":46,"s":[57.152,18.419,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.933,1.11,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.052,0.062,0]},"t":47,"s":[46.41,13.418,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.849,0.964,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.344,0.047,0]},"t":48,"s":[75.422,32.787,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.055,0.769,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.185,-0.063,0]},"t":49,"s":[69.769,-12.086,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.398,0.912,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.033,0.13,0]},"t":50,"s":[65.147,13.473,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.884,2.464,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.069,1.481,0]},"t":51,"s":[72.836,58.774,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.911,0.943,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.296,0.079,0]},"t":52,"s":[28.388,61.475,100]},{"i":{"x":[0.833,0.833,0.833],"y":[3.369,0.846,1]},"o":{"x":[0.167,0.167,0.167],"y":[1.317,-0.176,0]},"t":53,"s":[10.975,11.341,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.072,1.254,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.081,0.182,0]},"t":54,"s":[9.799,27.481,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.975,0.968,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.039,0.063,0]},"t":55,"s":[44.403,41.158,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.912,0.931,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.036,-0.052,0]},"t":56,"s":[-20.175,-14.141,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.193,1.424,1]},"o":{"x":[0.167,0.167,0.167],"y":[1.504,-0.402,0]},"t":57,"s":[25.048,19.928,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.833,0.833,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.093,0.07,0]},"t":58,"s":[27.7,14.076,100]},{"t":59,"s":[50.717,49.703,100]}],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"d":1,"ty":"el","s":{"a":0,"k":[10,10],"ix":2},"p":{"a":0,"k":[0,0],"ix":3},"nm":"Ellipse Path 1","mn":"ADBE Vector Shape - Ellipse","hd":false},{"ty":"fl","c":{"a":0,"k":[1,0.10196078431372549,0.3803921568627451,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Ellipse 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":5,"op":60,"st":5,"bm":0},{"ddd":0,"ind":46,"ty":4,"nm":"Shape Layer 6","sr":1,"ks":{"o":{"a":1,"k":[{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":12,"s":[0]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":17,"s":[100]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":39,"s":[100]},{"t":55,"s":[0]}],"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.833,"y":0.603},"o":{"x":0.167,"y":0.167},"t":12,"s":[269.021,253.165,0],"to":[0.334,-0.343,0],"ti":[-1.472,1.743,0]},{"i":{"x":0.833,"y":0.765},"o":{"x":0.167,"y":0.105},"t":13,"s":[271.027,251.106,0],"to":[1.472,-1.743,0],"ti":[-3.01,4.091,0]},{"i":{"x":0.833,"y":0.824},"o":{"x":0.167,"y":0.129},"t":14,"s":[277.851,242.705,0],"to":[3.01,-4.091,0],"ti":[-3.712,5.823,0]},{"i":{"x":0.833,"y":0.844},"o":{"x":0.167,"y":0.159},"t":15,"s":[289.088,226.561,0],"to":[3.712,-5.823,0],"ti":[-3.262,5.954,0]},{"i":{"x":0.833,"y":0.847},"o":{"x":0.167,"y":0.179},"t":16,"s":[300.123,207.765,0],"to":[3.262,-5.954,0],"ti":[-2.491,5.217,0]},{"i":{"x":0.833,"y":0.847},"o":{"x":0.167,"y":0.184},"t":17,"s":[308.66,190.839,0],"to":[2.491,-5.217,0],"ti":[-1.89,4.434,0]},{"i":{"x":0.833,"y":0.846},"o":{"x":0.167,"y":0.183},"t":18,"s":[315.071,176.461,0],"to":[1.89,-4.434,0],"ti":[-1.471,3.789,0]},{"i":{"x":0.833,"y":0.845},"o":{"x":0.167,"y":0.181},"t":19,"s":[320.001,164.236,0],"to":[1.471,-3.789,0],"ti":[-1.175,3.273,0]},{"i":{"x":0.833,"y":0.844},"o":{"x":0.167,"y":0.18},"t":20,"s":[323.898,153.727,0],"to":[1.175,-3.273,0],"ti":[-0.958,2.857,0]},{"i":{"x":0.833,"y":0.843},"o":{"x":0.167,"y":0.179},"t":21,"s":[327.049,144.595,0],"to":[0.958,-2.857,0],"ti":[-0.794,2.513,0]},{"i":{"x":0.833,"y":0.843},"o":{"x":0.167,"y":0.178},"t":22,"s":[329.644,136.588,0],"to":[0.794,-2.513,0],"ti":[-0.666,2.226,0]},{"i":{"x":0.833,"y":0.843},"o":{"x":0.167,"y":0.178},"t":23,"s":[331.811,129.516,0],"to":[0.666,-2.226,0],"ti":[-0.563,1.981,0]},{"i":{"x":0.833,"y":0.842},"o":{"x":0.167,"y":0.177},"t":24,"s":[333.638,123.235,0],"to":[0.563,-1.981,0],"ti":[-0.48,1.769,0]},{"i":{"x":0.833,"y":0.842},"o":{"x":0.167,"y":0.177},"t":25,"s":[335.19,117.632,0],"to":[0.48,-1.769,0],"ti":[-0.41,1.586,0]},{"i":{"x":0.833,"y":0.842},"o":{"x":0.167,"y":0.176},"t":26,"s":[336.516,112.618,0],"to":[0.41,-1.586,0],"ti":[-0.351,1.424,0]},{"i":{"x":0.833,"y":0.842},"o":{"x":0.167,"y":0.176},"t":27,"s":[337.65,108.119,0],"to":[0.351,-1.424,0],"ti":[-0.3,1.282,0]},{"i":{"x":0.833,"y":0.842},"o":{"x":0.167,"y":0.176},"t":28,"s":[338.62,104.073,0],"to":[0.3,-1.282,0],"ti":[-0.255,1.156,0]},{"i":{"x":0.833,"y":0.842},"o":{"x":0.167,"y":0.176},"t":29,"s":[339.448,100.428,0],"to":[0.255,-1.156,0],"ti":[-0.216,1.044,0]},{"i":{"x":0.833,"y":0.841},"o":{"x":0.167,"y":0.176},"t":30,"s":[340.152,97.139,0],"to":[0.216,-1.044,0],"ti":[-0.181,0.944,0]},{"i":{"x":0.833,"y":0.841},"o":{"x":0.167,"y":0.176},"t":31,"s":[340.744,94.166,0],"to":[0.181,-0.944,0],"ti":[-0.149,0.856,0]},{"i":{"x":0.833,"y":0.841},"o":{"x":0.167,"y":0.175},"t":32,"s":[341.237,91.474,0],"to":[0.149,-0.856,0],"ti":[-0.12,0.778,0]},{"i":{"x":0.833,"y":0.841},"o":{"x":0.167,"y":0.175},"t":33,"s":[341.639,89.031,0],"to":[0.12,-0.778,0],"ti":[-0.094,0.709,0]},{"i":{"x":0.833,"y":0.841},"o":{"x":0.167,"y":0.175},"t":34,"s":[341.96,86.808,0],"to":[0.094,-0.709,0],"ti":[-0.069,0.648,0]},{"i":{"x":0.833,"y":0.842},"o":{"x":0.167,"y":0.175},"t":35,"s":[342.205,84.778,0],"to":[0.069,-0.648,0],"ti":[-0.038,0.59,0]},{"i":{"x":0.833,"y":0.842},"o":{"x":0.167,"y":0.176},"t":36,"s":[342.374,82.918,0],"to":[0.038,-0.59,0],"ti":[-0.003,0.532,0]},{"i":{"x":0.833,"y":0.841},"o":{"x":0.167,"y":0.176},"t":37,"s":[342.434,81.24,0],"to":[0.003,-0.532,0],"ti":[0.027,0.479,0]},{"i":{"x":0.833,"y":0.841},"o":{"x":0.167,"y":0.175},"t":38,"s":[342.395,79.728,0],"to":[-0.027,-0.479,0],"ti":[0.051,0.433,0]},{"i":{"x":0.833,"y":0.84},"o":{"x":0.167,"y":0.175},"t":39,"s":[342.275,78.364,0],"to":[-0.051,-0.433,0],"ti":[0.069,0.392,0]},{"i":{"x":0.833,"y":0.84},"o":{"x":0.167,"y":0.174},"t":40,"s":[342.091,77.129,0],"to":[-0.069,-0.392,0],"ti":[0.082,0.356,0]},{"i":{"x":0.833,"y":0.84},"o":{"x":0.167,"y":0.174},"t":41,"s":[341.861,76.01,0],"to":[-0.082,-0.356,0],"ti":[0.091,0.324,0]},{"i":{"x":0.833,"y":0.84},"o":{"x":0.167,"y":0.174},"t":42,"s":[341.597,74.992,0],"to":[-0.091,-0.324,0],"ti":[0.096,0.296,0]},{"i":{"x":0.833,"y":0.84},"o":{"x":0.167,"y":0.173},"t":43,"s":[341.314,74.063,0],"to":[-0.096,-0.296,0],"ti":[0.096,0.272,0]},{"i":{"x":0.833,"y":0.84},"o":{"x":0.167,"y":0.173},"t":44,"s":[341.023,73.213,0],"to":[-0.096,-0.272,0],"ti":[0.094,0.25,0]},{"i":{"x":0.833,"y":0.84},"o":{"x":0.167,"y":0.174},"t":45,"s":[340.736,72.433,0],"to":[-0.094,-0.25,0],"ti":[0.088,0.23,0]},{"i":{"x":0.833,"y":0.84},"o":{"x":0.167,"y":0.174},"t":46,"s":[340.462,71.716,0],"to":[-0.088,-0.23,0],"ti":[0.08,0.212,0]},{"i":{"x":0.833,"y":0.841},"o":{"x":0.167,"y":0.174},"t":47,"s":[340.209,71.055,0],"to":[-0.08,-0.212,0],"ti":[0.069,0.195,0]},{"i":{"x":0.833,"y":0.841},"o":{"x":0.167,"y":0.175},"t":48,"s":[339.984,70.445,0],"to":[-0.069,-0.195,0],"ti":[0.056,0.18,0]},{"i":{"x":0.833,"y":0.842},"o":{"x":0.167,"y":0.175},"t":49,"s":[339.795,69.882,0],"to":[-0.056,-0.18,0],"ti":[0.042,0.165,0]},{"i":{"x":0.833,"y":0.842},"o":{"x":0.167,"y":0.176},"t":50,"s":[339.646,69.365,0],"to":[-0.042,-0.165,0],"ti":[0.027,0.151,0]},{"i":{"x":0.833,"y":0.834},"o":{"x":0.167,"y":0.176},"t":51,"s":[339.542,68.892,0],"to":[-0.027,-0.151,0],"ti":[0.01,0.143,0]},{"i":{"x":0.833,"y":0.826},"o":{"x":0.167,"y":0.168},"t":52,"s":[339.484,68.461,0],"to":[-0.01,-0.143,0],"ti":[-0.008,0.149,0]},{"i":{"x":0.833,"y":0.826},"o":{"x":0.167,"y":0.16},"t":53,"s":[339.48,68.033,0],"to":[0.008,-0.149,0],"ti":[-0.028,0.16,0]},{"i":{"x":0.833,"y":0.827},"o":{"x":0.167,"y":0.16},"t":54,"s":[339.535,67.568,0],"to":[0.028,-0.16,0],"ti":[-0.047,0.169,0]},{"i":{"x":0.833,"y":0.828},"o":{"x":0.167,"y":0.161},"t":55,"s":[339.647,67.072,0],"to":[0.047,-0.169,0],"ti":[-0.065,0.176,0]},{"i":{"x":0.833,"y":0.829},"o":{"x":0.167,"y":0.162},"t":56,"s":[339.816,66.553,0],"to":[0.065,-0.176,0],"ti":[-0.083,0.179,0]},{"i":{"x":0.833,"y":0.831},"o":{"x":0.167,"y":0.163},"t":57,"s":[340.039,66.018,0],"to":[0.083,-0.179,0],"ti":[-0.099,0.18,0]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.164},"t":58,"s":[340.312,65.476,0],"to":[0.099,-0.18,0],"ti":[-0.053,0.09,0]},{"t":59,"s":[340.633,64.937,0]}],"ix":2,"l":2},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":1,"k":[{"i":{"x":[0.833,0.833,0.833],"y":[0.887,0.858,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.167,0.167,0]},"t":12,"s":[119.548,103.77,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.117,0.413,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.32,0.202,0]},"t":13,"s":[75.487,97.516,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.839,0.857,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.049,0.097,0]},"t":14,"s":[59.951,93.123,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.919,1.065,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.172,0.2,0]},"t":15,"s":[97.388,66.554,100]},{"i":{"x":[0.833,0.833,0.833],"y":[-2.744,0.826,1]},"o":{"x":[0.167,0.167,0.167],"y":[-2.464,0.037,0]},"t":16,"s":[132.453,47.628,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.896,0.982,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.085,0.16,0]},"t":17,"s":[131.306,81.38,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.123,0.94,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.42,-0.023,0]},"t":18,"s":[80.905,117.92,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.005,1.294,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.05,-0.217,0]},"t":19,"s":[68.413,89.26,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.919,1,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.004,0.065,0]},"t":20,"s":[99.306,97.223,100]},{"i":{"x":[0.833,0.833,0.833],"y":[-0.384,0.975,1]},"o":{"x":[0.167,0.167,0.167],"y":[-3.282,0,0]},"t":21,"s":[66.686,61.177,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.766,1.087,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.089,-0.035,0]},"t":22,"s":[67.494,97.2,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.858,0.983,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.129,0.043,0]},"t":23,"s":[80.098,71.917,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.241,0.896,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.201,-0.022,0]},"t":24,"s":[102.945,123.537,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.932,1.252,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.062,0.427,0]},"t":25,"s":[119.07,82.641,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.957,0.883,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.378,0.063,0]},"t":26,"s":[56.225,72.735,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.764,0.982,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.089,0.289,0]},"t":27,"s":[67.587,112.547,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.911,1.01,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.075,-0.022,0]},"t":28,"s":[62.103,128.666,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.018,1.194,1]},"o":{"x":[0.167,0.167,0.167],"y":[1.415,0.009,0]},"t":29,"s":[117.871,115.971,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.145,0.86,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.015,0.058,0]},"t":30,"s":[121.362,130.232,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.007,1.04,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.092,0.206,0]},"t":31,"s":[117.096,82.77,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.944,0.922,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.007,0.027,0]},"t":32,"s":[77.619,50.423,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.856,0.71,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.172,-1.15,0]},"t":33,"s":[120.562,98.344,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1,0.484,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.199,0.117,0]},"t":34,"s":[106.534,95.106,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.217,1.063,1]},"o":{"x":[0.167,0.167,0.167],"y":[0,0.099,0]},"t":35,"s":[96.391,87.062,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.943,0.954,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.06,0.036,0]},"t":36,"s":[106.476,45.285,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.104,0.996,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.179,-0.103,0]},"t":37,"s":[70.097,118.413,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.008,1.087,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.046,-0.004,0]},"t":38,"s":[81.643,85.768,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.912,0.911,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.007,0.043,0]},"t":39,"s":[55.641,116.896,100]},{"i":{"x":[0.833,0.833,0.833],"y":[-0.011,0.466,1]},"o":{"x":[0.167,0.167,0.167],"y":[1.675,1.402,0]},"t":40,"s":[83.987,53.173,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.17,0.898,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.091,0.099,0]},"t":41,"s":[85.471,49.147,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.916,1.762,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.056,0.465,0]},"t":42,"s":[102.003,27.383,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.131,1.022,1]},"o":{"x":[0.167,0.167,0.167],"y":[7.85,0.075,0]},"t":43,"s":[51.831,22.635,100]},{"i":{"x":[0.833,0.833,0.833],"y":[-1.033,0.913,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.051,0.017,0]},"t":44,"s":[51.293,70.822,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.9,1.802,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.087,1.766,0]},"t":45,"s":[52.676,10.032,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.828,0.986,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.491,0.075,0]},"t":46,"s":[85.033,7.021,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.048,0.89,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.162,-0.017,0]},"t":47,"s":[91.644,38.996,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.854,1.128,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.031,0.341,0]},"t":48,"s":[98.641,12.455,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.425,0.857,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.195,0.05,0]},"t":49,"s":[87.583,3.883,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.84,0.985,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.097,0.199,0]},"t":50,"s":[79.333,25.603,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.932,0.756,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.174,-0.018,0]},"t":51,"s":[30.689,41.218,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.381,1.037,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.362,0.127,0]},"t":52,"s":[-14.018,28.37,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.031,0.934,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.096,0.026,0]},"t":53,"s":[-5.646,3.642,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.941,0.845,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.022,-0.306,0]},"t":54,"s":[48.164,39.412,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.726,0.492,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.197,0.18,0]},"t":55,"s":[-25.525,31.762,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.914,1.052,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.12,0.1,0]},"t":56,"s":[-3.614,25.17,100]},{"i":{"x":[0.833,0.833,0.833],"y":[2.387,0.994,1]},"o":{"x":[0.167,0.167,0.167],"y":[2.415,0.032,0]},"t":57,"s":[46.515,-8.426,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.833,0.833,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.079,-0.006,0]},"t":58,"s":[48.307,46.093,100]},{"t":59,"s":[16.689,-4.541,100]}],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"d":1,"ty":"el","s":{"a":0,"k":[10,10],"ix":2},"p":{"a":0,"k":[0,0],"ix":3},"nm":"Ellipse Path 1","mn":"ADBE Vector Shape - Ellipse","hd":false},{"ty":"fl","c":{"a":0,"k":[1,0.10196078431372549,0.3803921568627451,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Ellipse 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":12,"op":60,"st":12,"bm":0},{"ddd":0,"ind":47,"ty":4,"nm":"Shape Layer 5","sr":1,"ks":{"o":{"a":1,"k":[{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":8,"s":[0]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":13,"s":[100]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":35,"s":[100]},{"t":51,"s":[0]}],"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.833,"y":0.549},"o":{"x":0.167,"y":0.167},"t":8,"s":[272.244,257.087,0],"to":[-0.124,-0.32,0],"ti":[0.816,1.673,0]},{"i":{"x":0.833,"y":0.761},"o":{"x":0.167,"y":0.102},"t":9,"s":[271.5,255.164,0],"to":[-0.816,-1.673,0],"ti":[2.219,3.742,0]},{"i":{"x":0.833,"y":0.824},"o":{"x":0.167,"y":0.128},"t":10,"s":[267.351,247.049,0],"to":[-2.219,-3.742,0],"ti":[3.396,4.937,0]},{"i":{"x":0.833,"y":0.844},"o":{"x":0.167,"y":0.158},"t":11,"s":[258.186,232.713,0],"to":[-3.396,-4.937,0],"ti":[3.5,4.76,0]},{"i":{"x":0.833,"y":0.848},"o":{"x":0.167,"y":0.179},"t":12,"s":[246.972,217.428,0],"to":[-3.5,-4.76,0],"ti":[2.913,4.097,0]},{"i":{"x":0.833,"y":0.847},"o":{"x":0.167,"y":0.184},"t":13,"s":[237.183,204.156,0],"to":[-2.913,-4.097,0],"ti":[2.265,3.524,0]},{"i":{"x":0.833,"y":0.846},"o":{"x":0.167,"y":0.183},"t":14,"s":[229.496,192.846,0],"to":[-2.265,-3.524,0],"ti":[1.734,3.087,0]},{"i":{"x":0.833,"y":0.844},"o":{"x":0.167,"y":0.181},"t":15,"s":[223.596,183.013,0],"to":[-1.734,-3.087,0],"ti":[1.325,2.741,0]},{"i":{"x":0.833,"y":0.843},"o":{"x":0.167,"y":0.179},"t":16,"s":[219.089,174.323,0],"to":[-1.325,-2.741,0],"ti":[1.014,2.455,0]},{"i":{"x":0.833,"y":0.843},"o":{"x":0.167,"y":0.178},"t":17,"s":[215.647,166.568,0],"to":[-1.014,-2.455,0],"ti":[0.78,2.215,0]},{"i":{"x":0.833,"y":0.842},"o":{"x":0.167,"y":0.177},"t":18,"s":[213.008,159.593,0],"to":[-0.78,-2.215,0],"ti":[0.606,2.01,0]},{"i":{"x":0.833,"y":0.841},"o":{"x":0.167,"y":0.176},"t":19,"s":[210.967,153.279,0],"to":[-0.606,-2.01,0],"ti":[0.48,1.83,0]},{"i":{"x":0.833,"y":0.842},"o":{"x":0.167,"y":0.176},"t":20,"s":[209.371,147.533,0],"to":[-0.48,-1.83,0],"ti":[0.392,1.66,0]},{"i":{"x":0.833,"y":0.841},"o":{"x":0.167,"y":0.176},"t":21,"s":[208.089,142.301,0],"to":[-0.392,-1.66,0],"ti":[0.329,1.501,0]},{"i":{"x":0.833,"y":0.841},"o":{"x":0.167,"y":0.176},"t":22,"s":[207.021,137.573,0],"to":[-0.329,-1.501,0],"ti":[0.281,1.36,0]},{"i":{"x":0.833,"y":0.841},"o":{"x":0.167,"y":0.175},"t":23,"s":[206.115,133.293,0],"to":[-0.281,-1.36,0],"ti":[0.243,1.235,0]},{"i":{"x":0.833,"y":0.841},"o":{"x":0.167,"y":0.175},"t":24,"s":[205.336,129.411,0],"to":[-0.243,-1.235,0],"ti":[0.212,1.123,0]},{"i":{"x":0.833,"y":0.841},"o":{"x":0.167,"y":0.175},"t":25,"s":[204.658,125.885,0],"to":[-0.212,-1.123,0],"ti":[0.187,1.022,0]},{"i":{"x":0.833,"y":0.841},"o":{"x":0.167,"y":0.175},"t":26,"s":[204.062,122.676,0],"to":[-0.187,-1.022,0],"ti":[0.165,0.933,0]},{"i":{"x":0.833,"y":0.841},"o":{"x":0.167,"y":0.175},"t":27,"s":[203.537,119.751,0],"to":[-0.165,-0.933,0],"ti":[0.145,0.852,0]},{"i":{"x":0.833,"y":0.841},"o":{"x":0.167,"y":0.175},"t":28,"s":[203.075,117.081,0],"to":[-0.145,-0.852,0],"ti":[0.126,0.779,0]},{"i":{"x":0.833,"y":0.84},"o":{"x":0.167,"y":0.175},"t":29,"s":[202.67,114.64,0],"to":[-0.126,-0.779,0],"ti":[0.108,0.713,0]},{"i":{"x":0.833,"y":0.84},"o":{"x":0.167,"y":0.174},"t":30,"s":[202.32,112.406,0],"to":[-0.108,-0.713,0],"ti":[0.09,0.654,0]},{"i":{"x":0.833,"y":0.84},"o":{"x":0.167,"y":0.174},"t":31,"s":[202.024,110.359,0],"to":[-0.09,-0.654,0],"ti":[0.072,0.599,0]},{"i":{"x":0.833,"y":0.84},"o":{"x":0.167,"y":0.174},"t":32,"s":[201.781,108.484,0],"to":[-0.072,-0.599,0],"ti":[0.054,0.55,0]},{"i":{"x":0.833,"y":0.84},"o":{"x":0.167,"y":0.174},"t":33,"s":[201.591,106.763,0],"to":[-0.054,-0.55,0],"ti":[0.036,0.505,0]},{"i":{"x":0.833,"y":0.84},"o":{"x":0.167,"y":0.174},"t":34,"s":[201.455,105.183,0],"to":[-0.036,-0.505,0],"ti":[0.018,0.464,0]},{"i":{"x":0.833,"y":0.84},"o":{"x":0.167,"y":0.174},"t":35,"s":[201.374,103.731,0],"to":[-0.018,-0.464,0],"ti":[-0.001,0.427,0]},{"i":{"x":0.833,"y":0.84},"o":{"x":0.167,"y":0.174},"t":36,"s":[201.348,102.397,0],"to":[0.001,-0.427,0],"ti":[-0.019,0.391,0]},{"i":{"x":0.833,"y":0.84},"o":{"x":0.167,"y":0.174},"t":37,"s":[201.377,101.171,0],"to":[0.019,-0.391,0],"ti":[-0.037,0.358,0]},{"i":{"x":0.833,"y":0.84},"o":{"x":0.167,"y":0.174},"t":38,"s":[201.462,100.048,0],"to":[0.037,-0.358,0],"ti":[-0.055,0.327,0]},{"i":{"x":0.833,"y":0.84},"o":{"x":0.167,"y":0.174},"t":39,"s":[201.6,99.021,0],"to":[0.055,-0.327,0],"ti":[-0.072,0.298,0]},{"i":{"x":0.833,"y":0.84},"o":{"x":0.167,"y":0.174},"t":40,"s":[201.792,98.084,0],"to":[0.072,-0.298,0],"ti":[-0.089,0.269,0]},{"i":{"x":0.833,"y":0.839},"o":{"x":0.167,"y":0.173},"t":41,"s":[202.034,97.235,0],"to":[0.089,-0.269,0],"ti":[-0.104,0.241,0]},{"i":{"x":0.833,"y":0.839},"o":{"x":0.167,"y":0.173},"t":42,"s":[202.325,96.471,0],"to":[0.104,-0.241,0],"ti":[-0.118,0.213,0]},{"i":{"x":0.833,"y":0.839},"o":{"x":0.167,"y":0.173},"t":43,"s":[202.66,95.791,0],"to":[0.118,-0.213,0],"ti":[-0.131,0.186,0]},{"i":{"x":0.833,"y":0.839},"o":{"x":0.167,"y":0.173},"t":44,"s":[203.035,95.192,0],"to":[0.131,-0.186,0],"ti":[-0.142,0.158,0]},{"i":{"x":0.833,"y":0.838},"o":{"x":0.167,"y":0.172},"t":45,"s":[203.446,94.675,0],"to":[0.142,-0.158,0],"ti":[-0.151,0.13,0]},{"i":{"x":0.833,"y":0.838},"o":{"x":0.167,"y":0.172},"t":46,"s":[203.886,94.242,0],"to":[0.151,-0.13,0],"ti":[-0.157,0.102,0]},{"i":{"x":0.833,"y":0.835},"o":{"x":0.167,"y":0.172},"t":47,"s":[204.349,93.894,0],"to":[0.157,-0.102,0],"ti":[-0.161,0.078,0]},{"i":{"x":0.833,"y":0.834},"o":{"x":0.167,"y":0.169},"t":48,"s":[204.827,93.632,0],"to":[0.161,-0.078,0],"ti":[-0.163,0.067,0]},{"i":{"x":0.833,"y":0.834},"o":{"x":0.167,"y":0.167},"t":49,"s":[205.315,93.423,0],"to":[0.163,-0.067,0],"ti":[-0.166,0.057,0]},{"i":{"x":0.833,"y":0.831},"o":{"x":0.167,"y":0.167},"t":50,"s":[205.808,93.233,0],"to":[0.166,-0.057,0],"ti":[-0.172,0.042,0]},{"i":{"x":0.833,"y":0.832},"o":{"x":0.167,"y":0.165},"t":51,"s":[206.31,93.079,0],"to":[0.172,-0.042,0],"ti":[-0.179,0.025,0]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.165},"t":52,"s":[206.839,92.979,0],"to":[0.179,-0.025,0],"ti":[-0.182,0.01,0]},{"i":{"x":0.833,"y":0.834},"o":{"x":0.167,"y":0.167},"t":53,"s":[207.382,92.928,0],"to":[0.182,-0.01,0],"ti":[-0.181,-0.005,0]},{"i":{"x":0.833,"y":0.836},"o":{"x":0.167,"y":0.168},"t":54,"s":[207.929,92.922,0],"to":[0.181,0.005,0],"ti":[-0.176,-0.017,0]},{"i":{"x":0.833,"y":0.837},"o":{"x":0.167,"y":0.169},"t":55,"s":[208.467,92.955,0],"to":[0.176,0.017,0],"ti":[-0.169,-0.028,0]},{"i":{"x":0.833,"y":0.838},"o":{"x":0.167,"y":0.17},"t":56,"s":[208.986,93.024,0],"to":[0.169,0.028,0],"ti":[-0.158,-0.038,0]},{"i":{"x":0.833,"y":0.839},"o":{"x":0.167,"y":0.172},"t":57,"s":[209.478,93.124,0],"to":[0.158,0.038,0],"ti":[-0.145,-0.046,0]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.173},"t":58,"s":[209.934,93.25,0],"to":[0.145,0.046,0],"ti":[-0.069,-0.025,0]},{"t":59,"s":[210.348,93.398,0]}],"ix":2,"l":2},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":1,"k":[{"i":{"x":[0.833,0.833,0.833],"y":[0.87,0.772,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.167,0.167,0]},"t":8,"s":[119.453,55.55,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.948,0.927,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.231,0.131,0]},"t":9,"s":[106.941,69.697,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.287,0.579,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.135,-0.59,0]},"t":10,"s":[99.865,94.324,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.885,0.86,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.065,0.104,0]},"t":11,"s":[102.566,91.274,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.741,0.971,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.301,0.205,0]},"t":12,"s":[90.563,78.91,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.394,1.467,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.123,-0.044,0]},"t":13,"s":[85.972,70.463,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.896,0.973,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.069,0.071,0]},"t":14,"s":[76.313,75.971,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.334,0.741,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.413,-0.04,0]},"t":15,"s":[131.692,39.582,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.923,0.958,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.067,0.123,0]},"t":16,"s":[145.687,64.182,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.286,0.922,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.941,-0.085,0]},"t":17,"s":[75.572,116.009,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.026,3.589,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.094,-1.303,0]},"t":18,"s":[81.277,90.372,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.88,0.961,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.02,0.081,0]},"t":19,"s":[124.472,91.912,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.062,0.923,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.275,-0.072,0]},"t":20,"s":[67.544,42.493,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.861,1.459,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.036,-0.98,0]},"t":21,"s":[42.728,69.036,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.922,0.917,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.208,0.071,0]},"t":22,"s":[85.996,66.957,100]},{"i":{"x":[0.833,0.833,0.833],"y":[-1.537,-47.561,1]},"o":{"x":[0.167,0.167,0.167],"y":[-1.187,74,0]},"t":23,"s":[114.947,80.491,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.967,0.566,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.086,0.083,0]},"t":24,"s":[113.049,80.506,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.925,0.917,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.054,0.103,0]},"t":25,"s":[57.15,89.382,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.098,5.726,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.754,-8.491,0]},"t":26,"s":[91.118,126.727,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.09,0.993,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.045,0.082,0]},"t":27,"s":[87.736,126.364,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.908,0.868,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.043,-0.008,0]},"t":28,"s":[95.097,147.312,100]},{"i":{"x":[0.833,0.833,0.833],"y":[-0.56,0.516,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.913,0.225,0]},"t":29,"s":[79.819,128.237,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.128,0.929,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.088,0.101,0]},"t":30,"s":[78.286,116.992,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.943,0.612,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.051,-0.463,0]},"t":31,"s":[51.103,62.981,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.755,0.962,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.177,0.106,0]},"t":32,"s":[120.097,71.216,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.967,0.78,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.126,-0.07,0]},"t":33,"s":[97.985,101.356,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.958,0.93,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.054,0.134,0]},"t":34,"s":[55.144,84.965,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.921,0.999,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.086,-0.43,0]},"t":35,"s":[81.051,58.096,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.51,1.562,1]},"o":{"x":[0.167,0.167,0.167],"y":[-1.655,-0.001,0]},"t":36,"s":[68.289,62.454,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.243,1.041,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.072,0.073,0]},"t":37,"s":[68.9,58.129,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.116,0.929,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.062,0.028,0]},"t":38,"s":[64.548,91.624,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.007,0.943,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.048,-0.49,0]},"t":39,"s":[81.603,41.501,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.027,-0.787,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.007,-0.185,0]},"t":40,"s":[40.821,48.785,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.952,0.981,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.02,0.087,0]},"t":41,"s":[85.22,46.522,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.945,1.024,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.112,-0.025,0]},"t":42,"s":[26.513,0.273,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.934,0.915,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.158,0.018,0]},"t":43,"s":[51.537,35.891,100]},{"i":{"x":[0.833,0.833,0.833],"y":[-0.358,1.609,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.318,4.617,0]},"t":44,"s":[42.897,-9.828,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.956,0.696,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.089,0.073,0]},"t":45,"s":[44.69,-10.668,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.922,0.946,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.095,0.115,0]},"t":46,"s":[72.117,-3.689,100]},{"i":{"x":[0.833,0.833,0.833],"y":[2.789,0.815,1]},"o":{"x":[0.167,0.167,0.167],"y":[-1.25,-0.156,0]},"t":47,"s":[59.287,14.778,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.827,0.946,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.08,0.152,0]},"t":48,"s":[60.089,8.351,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.698,-0.404,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.161,-0.154,0]},"t":49,"s":[42.069,0.529,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.95,0.986,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.115,0.089,0]},"t":50,"s":[22.609,3.273,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.627,0.926,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.126,-0.016,0]},"t":51,"s":[-28.548,46.76,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.957,0.182,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.107,-0.67,0]},"t":52,"s":[-8.217,10.367,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.796,0.936,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.089,0.093,0]},"t":53,"s":[62.57,14.39,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.988,0.546,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.141,-0.271,0]},"t":54,"s":[28.321,49.863,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.858,1,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.015,0.102,0]},"t":55,"s":[-21.183,41.528,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.006,0.962,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.202,0,0]},"t":56,"s":[20.981,4.416,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.939,0.899,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.005,-0.071,0]},"t":57,"s":[50.62,41.308,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.833,0.833,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.223,0.468,0]},"t":58,"s":[18.942,21.34,100]},{"t":59,"s":[27.547,17.011,100]}],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"d":1,"ty":"el","s":{"a":0,"k":[10,10],"ix":2},"p":{"a":0,"k":[0,0],"ix":3},"nm":"Ellipse Path 1","mn":"ADBE Vector Shape - Ellipse","hd":false},{"ty":"fl","c":{"a":0,"k":[1,0.10196078431372549,0.3803921568627451,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Ellipse 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":8,"op":60,"st":8,"bm":0},{"ddd":0,"ind":48,"ty":4,"nm":"Shape Layer 4","sr":1,"ks":{"o":{"a":1,"k":[{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":2,"s":[0]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":7,"s":[100]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":29,"s":[100]},{"t":45,"s":[0]}],"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.833,"y":0.599},"o":{"x":0.167,"y":0.167},"t":2,"s":[273.94,260.349,0],"to":[-0.179,0.314,0],"ti":[0.915,-1.476,0]},{"i":{"x":0.833,"y":0.765},"o":{"x":0.167,"y":0.105},"t":3,"s":[272.864,262.231,0],"to":[-0.915,1.476,0],"ti":[2.375,-3.065,0]},{"i":{"x":0.833,"y":0.824},"o":{"x":0.167,"y":0.129},"t":4,"s":[268.451,269.206,0],"to":[-2.375,3.065,0],"ti":[3.841,-3.636,0]},{"i":{"x":0.833,"y":0.844},"o":{"x":0.167,"y":0.158},"t":5,"s":[258.614,280.62,0],"to":[-3.841,3.636,0],"ti":[4.317,-2.988,0]},{"i":{"x":0.833,"y":0.847},"o":{"x":0.167,"y":0.178},"t":6,"s":[245.406,291.021,0],"to":[-4.317,2.988,0],"ti":[3.939,-2.213,0]},{"i":{"x":0.833,"y":0.846},"o":{"x":0.167,"y":0.183},"t":7,"s":[232.715,298.549,0],"to":[-3.939,2.213,0],"ti":[3.368,-1.76,0]},{"i":{"x":0.833,"y":0.845},"o":{"x":0.167,"y":0.182},"t":8,"s":[221.77,304.296,0],"to":[-3.368,1.76,0],"ti":[2.855,-1.514,0]},{"i":{"x":0.833,"y":0.844},"o":{"x":0.167,"y":0.18},"t":9,"s":[212.51,309.106,0],"to":[-2.855,1.514,0],"ti":[2.434,-1.366,0]},{"i":{"x":0.833,"y":0.843},"o":{"x":0.167,"y":0.179},"t":10,"s":[204.641,313.381,0],"to":[-2.434,1.366,0],"ti":[2.092,-1.261,0]},{"i":{"x":0.833,"y":0.843},"o":{"x":0.167,"y":0.178},"t":11,"s":[197.904,317.3,0],"to":[-2.092,1.261,0],"ti":[1.81,-1.177,0]},{"i":{"x":0.833,"y":0.842},"o":{"x":0.167,"y":0.177},"t":12,"s":[192.09,320.946,0],"to":[-1.81,1.177,0],"ti":[1.575,-1.103,0]},{"i":{"x":0.833,"y":0.842},"o":{"x":0.167,"y":0.177},"t":13,"s":[187.044,324.36,0],"to":[-1.575,1.103,0],"ti":[1.376,-1.035,0]},{"i":{"x":0.833,"y":0.842},"o":{"x":0.167,"y":0.176},"t":14,"s":[182.643,327.564,0],"to":[-1.376,1.035,0],"ti":[1.205,-0.972,0]},{"i":{"x":0.833,"y":0.841},"o":{"x":0.167,"y":0.176},"t":15,"s":[178.791,330.572,0],"to":[-1.205,0.972,0],"ti":[1.057,-0.911,0]},{"i":{"x":0.833,"y":0.841},"o":{"x":0.167,"y":0.176},"t":16,"s":[175.413,333.393,0],"to":[-1.057,0.911,0],"ti":[0.928,-0.852,0]},{"i":{"x":0.833,"y":0.841},"o":{"x":0.167,"y":0.176},"t":17,"s":[172.447,336.036,0],"to":[-0.928,0.852,0],"ti":[0.814,-0.797,0]},{"i":{"x":0.833,"y":0.841},"o":{"x":0.167,"y":0.176},"t":18,"s":[169.845,338.508,0],"to":[-0.814,0.797,0],"ti":[0.712,-0.743,0]},{"i":{"x":0.833,"y":0.841},"o":{"x":0.167,"y":0.176},"t":19,"s":[167.565,340.816,0],"to":[-0.712,0.743,0],"ti":[0.621,-0.692,0]},{"i":{"x":0.833,"y":0.842},"o":{"x":0.167,"y":0.176},"t":20,"s":[165.574,342.967,0],"to":[-0.621,0.692,0],"ti":[0.539,-0.642,0]},{"i":{"x":0.833,"y":0.842},"o":{"x":0.167,"y":0.176},"t":21,"s":[163.841,344.967,0],"to":[-0.539,0.642,0],"ti":[0.464,-0.595,0]},{"i":{"x":0.833,"y":0.842},"o":{"x":0.167,"y":0.176},"t":22,"s":[162.342,346.821,0],"to":[-0.464,0.595,0],"ti":[0.397,-0.549,0]},{"i":{"x":0.833,"y":0.842},"o":{"x":0.167,"y":0.176},"t":23,"s":[161.056,348.535,0],"to":[-0.397,0.549,0],"ti":[0.336,-0.504,0]},{"i":{"x":0.833,"y":0.843},"o":{"x":0.167,"y":0.177},"t":24,"s":[159.962,350.113,0],"to":[-0.336,0.504,0],"ti":[0.28,-0.461,0]},{"i":{"x":0.833,"y":0.843},"o":{"x":0.167,"y":0.177},"t":25,"s":[159.042,351.559,0],"to":[-0.28,0.461,0],"ti":[0.23,-0.419,0]},{"i":{"x":0.833,"y":0.843},"o":{"x":0.167,"y":0.178},"t":26,"s":[158.282,352.878,0],"to":[-0.23,0.419,0],"ti":[0.185,-0.378,0]},{"i":{"x":0.833,"y":0.844},"o":{"x":0.167,"y":0.178},"t":27,"s":[157.663,354.073,0],"to":[-0.185,0.378,0],"ti":[0.145,-0.339,0]},{"i":{"x":0.833,"y":0.845},"o":{"x":0.167,"y":0.179},"t":28,"s":[157.172,355.149,0],"to":[-0.145,0.339,0],"ti":[0.11,-0.301,0]},{"i":{"x":0.833,"y":0.845},"o":{"x":0.167,"y":0.18},"t":29,"s":[156.793,356.108,0],"to":[-0.11,0.301,0],"ti":[0.08,-0.264,0]},{"i":{"x":0.833,"y":0.847},"o":{"x":0.167,"y":0.181},"t":30,"s":[156.511,356.955,0],"to":[-0.08,0.264,0],"ti":[0.054,-0.227,0]},{"i":{"x":0.833,"y":0.848},"o":{"x":0.167,"y":0.182},"t":31,"s":[156.313,357.691,0],"to":[-0.054,0.227,0],"ti":[0.034,-0.191,0]},{"i":{"x":0.833,"y":0.851},"o":{"x":0.167,"y":0.185},"t":32,"s":[156.184,358.318,0],"to":[-0.034,0.191,0],"ti":[0.018,-0.156,0]},{"i":{"x":0.833,"y":0.854},"o":{"x":0.167,"y":0.188},"t":33,"s":[156.11,358.839,0],"to":[-0.018,0.156,0],"ti":[0.007,-0.122,0]},{"i":{"x":0.833,"y":0.842},"o":{"x":0.167,"y":0.194},"t":34,"s":[156.077,359.255,0],"to":[-0.007,0.122,0],"ti":[-0.009,-0.098,0]},{"i":{"x":0.833,"y":0.828},"o":{"x":0.167,"y":0.177},"t":35,"s":[156.07,359.568,0],"to":[0.009,0.098,0],"ti":[-0.034,-0.089,0]},{"i":{"x":0.833,"y":0.826},"o":{"x":0.167,"y":0.162},"t":36,"s":[156.129,359.841,0],"to":[0.034,0.089,0],"ti":[-0.059,-0.084,0]},{"i":{"x":0.833,"y":0.826},"o":{"x":0.167,"y":0.16},"t":37,"s":[156.271,360.101,0],"to":[0.059,0.084,0],"ti":[-0.08,-0.078,0]},{"i":{"x":0.833,"y":0.827},"o":{"x":0.167,"y":0.16},"t":38,"s":[156.482,360.344,0],"to":[0.08,0.078,0],"ti":[-0.098,-0.071,0]},{"i":{"x":0.833,"y":0.829},"o":{"x":0.167,"y":0.161},"t":39,"s":[156.751,360.569,0],"to":[0.098,0.071,0],"ti":[-0.112,-0.063,0]},{"i":{"x":0.833,"y":0.83},"o":{"x":0.167,"y":0.162},"t":40,"s":[157.068,360.771,0],"to":[0.112,0.063,0],"ti":[-0.124,-0.054,0]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.163},"t":41,"s":[157.425,360.948,0],"to":[0.124,0.054,0],"ti":[-0.129,-0.049,0]},{"i":{"x":0.833,"y":0.836},"o":{"x":0.167,"y":0.166},"t":42,"s":[157.811,361.097,0],"to":[0.129,0.049,0],"ti":[-0.126,-0.051,0]},{"i":{"x":0.833,"y":0.837},"o":{"x":0.167,"y":0.169},"t":43,"s":[158.201,361.241,0],"to":[0.126,0.051,0],"ti":[-0.118,-0.056,0]},{"i":{"x":0.833,"y":0.838},"o":{"x":0.167,"y":0.171},"t":44,"s":[158.569,361.404,0],"to":[0.118,0.056,0],"ti":[-0.109,-0.06,0]},{"i":{"x":0.833,"y":0.839},"o":{"x":0.167,"y":0.172},"t":45,"s":[158.91,361.58,0],"to":[0.109,0.06,0],"ti":[-0.098,-0.062,0]},{"i":{"x":0.833,"y":0.84},"o":{"x":0.167,"y":0.173},"t":46,"s":[159.221,361.765,0],"to":[0.098,0.062,0],"ti":[-0.087,-0.063,0]},{"i":{"x":0.833,"y":0.842},"o":{"x":0.167,"y":0.174},"t":47,"s":[159.499,361.954,0],"to":[0.087,0.063,0],"ti":[-0.074,-0.063,0]},{"i":{"x":0.833,"y":0.843},"o":{"x":0.167,"y":0.176},"t":48,"s":[159.741,362.144,0],"to":[0.074,0.063,0],"ti":[-0.062,-0.061,0]},{"i":{"x":0.833,"y":0.845},"o":{"x":0.167,"y":0.178},"t":49,"s":[159.945,362.33,0],"to":[0.062,0.061,0],"ti":[-0.049,-0.058,0]},{"i":{"x":0.833,"y":0.847},"o":{"x":0.167,"y":0.18},"t":50,"s":[160.111,362.509,0],"to":[0.049,0.058,0],"ti":[-0.036,-0.053,0]},{"i":{"x":0.833,"y":0.849},"o":{"x":0.167,"y":0.182},"t":51,"s":[160.237,362.677,0],"to":[0.036,0.053,0],"ti":[-0.023,-0.048,0]},{"i":{"x":0.833,"y":0.85},"o":{"x":0.167,"y":0.185},"t":52,"s":[160.325,362.83,0],"to":[0.023,0.048,0],"ti":[-0.011,-0.042,0]},{"i":{"x":0.833,"y":0.85},"o":{"x":0.167,"y":0.187},"t":53,"s":[160.376,362.965,0],"to":[0.011,0.042,0],"ti":[0.001,-0.034,0]},{"i":{"x":0.833,"y":0.844},"o":{"x":0.167,"y":0.187},"t":54,"s":[160.39,363.08,0],"to":[-0.001,0.034,0],"ti":[0.011,-0.026,0]},{"i":{"x":0.833,"y":0.831},"o":{"x":0.167,"y":0.178},"t":55,"s":[160.371,363.171,0],"to":[-0.011,0.026,0],"ti":[0.021,-0.017,0]},{"i":{"x":0.833,"y":0.82},"o":{"x":0.167,"y":0.164},"t":56,"s":[160.322,363.235,0],"to":[-0.021,0.017,0],"ti":[0.029,-0.006,0]},{"i":{"x":0.833,"y":0.816},"o":{"x":0.167,"y":0.155},"t":57,"s":[160.247,363.27,0],"to":[-0.029,0.006,0],"ti":[0.035,0.004,0]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.152},"t":58,"s":[160.149,363.273,0],"to":[-0.035,-0.004,0],"ti":[0.019,0.005,0]},{"t":59,"s":[160.035,363.244,0]}],"ix":2,"l":2},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":1,"k":[{"i":{"x":[0.833,0.833,0.833],"y":[1.052,0.912,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.167,0.167,0]},"t":2,"s":[91.411,73.902,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.827,0.39,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.032,1.61,0]},"t":3,"s":[65.83,91.918,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.046,1.571,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.161,0.097,0]},"t":4,"s":[107.448,92.901,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.942,0.923,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.03,0.073,0]},"t":5,"s":[152.078,99.113,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.847,0.674,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.195,-0.969,0]},"t":6,"s":[82.703,50.368,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.029,0.728,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.183,0.112,0]},"t":7,"s":[103.48,54.226,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.79,0.949,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.022,0.12,0]},"t":8,"s":[120.884,65.462,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.94,0.95,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.138,-0.135,0]},"t":9,"s":[97.362,90.861,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.855,1.74,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.209,-0.125,0]},"t":10,"s":[61.56,81.156,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.023,1.03,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.196,0.075,0]},"t":11,"s":[71.774,85.037,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.369,0.932,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.018,0.022,0]},"t":12,"s":[79.328,46.686,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.894,0.844,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.068,-0.362,0]},"t":13,"s":[69.652,98.9,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.223,1.075,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.384,0.179,0]},"t":14,"s":[122.192,89.119,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.92,0.879,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.061,0.04,0]},"t":15,"s":[136.768,80.556,100]},{"i":{"x":[0.833,0.833,0.833],"y":[-0.268,1.123,1]},"o":{"x":[0.167,0.167,0.167],"y":[-1.772,0.269,0]},"t":16,"s":[83.231,96.867,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.041,0.715,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.089,0.05,0]},"t":17,"s":[85.636,104.189,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.882,0.919,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.028,0.118,0]},"t":18,"s":[119.843,86.028,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.101,-1.337,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.286,-2.378,0]},"t":19,"s":[68.608,42.162,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.862,0.996,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.046,0.086,0]},"t":20,"s":[47.52,43.647,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.951,0.979,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.21,-0.004,0]},"t":21,"s":[94.079,83.818,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.63,0.826,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.12,-0.028,0]},"t":22,"s":[124.614,45.446,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.959,0.867,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.108,0.16,0]},"t":23,"s":[112.121,74.09,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.798,1.043,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.082,0.224,0]},"t":24,"s":[69.17,105.139,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.989,1.056,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.142,0.028,0]},"t":25,"s":[90.804,123.544,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.836,0.968,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.013,0.034,0]},"t":26,"s":[121.588,95.744,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.88,0.908,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.17,-0.053,0]},"t":27,"s":[95.051,142.325,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.702,0.553,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.275,0.917,0]},"t":28,"s":[69.508,113.896,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.962,0.63,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.116,0.102,0]},"t":29,"s":[58.415,111.056,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.975,0.97,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.069,0.108,0]},"t":30,"s":[29.777,98.67,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.682,0.963,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.036,-0.046,0]},"t":31,"s":[45.407,56.133,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.99,0.982,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.113,-0.066,0]},"t":32,"s":[34.491,83.602,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.867,1.248,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.012,-0.022,0]},"t":33,"s":[3.789,68.283,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.991,0.862,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.224,0.062,0]},"t":34,"s":[30.69,80.365,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.11,1.012,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.01,0.211,0]},"t":35,"s":[46.645,32.371,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.919,0.974,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.047,0.011,0]},"t":36,"s":[32.343,1.12,100]},{"i":{"x":[0.833,0.833,0.833],"y":[-0.962,1.001,1]},"o":{"x":[0.167,0.167,0.167],"y":[-2.466,-0.037,0]},"t":37,"s":[65.552,37.014,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.026,0.929,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.087,0.001,0]},"t":38,"s":[64.467,12.19,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.059,-0.017,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.02,-0.473,0]},"t":39,"s":[40,37.362,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.936,0.93,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.034,0.091,0]},"t":40,"s":[72.228,33.594,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.744,0.949,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.269,-0.441,0]},"t":41,"s":[17.233,-8.641,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.021,1.149,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.124,-0.134,0]},"t":42,"s":[30.25,-1.935,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.994,0.976,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.017,0.053,0]},"t":43,"s":[57.17,-4.506,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.998,1.452,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.006,-0.034,0]},"t":44,"s":[23.549,2.656,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.948,0.864,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.002,0.07,0]},"t":45,"s":[54.911,-2.436,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.044,1.08,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.138,0.216,0]},"t":46,"s":[24.231,30.266,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.98,0.929,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.029,0.041,0]},"t":47,"s":[35.767,50.837,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.284,0.351,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.027,-0.49,0]},"t":48,"s":[18.177,10.486,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.93,0.976,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.064,0.096,0]},"t":49,"s":[31.443,16.354,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.369,0.815,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.427,-0.033,0]},"t":50,"s":[-27.024,56.172,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.885,0.996,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.096,0.152,0]},"t":51,"s":[-17.47,27.705,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.145,0.868,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.306,-0.005,0]},"t":52,"s":[45.271,-6.879,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.872,1.081,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.053,0.227,0]},"t":53,"s":[68.762,25.916,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.048,1.039,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.239,0.041,0]},"t":54,"s":[4.456,44.914,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.954,0.959,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.03,0.026,0]},"t":55,"s":[-30.068,7.408,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.082,0.922,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.101,-0.08,0]},"t":56,"s":[24.371,62.294,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.894,2.558,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.041,-1.19,0]},"t":57,"s":[-0.235,34.376,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.833,0.833,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.394,0.079,0]},"t":58,"s":[48.622,36.203,100]},{"t":59,"s":[61.716,0.231,100]}],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"d":1,"ty":"el","s":{"a":0,"k":[10,10],"ix":2},"p":{"a":0,"k":[0,0],"ix":3},"nm":"Ellipse Path 1","mn":"ADBE Vector Shape - Ellipse","hd":false},{"ty":"fl","c":{"a":0,"k":[1,0.10196078431372549,0.3803921568627451,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Ellipse 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":2,"op":60,"st":2,"bm":0},{"ddd":0,"ind":49,"ty":4,"nm":"Shape Layer 3","sr":1,"ks":{"o":{"a":1,"k":[{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":7,"s":[0]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":12,"s":[100]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":34,"s":[100]},{"t":50,"s":[0]}],"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.833,"y":0.551},"o":{"x":0.167,"y":0.167},"t":7,"s":[272.377,258.087,0],"to":[-0.394,0.137,0],"ti":[2.139,-0.681,0]},{"i":{"x":0.833,"y":0.762},"o":{"x":0.167,"y":0.102},"t":8,"s":[270.01,258.906,0],"to":[-2.139,0.681,0],"ti":[5.098,-1.104,0]},{"i":{"x":0.833,"y":0.824},"o":{"x":0.167,"y":0.128},"t":9,"s":[259.541,262.172,0],"to":[-5.098,1.104,0],"ti":[7.13,-0.303,0]},{"i":{"x":0.833,"y":0.844},"o":{"x":0.167,"y":0.158},"t":10,"s":[239.424,265.531,0],"to":[-7.13,0.303,0],"ti":[6.918,1.295,0]},{"i":{"x":0.833,"y":0.847},"o":{"x":0.167,"y":0.179},"t":11,"s":[216.76,263.988,0],"to":[-6.918,-1.295,0],"ti":[5.553,2.383,0]},{"i":{"x":0.833,"y":0.847},"o":{"x":0.167,"y":0.183},"t":12,"s":[197.916,257.762,0],"to":[-5.553,-2.383,0],"ti":[4.294,2.71,0]},{"i":{"x":0.833,"y":0.845},"o":{"x":0.167,"y":0.182},"t":13,"s":[183.44,249.693,0],"to":[-4.294,-2.71,0],"ti":[3.412,2.643,0]},{"i":{"x":0.833,"y":0.844},"o":{"x":0.167,"y":0.181},"t":14,"s":[172.149,241.499,0],"to":[-3.412,-2.643,0],"ti":[2.824,2.427,0]},{"i":{"x":0.833,"y":0.843},"o":{"x":0.167,"y":0.179},"t":15,"s":[162.967,233.838,0],"to":[-2.824,-2.427,0],"ti":[2.43,2.167,0]},{"i":{"x":0.833,"y":0.842},"o":{"x":0.167,"y":0.178},"t":16,"s":[155.204,226.937,0],"to":[-2.43,-2.167,0],"ti":[2.166,1.903,0]},{"i":{"x":0.833,"y":0.842},"o":{"x":0.167,"y":0.177},"t":17,"s":[148.386,220.833,0],"to":[-2.166,-1.903,0],"ti":[1.988,1.643,0]},{"i":{"x":0.833,"y":0.841},"o":{"x":0.167,"y":0.176},"t":18,"s":[142.207,215.519,0],"to":[-1.988,-1.643,0],"ti":[1.866,1.384,0]},{"i":{"x":0.833,"y":0.842},"o":{"x":0.167,"y":0.176},"t":19,"s":[136.458,210.978,0],"to":[-1.866,-1.384,0],"ti":[1.768,1.113,0]},{"i":{"x":0.833,"y":0.842},"o":{"x":0.167,"y":0.176},"t":20,"s":[131.01,207.214,0],"to":[-1.768,-1.113,0],"ti":[1.677,0.828,0]},{"i":{"x":0.833,"y":0.841},"o":{"x":0.167,"y":0.176},"t":21,"s":[125.848,204.301,0],"to":[-1.677,-0.828,0],"ti":[1.589,0.546,0]},{"i":{"x":0.833,"y":0.841},"o":{"x":0.167,"y":0.176},"t":22,"s":[120.949,202.245,0],"to":[-1.589,-0.546,0],"ti":[1.493,0.277,0]},{"i":{"x":0.833,"y":0.841},"o":{"x":0.167,"y":0.175},"t":23,"s":[116.315,201.024,0],"to":[-1.493,-0.277,0],"ti":[1.38,0.034,0]},{"i":{"x":0.833,"y":0.84},"o":{"x":0.167,"y":0.175},"t":24,"s":[111.993,200.584,0],"to":[-1.38,-0.034,0],"ti":[1.254,-0.168,0]},{"i":{"x":0.833,"y":0.84},"o":{"x":0.167,"y":0.174},"t":25,"s":[108.033,200.82,0],"to":[-1.254,0.168,0],"ti":[1.122,-0.322,0]},{"i":{"x":0.833,"y":0.839},"o":{"x":0.167,"y":0.174},"t":26,"s":[104.469,201.591,0],"to":[-1.122,0.322,0],"ti":[0.991,-0.429,0]},{"i":{"x":0.833,"y":0.84},"o":{"x":0.167,"y":0.173},"t":27,"s":[101.304,202.75,0],"to":[-0.991,0.429,0],"ti":[0.869,-0.496,0]},{"i":{"x":0.833,"y":0.839},"o":{"x":0.167,"y":0.173},"t":28,"s":[98.52,204.163,0],"to":[-0.869,0.496,0],"ti":[0.759,-0.533,0]},{"i":{"x":0.833,"y":0.839},"o":{"x":0.167,"y":0.173},"t":29,"s":[96.088,205.725,0],"to":[-0.759,0.533,0],"ti":[0.662,-0.548,0]},{"i":{"x":0.833,"y":0.84},"o":{"x":0.167,"y":0.173},"t":30,"s":[93.965,207.359,0],"to":[-0.662,0.548,0],"ti":[0.577,-0.547,0]},{"i":{"x":0.833,"y":0.84},"o":{"x":0.167,"y":0.174},"t":31,"s":[92.114,209.011,0],"to":[-0.577,0.547,0],"ti":[0.502,-0.535,0]},{"i":{"x":0.833,"y":0.84},"o":{"x":0.167,"y":0.174},"t":32,"s":[90.501,210.642,0],"to":[-0.502,0.535,0],"ti":[0.436,-0.515,0]},{"i":{"x":0.833,"y":0.841},"o":{"x":0.167,"y":0.174},"t":33,"s":[89.1,212.221,0],"to":[-0.436,0.515,0],"ti":[0.376,-0.488,0]},{"i":{"x":0.833,"y":0.841},"o":{"x":0.167,"y":0.175},"t":34,"s":[87.887,213.729,0],"to":[-0.376,0.488,0],"ti":[0.324,-0.458,0]},{"i":{"x":0.833,"y":0.842},"o":{"x":0.167,"y":0.175},"t":35,"s":[86.842,215.15,0],"to":[-0.324,0.458,0],"ti":[0.277,-0.424,0]},{"i":{"x":0.833,"y":0.843},"o":{"x":0.167,"y":0.176},"t":36,"s":[85.945,216.475,0],"to":[-0.277,0.424,0],"ti":[0.234,-0.388,0]},{"i":{"x":0.833,"y":0.843},"o":{"x":0.167,"y":0.177},"t":37,"s":[85.182,217.694,0],"to":[-0.234,0.388,0],"ti":[0.195,-0.349,0]},{"i":{"x":0.833,"y":0.845},"o":{"x":0.167,"y":0.178},"t":38,"s":[84.541,218.8,0],"to":[-0.195,0.349,0],"ti":[0.16,-0.31,0]},{"i":{"x":0.833,"y":0.846},"o":{"x":0.167,"y":0.18},"t":39,"s":[84.009,219.79,0],"to":[-0.16,0.31,0],"ti":[0.129,-0.269,0]},{"i":{"x":0.833,"y":0.849},"o":{"x":0.167,"y":0.182},"t":40,"s":[83.578,220.658,0],"to":[-0.129,0.269,0],"ti":[0.099,-0.226,0]},{"i":{"x":0.833,"y":0.852},"o":{"x":0.167,"y":0.186},"t":41,"s":[83.238,221.401,0],"to":[-0.099,0.226,0],"ti":[0.072,-0.183,0]},{"i":{"x":0.833,"y":0.857},"o":{"x":0.167,"y":0.19},"t":42,"s":[82.984,222.016,0],"to":[-0.072,0.183,0],"ti":[0.048,-0.14,0]},{"i":{"x":0.833,"y":0.865},"o":{"x":0.167,"y":0.199},"t":43,"s":[82.806,222.502,0],"to":[-0.048,0.14,0],"ti":[0.026,-0.097,0]},{"i":{"x":0.833,"y":0.882},"o":{"x":0.167,"y":0.219},"t":44,"s":[82.697,222.858,0],"to":[-0.026,0.097,0],"ti":[0.005,-0.052,0]},{"i":{"x":0.833,"y":0.84},"o":{"x":0.167,"y":0.284},"t":45,"s":[82.651,223.082,0],"to":[-0.005,0.052,0],"ti":[-0.014,-0.008,0]},{"i":{"x":0.833,"y":0.751},"o":{"x":0.167,"y":0.174},"t":46,"s":[82.666,223.173,0],"to":[0.014,0.008,0],"ti":[-0.027,0.032,0]},{"i":{"x":0.833,"y":0.805},"o":{"x":0.167,"y":0.125},"t":47,"s":[82.736,223.128,0],"to":[0.027,-0.032,0],"ti":[-0.027,0.062,0]},{"i":{"x":0.833,"y":0.812},"o":{"x":0.167,"y":0.145},"t":48,"s":[82.826,222.979,0],"to":[0.027,-0.062,0],"ti":[-0.023,0.085,0]},{"i":{"x":0.833,"y":0.827},"o":{"x":0.167,"y":0.149},"t":49,"s":[82.9,222.757,0],"to":[0.023,-0.085,0],"ti":[-0.025,0.099,0]},{"i":{"x":0.833,"y":0.831},"o":{"x":0.167,"y":0.161},"t":50,"s":[82.964,222.469,0],"to":[0.025,-0.099,0],"ti":[-0.032,0.102,0]},{"i":{"x":0.833,"y":0.834},"o":{"x":0.167,"y":0.165},"t":51,"s":[83.05,222.164,0],"to":[0.032,-0.102,0],"ti":[-0.036,0.101,0]},{"i":{"x":0.833,"y":0.836},"o":{"x":0.167,"y":0.167},"t":52,"s":[83.155,221.857,0],"to":[0.036,-0.101,0],"ti":[-0.038,0.098,0]},{"i":{"x":0.833,"y":0.839},"o":{"x":0.167,"y":0.17},"t":53,"s":[83.269,221.556,0],"to":[0.038,-0.098,0],"ti":[-0.038,0.093,0]},{"i":{"x":0.833,"y":0.841},"o":{"x":0.167,"y":0.172},"t":54,"s":[83.385,221.268,0],"to":[0.038,-0.093,0],"ti":[-0.035,0.085,0]},{"i":{"x":0.833,"y":0.844},"o":{"x":0.167,"y":0.175},"t":55,"s":[83.497,221,0],"to":[0.035,-0.085,0],"ti":[-0.031,0.076,0]},{"i":{"x":0.833,"y":0.848},"o":{"x":0.167,"y":0.179},"t":56,"s":[83.598,220.758,0],"to":[0.031,-0.076,0],"ti":[-0.024,0.065,0]},{"i":{"x":0.833,"y":0.853},"o":{"x":0.167,"y":0.184},"t":57,"s":[83.682,220.545,0],"to":[0.024,-0.065,0],"ti":[-0.017,0.053,0]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.192},"t":58,"s":[83.745,220.367,0],"to":[0.017,-0.053,0],"ti":[-0.006,0.023,0]},{"t":59,"s":[83.781,220.227,0]}],"ix":2,"l":2},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":1,"k":[{"i":{"x":[0.833,0.833,0.833],"y":[0.852,3.785,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.167,0.167,0]},"t":7,"s":[121.49,88.737,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.91,0.921,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.19,0.081,0]},"t":8,"s":[91.076,88.159,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.429,-3.486,1]},"o":{"x":[0.167,0.167,0.167],"y":[1.162,-1.415,0]},"t":9,"s":[67.412,108.062,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.033,0.953,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.098,0.085,0]},"t":10,"s":[65.583,106.955,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.841,0.807,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.024,-0.106,0]},"t":11,"s":[54.874,48.475,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.733,0.874,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.176,0.146,0]},"t":12,"s":[69.879,74.186,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.032,1.001,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.121,0.246,0]},"t":13,"s":[83.434,108.14,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.978,0.868,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.023,0.001,0]},"t":14,"s":[113.236,125.507,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.937,0.727,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.03,0.227,0]},"t":15,"s":[72.085,108.002,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.63,0.848,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.261,0.12,0]},"t":16,"s":[102.383,97.845,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.922,0.819,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.108,0.184,0]},"t":17,"s":[95.054,74.753,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.758,1.104,1]},"o":{"x":[0.167,0.167,0.167],"y":[-1.116,0.155,0]},"t":18,"s":[69.833,55.593,100]},{"i":{"x":[0.833,0.833,0.833],"y":[-0.253,0.933,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.127,0.046,0]},"t":19,"s":[71.585,33.206,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.962,0.845,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.089,-0.336,0]},"t":20,"s":[74.924,83.413,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.895,1.179,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.069,0.18,0]},"t":21,"s":[121.778,73.433,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.929,0.903,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.41,0.057,0]},"t":22,"s":[96.063,64.842,100]},{"i":{"x":[0.833,0.833,0.833],"y":[-2.344,1.503,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.493,0.61,0]},"t":23,"s":[89.506,91.864,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1,1.002,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.085,0.071,0]},"t":24,"s":[90.453,96.143,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.913,0.848,1]},"o":{"x":[0.167,0.167,0.167],"y":[0,0.002,0]},"t":25,"s":[127.532,66.021,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.203,1.019,1]},"o":{"x":[0.167,0.167,0.167],"y":[1.831,0.185,0]},"t":26,"s":[90.276,96.965,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.094,0.969,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.059,0.015,0]},"t":27,"s":[88.5,122.317,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.722,1.067,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.044,-0.049,0]},"t":28,"s":[94.599,91.269,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.905,0.958,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.119,0.037,0]},"t":29,"s":[81.585,110.87,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.81,0.948,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.689,-0.085,0]},"t":30,"s":[51.164,75.568,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.817,1.401,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.148,-0.141,0]},"t":31,"s":[46.977,93.082,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.272,0.996,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.153,0.069,0]},"t":32,"s":[41.615,86.585,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.928,0.774,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.064,-0.004,0]},"t":33,"s":[35.186,124.337,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.499,0.946,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.532,0.132,0]},"t":34,"s":[62.591,88.269,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.951,0.921,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.071,-0.154,0]},"t":35,"s":[58.881,26.321,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.072,1.831,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.121,-1.508,0]},"t":36,"s":[84.818,48.109,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.946,0.916,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.038,0.076,0]},"t":37,"s":[74.254,46.968,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.594,35.497,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.15,12.359,0]},"t":38,"s":[93.883,59.486,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.867,0.926,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.105,0.083,0]},"t":39,"s":[86.872,59.571,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.076,0.594,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.224,-0.643,0]},"t":40,"s":[59.763,24.304,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.02,0.969,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.04,0.105,0]},"t":41,"s":[43.653,28.352,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.928,1.059,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.016,-0.049,0]},"t":42,"s":[74.399,44.017,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.401,0.902,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.523,0.034,0]},"t":43,"s":[36.198,34.152,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.943,0.234,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.069,0.557,0]},"t":44,"s":[41.448,50.974,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.109,1.139,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.183,0.094,0]},"t":45,"s":[10.923,53.931,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.042,0.95,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.047,0.052,0]},"t":46,"s":[20.467,78.157,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.003,0.801,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.028,-0.123,0]},"t":47,"s":[-1.575,13.482,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.927,0.974,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.003,0.144,0]},"t":48,"s":[31.467,39.632,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.201,0.662,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.568,-0.037,0]},"t":49,"s":[-2.832,75.796,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.885,0.948,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.093,0.111,0]},"t":50,"s":[1.556,50.773,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.966,0.83,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.302,-0.136,0]},"t":51,"s":[39.258,-25.561,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.108,1.032,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.057,0.164,0]},"t":52,"s":[53.602,3.457,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.962,1.046,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.092,0.023,0]},"t":53,"s":[45.062,33.613,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.964,0.941,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.07,0.03,0]},"t":54,"s":[-37.848,-8.287,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.116,0.998,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.063,-0.206,0]},"t":55,"s":[7.213,56.765,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.896,1.241,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.049,-0.003,0]},"t":56,"s":[-18.526,38.06,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.835,0.935,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.416,0.062,0]},"t":57,"s":[43.172,56.215,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.833,0.833,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.168,-0.296,0]},"t":58,"s":[58.626,-14.531,100]},{"t":59,"s":[73.773,0.993,100]}],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"d":1,"ty":"el","s":{"a":0,"k":[10,10],"ix":2},"p":{"a":0,"k":[0,0],"ix":3},"nm":"Ellipse Path 1","mn":"ADBE Vector Shape - Ellipse","hd":false},{"ty":"fl","c":{"a":0,"k":[0.3137254901960784,0.8901960784313725,0.7607843137254902,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Ellipse 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":7,"op":60,"st":7,"bm":0},{"ddd":0,"ind":50,"ty":4,"nm":"Shape Layer 2","sr":1,"ks":{"o":{"a":1,"k":[{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":6,"s":[0]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":11,"s":[100]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":33,"s":[100]},{"t":49,"s":[0]}],"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.833,"y":0.592},"o":{"x":0.167,"y":0.167},"t":6,"s":[272.75,257.654,0],"to":[0.022,0.426,0],"ti":[-0.208,-2.079,0]},{"i":{"x":0.833,"y":0.764},"o":{"x":0.167,"y":0.105},"t":7,"s":[272.88,260.209,0],"to":[0.208,2.079,0],"ti":[-0.762,-4.636,0]},{"i":{"x":0.833,"y":0.824},"o":{"x":0.167,"y":0.129},"t":8,"s":[273.997,270.127,0],"to":[0.762,4.636,0],"ti":[-1.59,-6.205,0]},{"i":{"x":0.833,"y":0.844},"o":{"x":0.167,"y":0.158},"t":9,"s":[277.455,288.025,0],"to":[1.59,6.205,0],"ti":[-2.259,-5.901,0]},{"i":{"x":0.833,"y":0.847},"o":{"x":0.167,"y":0.179},"t":10,"s":[283.537,307.355,0],"to":[2.259,5.901,0],"ti":[-2.568,-4.763,0]},{"i":{"x":0.833,"y":0.846},"o":{"x":0.167,"y":0.183},"t":11,"s":[291.011,323.433,0],"to":[2.568,4.763,0],"ti":[-2.65,-3.689,0]},{"i":{"x":0.833,"y":0.845},"o":{"x":0.167,"y":0.182},"t":12,"s":[298.946,335.933,0],"to":[2.65,3.689,0],"ti":[-2.621,-2.842,0]},{"i":{"x":0.833,"y":0.844},"o":{"x":0.167,"y":0.18},"t":13,"s":[306.911,345.569,0],"to":[2.621,2.842,0],"ti":[-2.532,-2.186,0]},{"i":{"x":0.833,"y":0.843},"o":{"x":0.167,"y":0.179},"t":14,"s":[314.674,352.986,0],"to":[2.532,2.186,0],"ti":[-2.408,-1.678,0]},{"i":{"x":0.833,"y":0.842},"o":{"x":0.167,"y":0.178},"t":15,"s":[322.1,358.684,0],"to":[2.408,1.678,0],"ti":[-2.266,-1.285,0]},{"i":{"x":0.833,"y":0.842},"o":{"x":0.167,"y":0.177},"t":16,"s":[329.119,363.056,0],"to":[2.266,1.285,0],"ti":[-2.117,-0.981,0]},{"i":{"x":0.833,"y":0.841},"o":{"x":0.167,"y":0.176},"t":17,"s":[335.695,366.397,0],"to":[2.117,0.981,0],"ti":[-1.968,-0.746,0]},{"i":{"x":0.833,"y":0.841},"o":{"x":0.167,"y":0.175},"t":18,"s":[341.819,368.944,0],"to":[1.968,0.746,0],"ti":[-1.824,-0.565,0]},{"i":{"x":0.833,"y":0.841},"o":{"x":0.167,"y":0.175},"t":19,"s":[347.502,370.875,0],"to":[1.824,0.565,0],"ti":[-1.688,-0.424,0]},{"i":{"x":0.833,"y":0.84},"o":{"x":0.167,"y":0.175},"t":20,"s":[352.764,372.332,0],"to":[1.688,0.424,0],"ti":[-1.56,-0.315,0]},{"i":{"x":0.833,"y":0.84},"o":{"x":0.167,"y":0.174},"t":21,"s":[357.63,373.419,0],"to":[1.56,0.315,0],"ti":[-1.442,-0.23,0]},{"i":{"x":0.833,"y":0.84},"o":{"x":0.167,"y":0.174},"t":22,"s":[362.126,374.22,0],"to":[1.442,0.23,0],"ti":[-1.332,-0.163,0]},{"i":{"x":0.833,"y":0.84},"o":{"x":0.167,"y":0.174},"t":23,"s":[366.28,374.797,0],"to":[1.332,0.163,0],"ti":[-1.23,-0.11,0]},{"i":{"x":0.833,"y":0.84},"o":{"x":0.167,"y":0.174},"t":24,"s":[370.118,375.198,0],"to":[1.23,0.11,0],"ti":[-1.135,-0.068,0]},{"i":{"x":0.833,"y":0.84},"o":{"x":0.167,"y":0.174},"t":25,"s":[373.661,375.459,0],"to":[1.135,0.068,0],"ti":[-1.047,-0.035,0]},{"i":{"x":0.833,"y":0.84},"o":{"x":0.167,"y":0.174},"t":26,"s":[376.93,375.608,0],"to":[1.047,0.035,0],"ti":[-0.963,-0.007,0]},{"i":{"x":0.833,"y":0.84},"o":{"x":0.167,"y":0.174},"t":27,"s":[379.942,375.666,0],"to":[0.963,0.007,0],"ti":[-0.884,0.016,0]},{"i":{"x":0.833,"y":0.841},"o":{"x":0.167,"y":0.174},"t":28,"s":[382.71,375.649,0],"to":[0.884,-0.016,0],"ti":[-0.808,0.036,0]},{"i":{"x":0.833,"y":0.841},"o":{"x":0.167,"y":0.175},"t":29,"s":[385.245,375.569,0],"to":[0.808,-0.036,0],"ti":[-0.736,0.053,0]},{"i":{"x":0.833,"y":0.841},"o":{"x":0.167,"y":0.175},"t":30,"s":[387.559,375.434,0],"to":[0.736,-0.053,0],"ti":[-0.666,0.068,0]},{"i":{"x":0.833,"y":0.842},"o":{"x":0.167,"y":0.175},"t":31,"s":[389.659,375.251,0],"to":[0.666,-0.068,0],"ti":[-0.598,0.082,0]},{"i":{"x":0.833,"y":0.843},"o":{"x":0.167,"y":0.176},"t":32,"s":[391.553,375.025,0],"to":[0.598,-0.082,0],"ti":[-0.531,0.096,0]},{"i":{"x":0.833,"y":0.842},"o":{"x":0.167,"y":0.177},"t":33,"s":[393.245,374.757,0],"to":[0.531,-0.096,0],"ti":[-0.471,0.106,0]},{"i":{"x":0.833,"y":0.84},"o":{"x":0.167,"y":0.176},"t":34,"s":[394.738,374.451,0],"to":[0.471,-0.106,0],"ti":[-0.425,0.111,0]},{"i":{"x":0.833,"y":0.841},"o":{"x":0.167,"y":0.174},"t":35,"s":[396.069,374.119,0],"to":[0.425,-0.111,0],"ti":[-0.387,0.11,0]},{"i":{"x":0.833,"y":0.842},"o":{"x":0.167,"y":0.175},"t":36,"s":[397.286,373.785,0],"to":[0.387,-0.11,0],"ti":[-0.348,0.107,0]},{"i":{"x":0.833,"y":0.843},"o":{"x":0.167,"y":0.176},"t":37,"s":[398.388,373.457,0],"to":[0.348,-0.107,0],"ti":[-0.31,0.102,0]},{"i":{"x":0.833,"y":0.844},"o":{"x":0.167,"y":0.177},"t":38,"s":[399.376,373.143,0],"to":[0.31,-0.102,0],"ti":[-0.272,0.094,0]},{"i":{"x":0.833,"y":0.846},"o":{"x":0.167,"y":0.179},"t":39,"s":[400.249,372.847,0],"to":[0.272,-0.094,0],"ti":[-0.233,0.086,0]},{"i":{"x":0.833,"y":0.848},"o":{"x":0.167,"y":0.181},"t":40,"s":[401.007,372.576,0],"to":[0.233,-0.086,0],"ti":[-0.195,0.076,0]},{"i":{"x":0.833,"y":0.851},"o":{"x":0.167,"y":0.184},"t":41,"s":[401.649,372.333,0],"to":[0.195,-0.076,0],"ti":[-0.156,0.064,0]},{"i":{"x":0.833,"y":0.856},"o":{"x":0.167,"y":0.189},"t":42,"s":[402.175,372.123,0],"to":[0.156,-0.064,0],"ti":[-0.118,0.052,0]},{"i":{"x":0.833,"y":0.864},"o":{"x":0.167,"y":0.198},"t":43,"s":[402.586,371.947,0],"to":[0.118,-0.052,0],"ti":[-0.079,0.039,0]},{"i":{"x":0.833,"y":0.881},"o":{"x":0.167,"y":0.215},"t":44,"s":[402.881,371.81,0],"to":[0.079,-0.039,0],"ti":[-0.041,0.026,0]},{"i":{"x":0.833,"y":0.888},"o":{"x":0.167,"y":0.276},"t":45,"s":[403.061,371.711,0],"to":[0.041,-0.026,0],"ti":[-0.01,0.014,0]},{"i":{"x":0.833,"y":0.805},"o":{"x":0.167,"y":0.327},"t":46,"s":[403.128,371.653,0],"to":[0.01,-0.014,0],"ti":[0.008,0.004,0]},{"i":{"x":0.833,"y":0.754},"o":{"x":0.167,"y":0.145},"t":47,"s":[403.122,371.629,0],"to":[-0.008,-0.004,0],"ti":[0.019,-0.005,0]},{"i":{"x":0.833,"y":0.789},"o":{"x":0.167,"y":0.126},"t":48,"s":[403.083,371.63,0],"to":[-0.019,0.005,0],"ti":[0.031,-0.013,0]},{"i":{"x":0.833,"y":0.805},"o":{"x":0.167,"y":0.138},"t":49,"s":[403.008,371.658,0],"to":[-0.031,0.013,0],"ti":[0.042,-0.022,0]},{"i":{"x":0.833,"y":0.813},"o":{"x":0.167,"y":0.145},"t":50,"s":[402.898,371.711,0],"to":[-0.042,0.022,0],"ti":[0.053,-0.03,0]},{"i":{"x":0.833,"y":0.818},"o":{"x":0.167,"y":0.15},"t":51,"s":[402.755,371.79,0],"to":[-0.053,0.03,0],"ti":[0.064,-0.038,0]},{"i":{"x":0.833,"y":0.821},"o":{"x":0.167,"y":0.154},"t":52,"s":[402.579,371.893,0],"to":[-0.064,0.038,0],"ti":[0.074,-0.045,0]},{"i":{"x":0.833,"y":0.824},"o":{"x":0.167,"y":0.156},"t":53,"s":[402.371,372.019,0],"to":[-0.074,0.045,0],"ti":[0.083,-0.052,0]},{"i":{"x":0.833,"y":0.826},"o":{"x":0.167,"y":0.158},"t":54,"s":[402.135,372.165,0],"to":[-0.083,0.052,0],"ti":[0.091,-0.057,0]},{"i":{"x":0.833,"y":0.828},"o":{"x":0.167,"y":0.16},"t":55,"s":[401.874,372.329,0],"to":[-0.091,0.057,0],"ti":[0.097,-0.062,0]},{"i":{"x":0.833,"y":0.83},"o":{"x":0.167,"y":0.162},"t":56,"s":[401.591,372.509,0],"to":[-0.097,0.062,0],"ti":[0.102,-0.065,0]},{"i":{"x":0.833,"y":0.832},"o":{"x":0.167,"y":0.163},"t":57,"s":[401.29,372.7,0],"to":[-0.102,0.065,0],"ti":[0.106,-0.067,0]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.165},"t":58,"s":[400.977,372.899,0],"to":[-0.106,0.067,0],"ti":[0.053,-0.034,0]},{"t":59,"s":[400.657,373.102,0]}],"ix":2,"l":2},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":1,"k":[{"i":{"x":[0.833,0.833,0.833],"y":[0.867,1.088,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.167,0.167,0]},"t":6,"s":[119.144,105.827,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.795,0.877,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.222,0.043,0]},"t":7,"s":[108.917,89.684,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.672,1.179,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.141,0.257,0]},"t":8,"s":[102.765,122.894,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.908,0.844,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.112,0.057,0]},"t":9,"s":[93.801,138.844,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.347,0.934,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.904,0.179,0]},"t":10,"s":[67.511,88.612,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.047,0.005,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.096,-0.308,0]},"t":11,"s":[64.842,45.024,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.006,0.93,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.03,0.091,0]},"t":12,"s":[46.604,54.307,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.068,0.682,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.006,-0.449,0]},"t":13,"s":[75.236,155.919,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.917,0.86,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.037,0.113,0]},"t":14,"s":[44.513,140.008,100]},{"i":{"x":[0.833,0.833,0.833],"y":[152.322,0.967,1]},"o":{"x":[0.167,0.167,0.167],"y":[270.204,0.206,0]},"t":15,"s":[100.175,95.172,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.025,0.944,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.083,-0.056,0]},"t":16,"s":[100.192,64.696,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.006,0.462,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.02,-0.171,0]},"t":17,"s":[68.993,82.975,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.991,1.026,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.006,0.099,0]},"t":18,"s":[109.723,76.983,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.999,0.913,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.01,0.02,0]},"t":19,"s":[65.994,44.292,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.952,2.661,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.001,2.206,0]},"t":20,"s":[105.048,87.05,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.907,0.908,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.115,0.079,0]},"t":21,"s":[66.25,88.729,100]},{"i":{"x":[0.833,0.833,0.833],"y":[-0.395,1.035,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.774,0.85,0]},"t":22,"s":[82.513,53.595,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.013,0.706,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.089,0.024,0]},"t":23,"s":[84.475,49.774,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.914,0.563,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.011,0.116,0]},"t":24,"s":[115.346,55.183,100]},{"i":{"x":[0.833,0.833,0.833],"y":[3.517,0.971,1]},"o":{"x":[0.167,0.167,0.167],"y":[2.433,0.103,0]},"t":25,"s":[79.807,68.878,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.948,0.939,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.081,-0.045,0]},"t":26,"s":[78.547,127.052,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.885,1.131,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.137,-0.23,0]},"t":27,"s":[117.885,89.313,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.672,0.895,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.305,0.051,0]},"t":28,"s":[103.035,99.348,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.965,1.107,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.112,0.406,0]},"t":29,"s":[97.463,73.594,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.366,0.923,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.06,0.047,0]},"t":30,"s":[81.079,66.939,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.944,2.315,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.068,-1.035,0]},"t":31,"s":[90.595,82.121,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.794,1.043,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.173,0.078,0]},"t":32,"s":[39.327,80.989,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.967,0.857,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.14,0.028,0]},"t":33,"s":[55.998,99.978,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.774,1.028,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.055,0.199,0]},"t":34,"s":[80.566,71.181,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.053,0.916,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.132,0.021,0]},"t":35,"s":[65.735,50.454,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.93,2.464,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.032,17.741,0]},"t":36,"s":[40.364,78.153,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.919,-0.052,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.444,0.079,0]},"t":37,"s":[81.902,78.284,100]},{"i":{"x":[0.833,0.833,0.833],"y":[8.691,0.931,1]},"o":{"x":[0.167,0.167,0.167],"y":[-2.408,0.091,0]},"t":38,"s":[75.338,75.857,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.962,0.423,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.082,-0.405,0]},"t":39,"s":[75.557,47.651,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.151,1.036,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.069,0.097,0]},"t":40,"s":[55.074,52.465,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.832,0.956,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.054,0.025,0]},"t":41,"s":[66.306,80.999,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.912,0.908,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.165,-0.095,0]},"t":42,"s":[34.754,40.269,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.421,1.175,1]},"o":{"x":[0.167,0.167,0.167],"y":[1.584,0.921,0]},"t":43,"s":[2.594,59.324,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.174,-0.146,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.07,0.056,0]},"t":44,"s":[0.808,61.219,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.039,0.948,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.056,0.09,0]},"t":45,"s":[11.618,55.345,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.955,0.744,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.027,-0.14,0]},"t":46,"s":[-21.777,-19.555,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.083,0.932,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.096,0.123,0]},"t":47,"s":[27.422,8.427,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.896,0.349,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.042,-0.359,0]},"t":48,"s":[4.597,66.513,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.087,0.933,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.425,0.096,0]},"t":49,"s":[50.12,55.561,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.942,0.784,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.043,-0.354,0]},"t":50,"s":[61.217,-19.054,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.717,1.007,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.186,0.136,0]},"t":51,"s":[38.47,-4.836,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.956,1.072,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.075,0.006,0]},"t":52,"s":[45.499,17.824,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.926,0.911,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.092,0.039,0]},"t":53,"s":[-22.007,-6.623,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.362,0.919,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.679,1.237,0]},"t":54,"s":[10.024,38.847,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.873,-34.653,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.068,-2.841,0]},"t":55,"s":[6.522,42.131,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.708,0.925,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.243,0.084,0]},"t":56,"s":[25.242,42.037,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.976,-0.201,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.117,-0.791,0]},"t":57,"s":[35.027,2.106,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.833,0.833,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.034,0.09,0]},"t":58,"s":[59.478,5.911,100]},{"t":59,"s":[42.168,56.973,100]}],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"d":1,"ty":"el","s":{"a":0,"k":[10,10],"ix":2},"p":{"a":0,"k":[0,0],"ix":3},"nm":"Ellipse Path 1","mn":"ADBE Vector Shape - Ellipse","hd":false},{"ty":"fl","c":{"a":0,"k":[1,0.10196078431372549,0.3803921568627451,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Ellipse 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":6,"op":60,"st":6,"bm":0},{"ddd":0,"ind":51,"ty":4,"nm":"Shape Layer 1","sr":1,"ks":{"o":{"a":1,"k":[{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":3,"s":[0]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":8,"s":[100]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":30,"s":[100]},{"t":46,"s":[0]}],"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.833,"y":0.625},"o":{"x":0.167,"y":0.167},"t":3,"s":[273.808,259.665,0],"to":[0.527,-0.068,0],"ti":[-2.371,0.302,0]},{"i":{"x":0.833,"y":0.767},"o":{"x":0.167,"y":0.107},"t":4,"s":[276.968,259.259,0],"to":[2.371,-0.302,0],"ti":[-5.103,0.961,0]},{"i":{"x":0.833,"y":0.824},"o":{"x":0.167,"y":0.13},"t":5,"s":[288.032,257.854,0],"to":[5.103,-0.961,0],"ti":[-6.631,2.225,0]},{"i":{"x":0.833,"y":0.844},"o":{"x":0.167,"y":0.159},"t":6,"s":[307.589,253.495,0],"to":[6.631,-2.225,0],"ti":[-5.809,3.598,0]},{"i":{"x":0.833,"y":0.847},"o":{"x":0.167,"y":0.179},"t":7,"s":[327.818,244.501,0],"to":[5.809,-3.598,0],"ti":[-3.886,4.355,0]},{"i":{"x":0.833,"y":0.847},"o":{"x":0.167,"y":0.183},"t":8,"s":[342.441,231.905,0],"to":[3.886,-4.355,0],"ti":[-2.267,4.355,0]},{"i":{"x":0.833,"y":0.846},"o":{"x":0.167,"y":0.183},"t":9,"s":[351.136,218.373,0],"to":[2.267,-4.355,0],"ti":[-1.31,3.958,0]},{"i":{"x":0.833,"y":0.844},"o":{"x":0.167,"y":0.181},"t":10,"s":[356.042,205.774,0],"to":[1.31,-3.958,0],"ti":[-0.831,3.49,0]},{"i":{"x":0.833,"y":0.844},"o":{"x":0.167,"y":0.18},"t":11,"s":[358.996,194.624,0],"to":[0.831,-3.49,0],"ti":[-0.612,3.067,0]},{"i":{"x":0.833,"y":0.843},"o":{"x":0.167,"y":0.178},"t":12,"s":[361.029,184.836,0],"to":[0.612,-3.067,0],"ti":[-0.528,2.704,0]},{"i":{"x":0.833,"y":0.842},"o":{"x":0.167,"y":0.177},"t":13,"s":[362.67,176.222,0],"to":[0.528,-2.704,0],"ti":[-0.514,2.391,0]},{"i":{"x":0.833,"y":0.842},"o":{"x":0.167,"y":0.177},"t":14,"s":[364.195,168.613,0],"to":[0.514,-2.391,0],"ti":[-0.538,2.117,0]},{"i":{"x":0.833,"y":0.842},"o":{"x":0.167,"y":0.176},"t":15,"s":[365.753,161.877,0],"to":[0.538,-2.117,0],"ti":[-0.583,1.874,0]},{"i":{"x":0.833,"y":0.842},"o":{"x":0.167,"y":0.176},"t":16,"s":[367.425,155.911,0],"to":[0.583,-1.874,0],"ti":[-0.636,1.65,0]},{"i":{"x":0.833,"y":0.842},"o":{"x":0.167,"y":0.176},"t":17,"s":[369.252,150.634,0],"to":[0.636,-1.65,0],"ti":[-0.685,1.438,0]},{"i":{"x":0.833,"y":0.841},"o":{"x":0.167,"y":0.176},"t":18,"s":[371.238,146.01,0],"to":[0.685,-1.438,0],"ti":[-0.735,1.242,0]},{"i":{"x":0.833,"y":0.84},"o":{"x":0.167,"y":0.175},"t":19,"s":[373.362,142.007,0],"to":[0.735,-1.242,0],"ti":[-0.783,1.066,0]},{"i":{"x":0.833,"y":0.84},"o":{"x":0.167,"y":0.174},"t":20,"s":[375.646,138.557,0],"to":[0.783,-1.066,0],"ti":[-0.813,0.905,0]},{"i":{"x":0.833,"y":0.84},"o":{"x":0.167,"y":0.174},"t":21,"s":[378.057,135.612,0],"to":[0.813,-0.905,0],"ti":[-0.823,0.759,0]},{"i":{"x":0.833,"y":0.84},"o":{"x":0.167,"y":0.174},"t":22,"s":[380.527,133.13,0],"to":[0.823,-0.759,0],"ti":[-0.814,0.633,0]},{"i":{"x":0.833,"y":0.84},"o":{"x":0.167,"y":0.174},"t":23,"s":[382.995,131.056,0],"to":[0.814,-0.633,0],"ti":[-0.791,0.525,0]},{"i":{"x":0.833,"y":0.84},"o":{"x":0.167,"y":0.174},"t":24,"s":[385.412,129.334,0],"to":[0.791,-0.525,0],"ti":[-0.756,0.434,0]},{"i":{"x":0.833,"y":0.841},"o":{"x":0.167,"y":0.174},"t":25,"s":[387.738,127.909,0],"to":[0.756,-0.434,0],"ti":[-0.712,0.358,0]},{"i":{"x":0.833,"y":0.841},"o":{"x":0.167,"y":0.175},"t":26,"s":[389.945,126.733,0],"to":[0.712,-0.358,0],"ti":[-0.664,0.295,0]},{"i":{"x":0.833,"y":0.841},"o":{"x":0.167,"y":0.175},"t":27,"s":[392.013,125.763,0],"to":[0.664,-0.295,0],"ti":[-0.613,0.243,0]},{"i":{"x":0.833,"y":0.842},"o":{"x":0.167,"y":0.175},"t":28,"s":[393.931,124.963,0],"to":[0.613,-0.243,0],"ti":[-0.561,0.201,0]},{"i":{"x":0.833,"y":0.842},"o":{"x":0.167,"y":0.176},"t":29,"s":[395.694,124.303,0],"to":[0.561,-0.201,0],"ti":[-0.508,0.165,0]},{"i":{"x":0.833,"y":0.843},"o":{"x":0.167,"y":0.177},"t":30,"s":[397.298,123.76,0],"to":[0.508,-0.165,0],"ti":[-0.456,0.135,0]},{"i":{"x":0.833,"y":0.844},"o":{"x":0.167,"y":0.178},"t":31,"s":[398.744,123.313,0],"to":[0.456,-0.135,0],"ti":[-0.404,0.11,0]},{"i":{"x":0.833,"y":0.845},"o":{"x":0.167,"y":0.179},"t":32,"s":[400.032,122.948,0],"to":[0.404,-0.11,0],"ti":[-0.352,0.089,0]},{"i":{"x":0.833,"y":0.846},"o":{"x":0.167,"y":0.18},"t":33,"s":[401.165,122.652,0],"to":[0.352,-0.089,0],"ti":[-0.302,0.071,0]},{"i":{"x":0.833,"y":0.848},"o":{"x":0.167,"y":0.182},"t":34,"s":[402.144,122.415,0],"to":[0.302,-0.071,0],"ti":[-0.253,0.055,0]},{"i":{"x":0.833,"y":0.85},"o":{"x":0.167,"y":0.184},"t":35,"s":[402.975,122.228,0],"to":[0.253,-0.055,0],"ti":[-0.207,0.041,0]},{"i":{"x":0.833,"y":0.853},"o":{"x":0.167,"y":0.188},"t":36,"s":[403.664,122.085,0],"to":[0.207,-0.041,0],"ti":[-0.163,0.029,0]},{"i":{"x":0.833,"y":0.859},"o":{"x":0.167,"y":0.193},"t":37,"s":[404.217,121.981,0],"to":[0.163,-0.029,0],"ti":[-0.119,0.018,0]},{"i":{"x":0.833,"y":0.868},"o":{"x":0.167,"y":0.203},"t":38,"s":[404.639,121.911,0],"to":[0.119,-0.018,0],"ti":[-0.078,0.008,0]},{"i":{"x":0.833,"y":0.887},"o":{"x":0.167,"y":0.226},"t":39,"s":[404.934,121.872,0],"to":[0.078,-0.008,0],"ti":[-0.038,-0.001,0]},{"i":{"x":0.833,"y":0.82},"o":{"x":0.167,"y":0.317},"t":40,"s":[405.107,121.862,0],"to":[0.038,0.001,0],"ti":[0,-0.01,0]},{"i":{"x":0.833,"y":0.714},"o":{"x":0.167,"y":0.155},"t":41,"s":[405.163,121.879,0],"to":[0,0.01,0],"ti":[0.036,-0.018,0]},{"i":{"x":0.833,"y":0.805},"o":{"x":0.167,"y":0.118},"t":42,"s":[405.107,121.921,0],"to":[-0.036,0.018,0],"ti":[0.064,-0.025,0]},{"i":{"x":0.833,"y":0.829},"o":{"x":0.167,"y":0.146},"t":43,"s":[404.945,121.988,0],"to":[-0.064,0.025,0],"ti":[0.075,-0.028,0]},{"i":{"x":0.833,"y":0.83},"o":{"x":0.167,"y":0.163},"t":44,"s":[404.725,122.069,0],"to":[-0.075,0.028,0],"ti":[0.078,-0.03,0]},{"i":{"x":0.833,"y":0.832},"o":{"x":0.167,"y":0.164},"t":45,"s":[404.495,122.157,0],"to":[-0.078,0.03,0],"ti":[0.079,-0.032,0]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.165},"t":46,"s":[404.258,122.252,0],"to":[-0.079,0.032,0],"ti":[0.08,-0.034,0]},{"i":{"x":0.833,"y":0.835},"o":{"x":0.167,"y":0.166},"t":47,"s":[404.019,122.352,0],"to":[-0.08,0.034,0],"ti":[0.078,-0.036,0]},{"i":{"x":0.833,"y":0.852},"o":{"x":0.167,"y":0.169},"t":48,"s":[403.781,122.458,0],"to":[-0.078,0.036,0],"ti":[0.065,-0.038,0]},{"i":{"x":0.833,"y":0.858},"o":{"x":0.167,"y":0.191},"t":49,"s":[403.552,122.57,0],"to":[-0.065,0.038,0],"ti":[0.039,-0.038,0]},{"i":{"x":0.833,"y":0.848},"o":{"x":0.167,"y":0.201},"t":50,"s":[403.392,122.685,0],"to":[-0.039,0.038,0],"ti":[0.013,-0.038,0]},{"i":{"x":0.833,"y":0.823},"o":{"x":0.167,"y":0.184},"t":51,"s":[403.315,122.8,0],"to":[-0.013,0.038,0],"ti":[-0.011,-0.037,0]},{"i":{"x":0.833,"y":0.81},"o":{"x":0.167,"y":0.157},"t":52,"s":[403.314,122.914,0],"to":[0.011,0.037,0],"ti":[-0.033,-0.036,0]},{"i":{"x":0.833,"y":0.812},"o":{"x":0.167,"y":0.148},"t":53,"s":[403.381,123.025,0],"to":[0.033,0.036,0],"ti":[-0.052,-0.035,0]},{"i":{"x":0.833,"y":0.817},"o":{"x":0.167,"y":0.15},"t":54,"s":[403.509,123.131,0],"to":[0.052,0.035,0],"ti":[-0.069,-0.033,0]},{"i":{"x":0.833,"y":0.821},"o":{"x":0.167,"y":0.153},"t":55,"s":[403.692,123.233,0],"to":[0.069,0.033,0],"ti":[-0.084,-0.031,0]},{"i":{"x":0.833,"y":0.824},"o":{"x":0.167,"y":0.156},"t":56,"s":[403.923,123.328,0],"to":[0.084,0.031,0],"ti":[-0.096,-0.028,0]},{"i":{"x":0.833,"y":0.827},"o":{"x":0.167,"y":0.159},"t":57,"s":[404.195,123.417,0],"to":[0.096,0.028,0],"ti":[-0.107,-0.025,0]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.16},"t":58,"s":[404.501,123.497,0],"to":[0.107,0.025,0],"ti":[-0.056,-0.012,0]},{"t":59,"s":[404.836,123.569,0]}],"ix":2,"l":2},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":1,"k":[{"i":{"x":[0.833,0.833,0.833],"y":[1.013,0.991,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.167,0.167,0]},"t":3,"s":[94.174,88.68,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.854,0.871,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.011,-0.01,0]},"t":4,"s":[62.657,103.74,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1,0.874,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.195,0.236,0]},"t":5,"s":[98.913,90.256,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.962,1.852,1]},"o":{"x":[0.167,0.167,0.167],"y":[0,0.247,0]},"t":6,"s":[126.059,82.91,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.12,0.944,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.071,0.076,0]},"t":7,"s":[99.016,79.175,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.877,0.719,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.049,-0.167,0]},"t":8,"s":[113.623,121.094,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.808,0.833,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.258,0.118,0]},"t":9,"s":[77.907,107.132,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.024,1.089,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.147,0.166,0]},"t":10,"s":[60.895,73.957,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.02,0.864,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.019,0.043,0]},"t":11,"s":[38.746,40.656,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.997,1.04,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.016,0.216,0]},"t":12,"s":[67.397,109.363,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.787,0.879,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.003,0.027,0]},"t":13,"s":[31.725,152.383,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.983,0.979,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.137,0.27,0]},"t":14,"s":[66.258,88.662,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.996,0.751,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.021,-0.027,0]},"t":15,"s":[119.803,60.228,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.949,1.058,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.004,0.125,0]},"t":16,"s":[76.922,81.625,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.975,0.91,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.135,0.034,0]},"t":17,"s":[117.696,124.097,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.215,1.712,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.036,1.154,0]},"t":18,"s":[102.098,51.832,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.928,0.975,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.06,0.075,0]},"t":19,"s":[113.025,46.206,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.706,0.89,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.543,-0.036,0]},"t":20,"s":[73.843,99.907,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.753,0.976,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.116,0.341,0]},"t":21,"s":[79.054,62.467,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.064,0.498,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.126,-0.035,0]},"t":22,"s":[92.238,50.379,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.883,0.885,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.036,0.1,0]},"t":23,"s":[118.149,58.916,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.141,0.987,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.287,0.3,0]},"t":24,"s":[72.379,101.804,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.893,0.826,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.052,-0.015,0]},"t":25,"s":[53.668,118.311,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.925,1.044,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.378,0.16,0]},"t":26,"s":[103.965,104.335,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.059,1.06,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.756,0.029,0]},"t":27,"s":[118.191,89.162,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.036,0.944,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.091,0.035,0]},"t":28,"s":[116.779,112.396,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.158,1.215,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.025,-0.166,0]},"t":29,"s":[102.243,72.439,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.897,0.987,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.055,0.06,0]},"t":30,"s":[123.077,85.771,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.064,0.868,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.435,-0.015,0]},"t":31,"s":[62.652,37.969,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.949,1.055,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.036,0.226,0]},"t":32,"s":[48.316,78.523,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.545,0.919,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.131,0.033,0]},"t":33,"s":[73.718,102.173,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.979,-0.741,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.102,-2.641,0]},"t":34,"s":[63.843,62.973,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.921,1.048,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.028,0.088,0]},"t":35,"s":[19.791,64.173,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.758,0.937,1]},"o":{"x":[0.167,0.167,0.167],"y":[-1.621,0.03,0]},"t":36,"s":[52.853,88.034,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.306,0.9,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.127,-0.264,0]},"t":37,"s":[51.236,50.431,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.889,-0.258,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.065,0.495,0]},"t":38,"s":[48.161,59.44,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.538,1.041,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.334,0.089,0]},"t":39,"s":[62.531,61.263,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.759,0.854,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.072,0.027,0]},"t":40,"s":[67.316,86.954,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.954,1.042,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.127,0.194,0]},"t":41,"s":[31.655,48.638,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.875,0.994,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.1,0.028,0]},"t":42,"s":[-35.843,19.827,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.076,0.824,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.249,-0.007,0]},"t":43,"s":[-5.221,63.199,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.027,0.939,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.04,0.158,0]},"t":44,"s":[10.17,23.011,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.934,0.602,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.02,-0.226,0]},"t":45,"s":[-19.321,-21.801,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.313,0.928,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.321,0.105,0]},"t":46,"s":[19.702,-9.718,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.894,0.502,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.066,-0.533,0]},"t":47,"s":[11.661,35.883,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.021,0.956,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.392,0.1,0]},"t":48,"s":[49.934,29.715,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.685,0.902,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.016,-0.095,0]},"t":49,"s":[60.278,-1.013,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.849,1.927,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.113,0.549,0]},"t":50,"s":[47.382,13.308,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.934,1.073,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.187,0.076,0]},"t":51,"s":[11.559,15.87,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.35,0.905,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.311,0.039,0]},"t":52,"s":[-17.364,-15.186,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.953,1.167,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.096,0.662,0]},"t":53,"s":[-11.255,42.917,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.05,0.864,1]},"o":{"x":[0.167,0.167,0.167],"y":[-0.107,0.056,0]},"t":54,"s":[30.321,51.283,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.897,1.071,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.031,0.214,0]},"t":55,"s":[12.149,26.161,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1.244,0.929,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.44,0.038,0]},"t":56,"s":[41.324,10.186,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.878,0.901,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.062,-0.468,0]},"t":57,"s":[48.144,39.687,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.833,0.833,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.262,0.526,0]},"t":58,"s":[21.344,35.23,100]},{"t":59,"s":[8.869,34.392,100]}],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"d":1,"ty":"el","s":{"a":0,"k":[10,10],"ix":2},"p":{"a":0,"k":[0,0],"ix":3},"nm":"Ellipse Path 1","mn":"ADBE Vector Shape - Ellipse","hd":false},{"ty":"fl","c":{"a":0,"k":[1,0.10196078431372549,0.3803921568627451,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Ellipse 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":3,"op":60,"st":3,"bm":0}],"markers":[]} \ No newline at end of file diff --git a/assets/icon.png b/assets/icon.png new file mode 100644 index 0000000..e6594d2 Binary files /dev/null and b/assets/icon.png differ diff --git a/assets/images/aura-tab-mark.png b/assets/images/aura-tab-mark.png new file mode 100644 index 0000000..f83cefa Binary files /dev/null and b/assets/images/aura-tab-mark.png differ diff --git a/components/external-link.tsx b/components/external-link.tsx deleted file mode 100644 index 883e515..0000000 --- a/components/external-link.tsx +++ /dev/null @@ -1,25 +0,0 @@ -import { Href, Link } from 'expo-router'; -import { openBrowserAsync, WebBrowserPresentationStyle } from 'expo-web-browser'; -import { type ComponentProps } from 'react'; - -type Props = Omit, 'href'> & { href: Href & string }; - -export function ExternalLink({ href, ...rest }: Props) { - return ( - { - if (process.env.EXPO_OS !== 'web') { - // Prevent the default behavior of linking to the default browser on native. - event.preventDefault(); - // Open the link in an in-app browser. - await openBrowserAsync(href, { - presentationStyle: WebBrowserPresentationStyle.AUTOMATIC, - }); - } - }} - /> - ); -} diff --git a/components/haptic-tab.tsx b/components/haptic-tab.tsx deleted file mode 100644 index 7f3981c..0000000 --- a/components/haptic-tab.tsx +++ /dev/null @@ -1,18 +0,0 @@ -import { BottomTabBarButtonProps } from '@react-navigation/bottom-tabs'; -import { PlatformPressable } from '@react-navigation/elements'; -import * as Haptics from 'expo-haptics'; - -export function HapticTab(props: BottomTabBarButtonProps) { - return ( - { - if (process.env.EXPO_OS === 'ios') { - // Add a soft haptic feedback when pressing down on the tabs. - Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); - } - props.onPressIn?.(ev); - }} - /> - ); -} diff --git a/components/hello-wave.tsx b/components/hello-wave.tsx deleted file mode 100644 index 5def547..0000000 --- a/components/hello-wave.tsx +++ /dev/null @@ -1,19 +0,0 @@ -import Animated from 'react-native-reanimated'; - -export function HelloWave() { - return ( - - 👋 - - ); -} diff --git a/components/parallax-scroll-view.tsx b/components/parallax-scroll-view.tsx deleted file mode 100644 index 6f674a7..0000000 --- a/components/parallax-scroll-view.tsx +++ /dev/null @@ -1,79 +0,0 @@ -import type { PropsWithChildren, ReactElement } from 'react'; -import { StyleSheet } from 'react-native'; -import Animated, { - interpolate, - useAnimatedRef, - useAnimatedStyle, - useScrollOffset, -} from 'react-native-reanimated'; - -import { ThemedView } from '@/components/themed-view'; -import { useColorScheme } from '@/hooks/use-color-scheme'; -import { useThemeColor } from '@/hooks/use-theme-color'; - -const HEADER_HEIGHT = 250; - -type Props = PropsWithChildren<{ - headerImage: ReactElement; - headerBackgroundColor: { dark: string; light: string }; -}>; - -export default function ParallaxScrollView({ - children, - headerImage, - headerBackgroundColor, -}: Props) { - const backgroundColor = useThemeColor({}, 'background'); - const colorScheme = useColorScheme() ?? 'light'; - const scrollRef = useAnimatedRef(); - const scrollOffset = useScrollOffset(scrollRef); - const headerAnimatedStyle = useAnimatedStyle(() => { - return { - transform: [ - { - translateY: interpolate( - scrollOffset.value, - [-HEADER_HEIGHT, 0, HEADER_HEIGHT], - [-HEADER_HEIGHT / 2, 0, HEADER_HEIGHT * 0.75] - ), - }, - { - scale: interpolate(scrollOffset.value, [-HEADER_HEIGHT, 0, HEADER_HEIGHT], [2, 1, 1]), - }, - ], - }; - }); - - return ( - - - {headerImage} - - {children} - - ); -} - -const styles = StyleSheet.create({ - container: { - flex: 1, - }, - header: { - height: HEADER_HEIGHT, - overflow: 'hidden', - }, - content: { - flex: 1, - padding: 32, - gap: 16, - overflow: 'hidden', - }, -}); diff --git a/components/themed-text.tsx b/components/themed-text.tsx deleted file mode 100644 index d79d0a1..0000000 --- a/components/themed-text.tsx +++ /dev/null @@ -1,60 +0,0 @@ -import { StyleSheet, Text, type TextProps } from 'react-native'; - -import { useThemeColor } from '@/hooks/use-theme-color'; - -export type ThemedTextProps = TextProps & { - lightColor?: string; - darkColor?: string; - type?: 'default' | 'title' | 'defaultSemiBold' | 'subtitle' | 'link'; -}; - -export function ThemedText({ - style, - lightColor, - darkColor, - type = 'default', - ...rest -}: ThemedTextProps) { - const color = useThemeColor({ light: lightColor, dark: darkColor }, 'text'); - - return ( - - ); -} - -const styles = StyleSheet.create({ - default: { - fontSize: 16, - lineHeight: 24, - }, - defaultSemiBold: { - fontSize: 16, - lineHeight: 24, - fontWeight: '600', - }, - title: { - fontSize: 32, - fontWeight: 'bold', - lineHeight: 32, - }, - subtitle: { - fontSize: 20, - fontWeight: 'bold', - }, - link: { - lineHeight: 30, - fontSize: 16, - color: '#0a7ea4', - }, -}); diff --git a/components/themed-view.tsx b/components/themed-view.tsx deleted file mode 100644 index 6f181d8..0000000 --- a/components/themed-view.tsx +++ /dev/null @@ -1,14 +0,0 @@ -import { View, type ViewProps } from 'react-native'; - -import { useThemeColor } from '@/hooks/use-theme-color'; - -export type ThemedViewProps = ViewProps & { - lightColor?: string; - darkColor?: string; -}; - -export function ThemedView({ style, lightColor, darkColor, ...otherProps }: ThemedViewProps) { - const backgroundColor = useThemeColor({ light: lightColor, dark: darkColor }, 'background'); - - return ; -} diff --git a/components/ui/collapsible.tsx b/components/ui/collapsible.tsx deleted file mode 100644 index 6345fde..0000000 --- a/components/ui/collapsible.tsx +++ /dev/null @@ -1,45 +0,0 @@ -import { PropsWithChildren, useState } from 'react'; -import { StyleSheet, TouchableOpacity } from 'react-native'; - -import { ThemedText } from '@/components/themed-text'; -import { ThemedView } from '@/components/themed-view'; -import { IconSymbol } from '@/components/ui/icon-symbol'; -import { Colors } from '@/constants/theme'; -import { useColorScheme } from '@/hooks/use-color-scheme'; - -export function Collapsible({ children, title }: PropsWithChildren & { title: string }) { - const [isOpen, setIsOpen] = useState(false); - const theme = useColorScheme() ?? 'light'; - - return ( - - setIsOpen((value) => !value)} - activeOpacity={0.8}> - - - {title} - - {isOpen && {children}} - - ); -} - -const styles = StyleSheet.create({ - heading: { - flexDirection: 'row', - alignItems: 'center', - gap: 6, - }, - content: { - marginTop: 6, - marginLeft: 24, - }, -}); diff --git a/components/ui/icon-symbol.ios.tsx b/components/ui/icon-symbol.ios.tsx deleted file mode 100644 index 9177f4d..0000000 --- a/components/ui/icon-symbol.ios.tsx +++ /dev/null @@ -1,32 +0,0 @@ -import { SymbolView, SymbolViewProps, SymbolWeight } from 'expo-symbols'; -import { StyleProp, ViewStyle } from 'react-native'; - -export function IconSymbol({ - name, - size = 24, - color, - style, - weight = 'regular', -}: { - name: SymbolViewProps['name']; - size?: number; - color: string; - style?: StyleProp; - weight?: SymbolWeight; -}) { - return ( - - ); -} diff --git a/components/ui/icon-symbol.tsx b/components/ui/icon-symbol.tsx deleted file mode 100644 index b7ece6b..0000000 --- a/components/ui/icon-symbol.tsx +++ /dev/null @@ -1,41 +0,0 @@ -// Fallback for using MaterialIcons on Android and web. - -import MaterialIcons from '@expo/vector-icons/MaterialIcons'; -import { SymbolWeight, SymbolViewProps } from 'expo-symbols'; -import { ComponentProps } from 'react'; -import { OpaqueColorValue, type StyleProp, type TextStyle } from 'react-native'; - -type IconMapping = Record['name']>; -type IconSymbolName = keyof typeof MAPPING; - -/** - * Add your SF Symbols to Material Icons mappings here. - * - see Material Icons in the [Icons Directory](https://icons.expo.fyi). - * - see SF Symbols in the [SF Symbols](https://developer.apple.com/sf-symbols/) app. - */ -const MAPPING = { - 'house.fill': 'home', - 'paperplane.fill': 'send', - 'chevron.left.forwardslash.chevron.right': 'code', - 'chevron.right': 'chevron-right', -} as IconMapping; - -/** - * An icon component that uses native SF Symbols on iOS, and Material Icons on Android and web. - * This ensures a consistent look across platforms, and optimal resource usage. - * Icon `name`s are based on SF Symbols and require manual mapping to Material Icons. - */ -export function IconSymbol({ - name, - size = 24, - color, - style, -}: { - name: IconSymbolName; - size?: number; - color: string | OpaqueColorValue; - style?: StyleProp; - weight?: SymbolWeight; -}) { - return ; -} diff --git a/constants/theme.ts b/constants/theme.ts index 0fa7bd9..9668b00 100644 --- a/constants/theme.ts +++ b/constants/theme.ts @@ -1,96 +1,366 @@ -/** - * Below are the colors that are used in the app. The colors are defined in the light and dark mode. - * There are many other ways to style your app. For example, [Nativewind](https://www.nativewind.dev/), [Tamagui](https://tamagui.dev/), [unistyles](https://reactnativeunistyles.vercel.app), etc. - */ +import { Platform } from "react-native"; -import { Platform } from 'react-native'; +export const auraColors = { + backgroundDeep: "#080709", + backgroundDark: "#111014", + surface: "rgba(24,22,30,0.72)", + surfaceElevated: "rgba(34,31,40,0.82)", + surfaceBase: "rgba(24,22,30,0.72)", + surfaceRaised: "rgba(34,31,40,0.86)", + surfaceInteractive: "rgba(42,36,50,0.66)", + accentMauve: "#854F6C", + accentRose: "#D8AAA6", + boardLight: "#F7F1EA", + textPrimary: "#FBE4D8", + textSecondary: "rgba(251,228,216,0.82)", + textMuted: "rgba(251,228,216,0.64)", + textOnLight: "#111014", + textOnLightSecondary: "rgba(17,16,20,0.68)", + borderDark: "rgba(251,228,216,0.10)", + borderWarm: "rgba(223,182,178,0.18)", +} as const; -const tintColorLight = '#0a7ea4'; -const tintColorDark = '#8bd3ff'; +const tintColor = auraColors.accentRose; + +const auraSpacing = { + xxs: 4, + xs: 8, + sm: 12, + md: 16, + lg: 20, + xl: 24, + xxl: 32, + xxxl: 40, +} as const; + +const auraTypography = { + screenTitle: { + fontSize: 28, + lineHeight: 35, + fontWeight: "700" as const, + letterSpacing: -0.35, + }, + sectionTitle: { + fontSize: 20, + lineHeight: 26, + fontWeight: "600" as const, + letterSpacing: -0.2, + }, + section: { + fontSize: 17, + lineHeight: 23, + fontWeight: "600" as const, + letterSpacing: -0.15, + }, + cardTitle: { + fontSize: 18, + lineHeight: 24, + fontWeight: "600" as const, + letterSpacing: -0.15, + }, + heading: { + fontSize: 24, + lineHeight: 31, + fontWeight: "600" as const, + letterSpacing: -0.3, + }, + caption: { + fontSize: 12, + lineHeight: 17, + fontWeight: "500" as const, + }, + metadata: { + fontSize: 11, + lineHeight: 15, + fontWeight: "500" as const, + letterSpacing: 1.25, + }, + bodySecondary: { + fontSize: 14, + lineHeight: 20, + fontWeight: "500" as const, + }, + bodyStrong: { + fontSize: 15, + lineHeight: 22, + fontWeight: "600" as const, + }, + chipLabel: { + fontSize: 12.5, + lineHeight: 16, + fontWeight: "500" as const, + }, + buttonLabel: { + fontSize: 15, + lineHeight: 20, + fontWeight: "600" as const, + letterSpacing: 0.1, + }, + button: { + fontSize: 15, + lineHeight: 20, + fontWeight: "600" as const, + letterSpacing: 0.1, + }, + eyebrow: { + fontSize: 12, + lineHeight: 16, + letterSpacing: 1.9, + fontWeight: "600" as const, + }, + body: { + fontSize: 15, + lineHeight: 23, + fontWeight: "400" as const, + }, + subtitle: { + fontSize: 17, + lineHeight: 26, + fontWeight: "500" as const, + }, + title: { + fontSize: 34, + lineHeight: 41, + fontWeight: "700" as const, + letterSpacing: -0.45, + }, + hero: { + fontSize: 46, + lineHeight: 53, + fontWeight: "700" as const, + letterSpacing: -0.55, + }, + wordmark: { + fontSize: 28, + letterSpacing: 11, + fontWeight: "500" as const, + }, +} as const; + +const auraTokens = { + colors: { + background: auraColors.backgroundDeep, + surface: auraColors.surface, + surfaceBase: auraColors.surfaceBase, + surfaceRaised: auraColors.surfaceRaised, + surfaceInteractive: auraColors.surfaceInteractive, + surfaceSoft: "rgba(42,36,50,0.54)", + surfaceMuted: "rgba(34,31,40,0.64)", + surfaceElevated: auraColors.surfaceElevated, + glass: "rgba(16,15,20,0.68)", + surfaceGlass: "rgba(18,17,22,0.70)", + surface1: auraColors.surfaceBase, + surface2: "rgba(34,31,40,0.70)", + surface3: "rgba(42,36,50,0.76)", + surfaceWarm: auraColors.surface, + warmGlow: "rgba(223,182,178,0.04)", + textPrimary: auraColors.textPrimary, + textSecondary: auraColors.textSecondary, + textMuted: auraColors.textMuted, + primary: auraColors.accentRose, + accent: auraColors.accentRose, + accentSoft: "rgba(223,182,178,0.12)", + primaryText: auraColors.textOnLight, + primaryPurple: auraColors.accentMauve, + softPurple: auraColors.accentRose, + lightPurple: auraColors.accentRose, + ctaCream: auraColors.accentRose, + outfitBoardBackground: auraColors.boardLight, + border: auraColors.borderDark, + borderStrong: "rgba(251,228,216,0.14)", + purpleBorder: "rgba(223,182,178,0.22)", + purpleGlow: "rgba(223,182,178,0.05)", + purpleSurface: "rgba(223,182,178,0.10)", + purpleSurfaceStrong: "rgba(223,182,178,0.15)", + chipBackground: "rgba(34,31,40,0.70)", + inputBackground: "rgba(18,17,22,0.78)", + dockBackground: "rgba(8,7,9,0.84)", + dockScrimTop: "rgba(9,0,11,0)", + dockScrimMid: "rgba(9,0,11,0.03)", + dockScrimBottom: "rgba(9,0,11,0.08)", + danger: "#FF4D4F", + destructive: "#FF4D4F", + dangerSurface: "rgba(255,77,79,0.12)", + dangerBorder: "rgba(255,77,79,0.28)", + success: "#22C55E", + warning: "#F59E0B", + auraPink: auraColors.accentRose, + auraChampagne: auraColors.boardLight, + auraBlue: auraColors.accentMauve, + auraLavender: auraColors.accentRose, + iridescentStart: auraColors.accentMauve, + iridescentMid: auraColors.accentRose, + iridescentEnd: auraColors.boardLight, + borderSoft: "rgba(251,228,216,0.07)", + }, + gradients: { + aura: [auraColors.accentMauve, auraColors.accentRose, auraColors.boardLight, auraColors.accentRose] as const, + background: ["#111014", auraColors.backgroundDeep, auraColors.backgroundDark] as const, + }, + shadows: { + auraGlow: "rgba(223,182,178,0.14)", + subtle: { + shadowColor: "#000", + shadowOpacity: 0.1, + shadowRadius: 14, + shadowOffset: { width: 0, height: 8 }, + elevation: 2, + }, + elevated: { + shadowColor: "#000", + shadowOpacity: 0.16, + shadowRadius: 24, + shadowOffset: { width: 0, height: 16 }, + elevation: 6, + }, + }, + radii: { + sm: 12, + md: 18, + lg: 24, + small: 12, + medium: 18, + large: 24, + xl: 28, + xxl: 32, + card: 28, + full: 999, + pill: 999, + button: 999, + }, + motion: { + fast: 120, + normal: 180, + slow: 280, + pressScale: 0.97, + }, + spacing: auraSpacing, + typography: auraTypography, +} as const; + +function buildPalette() { + return { + backgroundDeep: auraColors.backgroundDeep, + backgroundDark: auraColors.backgroundDark, + background: auraTokens.colors.background, + surface: auraTokens.colors.surface, + surfaceBase: auraTokens.colors.surfaceBase, + surfaceRaised: auraTokens.colors.surfaceRaised, + surfaceInteractive: auraTokens.colors.surfaceInteractive, + surfaceSoft: auraTokens.colors.surfaceSoft, + surfaceMuted: auraTokens.colors.surfaceMuted, + surfaceElevated: auraTokens.colors.surfaceElevated, + glass: auraTokens.colors.glass, + surfaceGlass: auraTokens.colors.surfaceGlass, + card: auraTokens.colors.surface, + input: auraTokens.colors.inputBackground, + inputBackground: auraTokens.colors.inputBackground, + chipBackground: auraTokens.colors.chipBackground, + surface1: auraTokens.colors.surface1, + surface2: auraTokens.colors.surface2, + surface3: auraTokens.colors.surface3, + surfaceWarm: auraTokens.colors.surfaceWarm, + warmGlow: auraTokens.colors.warmGlow, + text: auraTokens.colors.textPrimary, + textPrimary: auraTokens.colors.textPrimary, + textSecondary: auraTokens.colors.textSecondary, + textMuted: auraTokens.colors.textMuted, + primary: auraTokens.colors.primary, + primaryText: auraTokens.colors.primaryText, + textOnLight: auraColors.textOnLight, + textOnLightSecondary: auraColors.textOnLightSecondary, + border: auraTokens.colors.border, + borderDark: auraColors.borderDark, + borderWarm: auraColors.borderWarm, + borderStrong: auraTokens.colors.borderStrong, + borderSoft: auraTokens.colors.borderSoft, + purpleBorder: auraTokens.colors.purpleBorder, + purpleGlow: auraTokens.colors.purpleGlow, + purpleSurface: auraTokens.colors.purpleSurface, + purpleSurfaceStrong: auraTokens.colors.purpleSurfaceStrong, + tint: tintColor, + accent: auraTokens.colors.accent, + accentMauve: auraColors.accentMauve, + accentRose: auraColors.accentRose, + accentSoft: auraTokens.colors.accentSoft, + primaryPurple: auraTokens.colors.primaryPurple, + softPurple: auraTokens.colors.softPurple, + lightPurple: auraTokens.colors.lightPurple, + ctaCream: auraTokens.colors.ctaCream, + ctaText: auraColors.textOnLight, + primaryCta: auraColors.accentRose, + primaryCtaText: auraColors.textOnLight, + secondaryCta: "rgba(34,31,40,0.72)", + secondaryCtaText: auraColors.textPrimary, + outfitBoardBackground: auraTokens.colors.outfitBoardBackground, + boardLight: auraColors.boardLight, + auraPink: auraTokens.colors.auraPink, + auraChampagne: auraTokens.colors.auraChampagne, + auraBlue: auraTokens.colors.auraBlue, + auraLavender: auraTokens.colors.auraLavender, + iridescentStart: auraTokens.colors.iridescentStart, + iridescentMid: auraTokens.colors.iridescentMid, + iridescentEnd: auraTokens.colors.iridescentEnd, + success: auraTokens.colors.success, + warning: auraTokens.colors.warning, + danger: auraTokens.colors.danger, + destructive: auraTokens.colors.destructive, + dangerSurface: auraTokens.colors.dangerSurface, + dangerBorder: auraTokens.colors.dangerBorder, + muted: auraTokens.colors.surfaceElevated, + shadow: "rgba(0,0,0,0.52)", + overlay: "rgba(9,0,11,0.72)", + glassBorder: auraTokens.colors.border, + glassInnerBorder: "rgba(251,228,216,0.05)", + glassEdge: "rgba(251,228,216,0.08)", + lens: auraTokens.colors.purpleSurfaceStrong, + lensInner: "rgba(251,228,216,0.05)", + scrimTop: "rgba(9,0,11,0)", + scrimMid: "rgba(9,0,11,0.30)", + scrimBottom: "rgba(9,0,11,0.70)", + dockBackground: auraTokens.colors.dockBackground, + dockScrimTop: auraTokens.colors.dockScrimTop, + dockScrimMid: auraTokens.colors.dockScrimMid, + dockScrimBottom: auraTokens.colors.dockScrimBottom, + dockIcon: "rgba(251,228,216,0.72)", + aiAccent: auraColors.accentRose, + icon: auraTokens.colors.textSecondary, + tabIconDefault: "rgba(251,228,216,0.72)", + tabIconSelected: auraColors.textPrimary, + }; +} + +export const ThemeTokens = { + light: auraTokens, + dark: auraTokens, +} as const; export const Colors = { - light: { - background: '#f7f7f8', - surface: '#ffffff', - card: '#ffffff', - input: '#ffffff', - text: '#11181C', - textSecondary: '#64748b', - border: '#e5e7eb', - tint: tintColorLight, - accent: '#111827', - accentSoft: '#f5f8ff', - success: '#0a7a5a', - warning: '#b7791f', - danger: '#c0392b', - muted: '#f3f4f6', - shadow: 'rgba(15,23,42,0.12)', - overlay: 'rgba(255,255,255,0.16)', - glassBorder: 'rgba(255,255,255,0.30)', - glassInnerBorder: 'rgba(0,0,0,0.06)', - glassEdge: 'rgba(255,255,255,0.60)', - lens: 'rgba(255,255,255,0.22)', - lensInner: 'rgba(255,255,255,0.10)', - scrimTop: 'rgba(255,255,255,0)', - scrimMid: 'rgba(255,255,255,0.20)', - scrimBottom: 'rgba(255,255,255,0.36)', - dockIcon: 'rgba(15,23,42,0.55)', - aiAccent: '#6fbfff', - icon: '#687076', - tabIconDefault: '#687076', - tabIconSelected: tintColorLight, - }, - dark: { - background: '#101215', - surface: '#171a1f', - card: '#1a1e24', - input: '#171a1f', - text: '#ECEDEE', - textSecondary: '#9aa4b2', - border: '#2b313a', - tint: tintColorDark, - accent: '#e5e7eb', - accentSoft: '#182233', - success: '#34d399', - warning: '#fbbf24', - danger: '#f87171', - muted: '#222833', - shadow: 'rgba(0,0,0,0.35)', - overlay: 'rgba(12,16,24,0.56)', - glassBorder: 'rgba(255,255,255,0.10)', - glassInnerBorder: 'rgba(255,255,255,0.05)', - glassEdge: 'rgba(255,255,255,0.12)', - lens: 'rgba(255,255,255,0.08)', - lensInner: 'rgba(255,255,255,0.04)', - scrimTop: 'rgba(16,18,21,0)', - scrimMid: 'rgba(16,18,21,0.26)', - scrimBottom: 'rgba(16,18,21,0.44)', - dockIcon: 'rgba(236,237,238,0.70)', - aiAccent: '#8fd8ff', - icon: '#9BA1A6', - tabIconDefault: '#9BA1A6', - tabIconSelected: tintColorDark, - }, -}; + light: buildPalette(), + dark: buildPalette(), +} as const; -export type AppColors = typeof Colors.light; +export type AppColors = typeof Colors.dark; +export type AppThemeTokens = typeof ThemeTokens.dark; export const Fonts = Platform.select({ ios: { - /** iOS `UIFontDescriptorSystemDesignDefault` */ - sans: 'system-ui', - /** iOS `UIFontDescriptorSystemDesignSerif` */ - serif: 'ui-serif', - /** iOS `UIFontDescriptorSystemDesignRounded` */ - rounded: 'ui-rounded', - /** iOS `UIFontDescriptorSystemDesignMonospaced` */ - mono: 'ui-monospace', + sans: undefined, + serif: undefined, + rounded: undefined, + mono: "Menlo", + }, + android: { + sans: "sans-serif", + serif: "serif", + rounded: "sans-serif", + mono: "monospace", }, default: { - sans: 'normal', - serif: 'serif', - rounded: 'normal', - mono: 'monospace', + sans: "sans-serif", + serif: "serif", + rounded: "sans-serif", + mono: "monospace", }, web: { sans: "system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif", diff --git a/docs/AURA_APP_AUDIT.md b/docs/AURA_APP_AUDIT.md new file mode 100644 index 0000000..b61b2ac --- /dev/null +++ b/docs/AURA_APP_AUDIT.md @@ -0,0 +1,345 @@ +# AURA App Audit + +Audited on 2026-04-30 against the Expo Router app in `app/`, shared UI/data modules in `src/`, and Firebase Functions in `functions/src`. + +## 1. Executive Summary + +AURA has a strong product direction: dark fashion-app styling, a closet data model, Firebase-backed chat/history, ingestion, laundry, planner, saved looks, and a real AURA chat surface. The app is not just a mock. But it currently feels like a very ambitious prototype with several production-trust gaps: + +- The strongest surfaces are Home, Closet, AURA chat, Add Item, Laundry, and the real Studio tab. +- The weakest surfaces are Insights, Packing, some profile settings, and standalone `app/studio.tsx`, which read as placeholder tool shells. +- AURA can save looks, plan looks, create item drafts from images/product candidates, save outfit-photo worn logs, and learn from feedback. It cannot reliably perform many expected app actions by command, such as changing laundry state, editing closet records, planning arbitrary dates, changing preferences, generating reminders, or making product recommendations with purchasable links. +- TypeScript is currently failing for one route (`/(tabs)/laundry` in Home), so the app is not in a clean typed state. +- There are many dev logs and lint warnings. They are not fatal, but they make the codebase feel pre-production. +- The visual system is inconsistent. The premium glass language exists, but item details, profile subpages, tool shells, alerts, and some modal sheets use basic inline styles, system alerts, old white/gray constants, or route headers. + +The product can become much more premium without a rewrite by tightening navigation, replacing placeholder tools, making AURA actions explicit and reliable, and standardizing the design primitives. + +## 2. Top 10 Urgent Fixes + +1. Fix the TypeScript failure in `app/(tabs)/index.tsx`: `router.push("/(tabs)/laundry")` is not a valid typed route. Use `/laundry` or align the route registration. +2. Remove or hide UI-only tool surfaces: `app/packing.tsx`, `app/studio.tsx`, `app/insights/gaps.tsx`, and parts of `app/insights/index.tsx`/`unworn.tsx`. +3. Wire disabled Closet bulk actions or remove them: Edit Tags / Bulk Edit, Add to Collection, Plan to Calendar, Archive / Hide. +4. Add true AURA action integrations for laundry, closet edits, planner date selection, saved looks, and profile preferences instead of only sending follow-up prompts. +5. Standardize back/close behavior across hidden tab routes, top-level tool routes, modals, and item detail. Some routes use `router.back`, some replace to tabs, some use Stack headers, some custom buttons. +6. Replace production-facing `Alert.alert` feedback with premium AURA toast/sheet patterns for common success/error states. +7. Make profile settings honest: notifications, closet preferences, subscription, billing, and delete account disclose “not wired” in the UI or move to a future section. +8. Fix dark-mode readability in item detail editable fields, color warnings, and static style constants. +9. Add robust empty/error states for no internet, permission denied, image upload failure, failed ingestion, empty chat, empty planner, and empty closet. +10. Remove dev logging or guard it consistently behind `__DEV__`/debug flags across Add Item, Closet, Item Detail, AURA, image processing, and outfit saves. + +## 3. Screen-by-Screen Audit + +### Auth and Onboarding + +Files: `app/(auth)/*`, `src/components/auth/AuthScaffold.tsx`, `app/(onboarding)/index.tsx`, `app/_layout.tsx`. + +- Functional status: Partially working. +- Good: Auth gate is real, branded loading screen is polished, Google/Apple/email flows are implemented, onboarding saves `onboardingCompleted`. +- Problems: + - Auth errors use system alerts, not branded UI. + - Login injects web CSS by hand, which is brittle and indicates design-system gaps. + - Onboarding gathers useful fit/style/category data, but later surfaces only partially honor it. + - Permission/network failure states are generic. + - Users can be repeatedly revalidated through onboarding in `app/_layout.tsx`; this is sensible, but failures default to onboarding incomplete, which may trap users if profile loading fails. + +### Navigation and Tab Bar + +Files: `app/_layout.tsx`, `app/(tabs)/_layout.tsx`, `src/components/FloatingGlassTabBar.tsx`, `src/constants/dock.ts`, `src/components/SafeScreen.tsx`. + +- Functional status: Partially working. +- Good: Floating glass tab bar is premium, keyboard-hide is enabled, hidden routes keep source tab context for item details. +- Problems: + - Typed route mismatch for laundry breaks `npx tsc --noEmit`. + - `app/(tabs)/_layout.tsx` registers `laundry`, but the actual shown file list includes top-level `app/laundry.tsx` exporting `./(tabs)/laundry`; this route shape is confusing. + - There are duplicate tab-bar files: `components/FloatingGlassTabBar.tsx` re-exports `src/components/FloatingGlassTabBar.tsx`. + - Back/close patterns vary: text “Back”, icon close, Stack header back, `router.replace("/(tabs)")`, `router.back()`. + - Safe-area and dock padding are manually handled per screen; most are okay, but the approach is easy to regress and sometimes over-pads. + +### Home + +Files: `app/(tabs)/index.tsx`, `src/components/home/*`, `src/components/closet/MinimumClosetProgressCard.tsx`. + +- Functional status: Partially working. +- Good: Home has a clear “Today’s Look” direction, quick outcomes, minimum closet progress, recent items, latest chat, latest saved/AURA look, and smart tools. +- Problems: + - Home says “Style me now” and “3 directions”, but those actions route into chat instead of an immediate native planner/action flow. + - The smart tool grid includes tools of very uneven maturity. Studio and Laundry are real; Insights/Packing are mostly shells. + - `HOME_DEFERRED_FEATURES` is a non-rendered product note in app code. Useful for developers, but it signals unfinished product churn. + - Weather copy can be informative, but Celsius-only display in parts of the app may feel odd for US users despite onboarding units. + - The minimum closet card may duplicate the “Add pieces” message already present in Home empty states. + +### Closet + +Files: `app/(tabs)/closet.tsx`, `src/components/closet/*`, `src/lib/items.ts`. + +- Functional status: Partially working. +- Good: Live Firestore listener, search, filters, category/subcategory grouping, quick add, product link entry, processing section, selection mode, bulk delete/favorite/laundry/available/style/share/mark worn. +- Problems: + - Some “More” bulk actions are visibly disabled and UI-only: bulk edit, collections, calendar planning, archive/hide. + - Product link flow sends the URL to AURA chat instead of a direct import/review flow, although a backend `importProductLink` function exists. + - Quick add creates AURA drafts, but recovery UX depends on the processing section and stale timeout. Users need clearer “what happened” and retry explanations. + - No obvious duplicate handling in the UI. + - Filters do not honor `profile.closetPreferences.hideLaundryByDefault` or `defaultSort`. + - Grid cards are nice, but long names/brands are aggressively truncated and status text is raw-ish (`IN LAUNDRY`). + - Selection mode is useful but dense and could occlude top controls on small phones. + +### Item Detail + +Files: `app/(tabs)/item/[id].tsx`, `src/lib/itemImage.ts`, `src/lib/items.ts`. + +- Functional status: Partially working. +- Good: Live item snapshot, image carousel/modal, quick facts, AI details, color correction, pattern/material edits, mark worn/laundry/washed/delete. +- Problems: + - Visual language is noticeably less premium than Home/Closet. Many styles are old static constants (`#ddd`, `#fff`, bare text buttons). + - TextInputs do not consistently set `color`/`placeholderTextColor`, risking dark-mode readability. + - Uses system `Alert.alert` for overflow actions, delete, wash confirmation, and errors. + - “Reset to AI” refuses if ingestion is already done, so there is no clear way to re-run extraction after manual edits. + - Failed ingestion only says “Couldn't analyze, you can edit manually”; no retry action is shown here. + - Back behavior replaces source tabs when `sourceTab` exists, which may discard stack context. + +### AURA Chat + +Files: `app/(tabs)/ai.tsx`, `src/components/ai/*`, `src/components/aura/AuraLookCard.tsx`, `src/lib/aura.ts`, `src/lib/auraAttachments.ts`, `src/lib/aiChats.ts`, `functions/src/askAuraStream.ts`. + +- Functional status: Partially working. +- Good: Real chat threads, cache, drawer, streaming with fallback, text/image/audio attachment support, candidate item cards, outfit cards, saved looks, planning for today, feedback logging, image outfit analysis actions. +- Problems: + - Empty state is visually almost blank (``), so a new user does not know what AURA can do. + - `onSaveOutfit={() => {}}` is passed for legacy outfit messages; any old `OutfitMessage` save button may be dead. + - “Projects” in the drawer is coming soon. + - AURA actions are narrow. It can save/plan today/create drafts, but many expected app operations are only prompts. + - Product recommendation/shopping is a brief generator, not a real product discovery/affiliate/link flow. + - Structured multi-look generation has substantial frontend fallback/repair logic, including outerwear repair. That can make AURA appear confident while hiding backend limitations. + - Error feedback is a system message plus sometimes an alert; no retry button on failed assistant turns. + - Voice requires native support and may fail with a plain alert. + +### Calendar / Planner + +Files: `app/(tabs)/calendar.tsx`, `src/components/calendar/*`, `src/utils/dailyOutfits.ts`, `src/utils/outfitPlanning.ts`. + +- Functional status: Partially working. +- Good: Date rail, range status subscription, weather/events hooks, generated daily looks, plan/copy/clear/swap/mark worn, worn streak. +- Problems: + - Planner generation appears local/rule-based (`generateDailyPlan`) rather than AURA/server-backed. + - Planned outfits are stored under `users/{uid}/outfits/{dateKey}` and sync to Home, but AURA “plan for today” only targets today, not arbitrary dates. + - Mark worn checks laundry/max wears only for today. Past/future behavior is less strict. + - Swap options exclude laundry, but initial generated looks depend on `generateDailyPlan` correctness. + - Empty past day has a “Plan an outfit” CTA that only sets selected look id and may not obviously create a plan. + - Date picker dependency is optional; if unavailable, the sheet shows a dead-ish message. + - Some copy uses Celsius and generic weather labels, not user units. + +### Laundry + +Files: `app/(tabs)/laundry.tsx`, `app/laundry.tsx`, `src/lib/items.ts`. + +- Functional status: Mostly working. +- Good: Dedicated status buckets, single-item status updates, bulk move needs-wash to laundry, bulk mark laundry clean. +- Problems: + - Home route type is broken. + - Empty state says “Tell AURA what you washed,” but AURA does not have a wired laundry command integration. + - Back uses `router.replace("/(tabs)")`, which can feel abrupt. + - The three status cards may be tight on small devices; long labels are constrained to one line. + +### Profile / Settings + +Files: `app/(tabs)/profile.tsx`, `app/profile/*`, `src/profile/screens.tsx`, `src/lib/userProfile.ts`. + +- Functional status: Partially working. +- Good: Profile hub, account profile, avatar upload, default sizes, body/fit, style preferences, units, my looks, memory clearing, export/share. +- Problems: + - Notifications screen says “Saved now for later. Not yet wired into reminders.” It is UI-only. + - Closet preferences screen says “Saved now for later. Not yet wired into sorting/filtering.” It is UI-only. + - Subscription/billing are coming soon. + - Delete account explicitly does not delete backend closet data. This is a serious privacy/product gap. + - Profile subpages are functional but visually basic and alert-heavy. + - There are two style profile concepts: `UserProfilePreferences` and `StyleProfile`/learned memory. The mapping exists but is easy to desync. + +### Add Item / Ingestion + +Files: `app/(tabs)/add.tsx`, `src/addItem/*`, `src/lib/uploadImage.ts`, `src/lib/visualNormalization.ts`, `src/lib/cutoutNormalize.ts`, `functions/src/ingestItemFromPhotos.ts`, `functions/src/generateCleanedProductImages.ts`. + +- Functional status: Partially working. +- Good: Multi-step wizard, edit mode, photo step, draft sync, AI extraction, background removal/cutout, duplicate last item, profile default sizes. +- Problems: + - Care tags are explicitly “coming soon.” + - Lots of lifecycle complexity and hook dependency lint warnings in extraction; stale closures are a risk. + - Failure recovery is spread across Add Item and Closet Processing instead of one clear retry/edit/delete flow. + - Duplicate detection is not visible. + - Upload/background-removal failures mostly become alerts/toasts, not rich recovery states. + - The footer and keyboard padding are heavily manual; small-phone keyboard overlap needs device testing. + +### Studio + +Files: `app/(tabs)/studio.tsx`, `app/studio.tsx`. + +- Functional status: Partially working for tab Studio; UI-only for top-level Studio. +- Good: The tab Studio is a real manual outfit builder using closet items, AuraLookCard preview, save look, plan today, ask AURA. +- Problems: + - There is a separate top-level `app/studio.tsx` that is a placeholder shell. Home routes to `/studio`, not the tab route, so users may hit the wrong Studio. + - The tab Studio only plans for today. + - No drag/reorder/swipe affordances despite “Studio” implying a board/editor. + - Selection is tap-based only; no search/filter beyond category chips. + +### Insights and Packing + +Files: `app/insights/*`, `app/packing.tsx`, `src/components/tools/ToolScreenScaffold.tsx`. + +- Functional status: UI-only / not wired. +- Problems: + - Wardrobe gaps are hardcoded cards. + - Unworn says real rotation data will come later. + - Most worn and Color balance cards in Insights have no `onPress`. + - Packing fields are static cards and “Build capsule” has no action. + - These screens lower trust because Home presents them as live tools. + +## 4. Broken or Non-Functional Features + +- Broken: app TypeScript check fails in `app/(tabs)/index.tsx` at the laundry route. +- Broken/confusing: Home `Studio` opens top-level `/studio`, which is a placeholder, while the real builder is `/(tabs)/studio`. +- UI-only: Packing. +- UI-only: Insights gap cards and unworn preview. +- UI-only: Profile notifications. +- UI-only: Profile closet preferences. +- UI-only: Profile subscription/billing. +- UI-only: AURA drawer “Projects.” +- UI-only/disabled: Closet bulk edit, collection, plan to calendar, archive/hide. +- Missing privacy completion: Delete account does not delete backend user data. +- Partially wired: Product link import is routed through chat even though a backend function exists. +- Partially wired: AURA shopping/missing pieces produces a brief, not product recommendations. +- Partially wired: AURA planner actions only plan today. + +## 5. UI Inconsistencies + +- Card radius varies from 12 to 28+ across screens. Premium areas use large glass cards; settings/item detail use basic bordered rectangles. +- Accent colors vary: purple, cream, pink/lavender, white CTAs, red warnings, and older `#ddd`/`#fff` constants are mixed. +- System alerts are used for core product actions instead of branded sheets/toasts. +- Header patterns vary across tab screens, Stack screens, custom back rows, and modal sheets. +- Icons are mixed across Ionicons, MaterialCommunity-like names, text symbols (`⋯`, `←`, hearts/stars in My Looks), and custom AURA mark. +- Tool shell screens use basic cards and buttons that do not match the rich Home/AURA surfaces. +- Some button disabled states are just opacity; others have no explanation. +- Typography is inline and inconsistent, especially in profile/settings/item detail. +- Some screens use route headers (`Stack.Screen`) while the main app hides headers. +- Warm/cream board backgrounds appear in outfit previews, while the rest is dark glass; this can work, but the transitions need more intentional framing. + +## 6. Missing Empty, Loading, and Error States + +- AURA empty state should explain capabilities with 3-5 real action chips and attachment examples. +- AURA failed response should show Retry / Edit prompt / Report problem. +- Closet empty state should include direct photo, camera, link, and manual actions. +- Closet filtered empty state should show active filters and a clear-filters CTA. +- Add Item failed ingestion should show retry extraction, edit manually, remove draft. +- Product link failure should preserve URL and allow retry. +- Image upload failure should keep selected assets and offer retry/remove. +- Permission denied states should offer Settings deep link where possible. +- No internet/backend unavailable states are not consistently represented. +- Calendar no-closet state should not pretend to generate useful looks. +- Insights/Packing should either be hidden or show honest “not available yet” with a single route back to AURA. +- Missing item images need a more premium placeholder with category/color/brand context. + +## 7. AURA Action Integration Gaps + +AURA can currently: + +- Save a look. +- Plan a look for today. +- Like/dislike/more/less feedback. +- Create closet drafts from images/product candidates. +- Analyze an outfit photo and save it as worn today. +- Add detected outfit pieces as drafts. +- Route prompts from Home/Closet/Studio. + +AURA cannot yet reliably: + +- Move specific items to laundry or mark them clean by natural language. +- Edit closet item fields like color, category, size, brand, tags, image, or notes. +- Delete/archive closet items with confirmation. +- Plan outfits for arbitrary dates or trip ranges. +- Swap planner items directly from chat. +- Read/write notification preferences. +- Use closet preferences like hidden laundry/default sort. +- Create collections/capsules. +- Run real product recommendations with links, prices, sizes, and availability. +- Create reminders. +- Explain and execute account/privacy actions. + +## 8. Data and Model Issues + +- `ClothingStatus` and `LaundryStatus` coexist with legacy mapping. This works but increases bugs unless backfilled everywhere. +- Wear logic uses `MAX_WEARS_BEFORE_WASH = 2`, while `normalizeLaundryStatus` marks needs-wash at `>= 3`; this threshold mismatch should be reviewed. +- Planned outfits store category item IDs only; analyzed outfit photos store detected pieces under `wornOutfit` with empty `itemsByCategory`, which may break consumers expecting IDs. +- Product link backend exists (`importProductLink`) but frontend primarily starts a chat prompt. +- `UserProfilePreferences`, `StyleProfile`, learned memory, assistant memory, and AURA memory overlap. Save paths need a single source-of-truth map. +- Several profile settings are saved but not consumed. +- Generated planner looks are local and may not use the same outfit engine as AURA chat/swipe. +- Many image fields exist (`photoUrl`, `originalImageUrl`, `cleanedImageUrl`, `photos.*`, `images[]`), requiring normalization discipline. + +## 9. Performance Concerns + +- Heavy inline styles and large screens may re-render more than needed; many screens create object styles on every render. +- AURA chat message rendering can be expensive with image-heavy look cards and FlatList autoscroll. +- Add Item extraction hooks are complex and have hook dependency warnings. +- Closet renders category sections with many cards in ScrollView rather than virtualized section lists. +- Studio renders all filtered items in a wrapping View, not a virtualized grid. +- Calendar subscribes to a 121-day date range and all items; acceptable for small closets but should be watched. +- Many console logs can slow dev and pollute production diagnostics if not guarded. + +## 10. Suggested Premium UI Upgrades + +- Create one `AuraScreenHeader`, `AuraBottomSheet`, `AuraActionRow`, `AuraEmptyState`, `AuraErrorState`, and `AuraConfirmSheet`. +- Replace routine `Alert.alert` with branded sheets/toasts. +- Give AURA chat a real empty state: “Style me today,” “Add from photo,” “Plan Friday,” “Fix this outfit,” “What should I buy?” +- Turn disabled buttons into either hidden actions or “Coming later” tooltips/sheets. +- Add skeletons for Home, Closet cards, AURA look cards, Add Item extraction, and Profile looks. +- Add press/haptic consistency through `AuraPressable`. +- Use real icon buttons for back/close/more instead of text where possible. +- Standardize card radius and color tokens; remove old `#ddd`, `#fff`, `#d11` constants from dark screens. +- Make tool screens feel like real workspaces or hide them until wired. +- Add confidence/progress UI for ingestion so users trust the AI extraction. + +## 11. Prioritized Roadmap + +### Fix Today + +- Fix TypeScript route failure for laundry. +- Route Home Studio to the real builder or remove the placeholder top-level Studio. +- Hide or label UI-only Home tools. +- Remove visible disabled Closet bulk actions or wire one obvious action: Plan to Calendar. +- Add AURA empty state with real quick actions. +- Guard/remove dev logs in production-facing files. +- Fix Item Detail dark TextInput styles and warning colors. + +### Fix This Week + +- Build branded confirm/error sheets and replace high-volume alerts. +- Wire product link quick add directly to `importProductLink` or a dedicated review flow. +- Add retry/edit/remove recovery to failed ingestion in Item Detail and Closet Processing. +- Make AURA perform laundry updates and planner-date updates via explicit client-side tool/action handlers. +- Apply profile closet preferences to Closet default sort and hide-laundry behavior. +- Wire Insights Unworn and Gaps to real closet data. +- Add no-network/backend-error states for AURA, Closet, Add Item, and Calendar. + +### Later Polish + +- Virtualize Closet and Studio grids for large wardrobes. +- Consolidate design tokens and remove inline one-off styles. +- Add gesture affordances to Studio. +- Improve planner weather/units and arbitrary-date planning from AURA. +- Add duplicate detection and merge flow in ingestion. +- Add richer microinteractions, skeleton loading, and card reveal consistency. + +### Future Premium Features + +- AURA action framework with explicit permissions and confirmations. +- Trip packing capsule builder. +- Product recommendations with live links, sizes, prices, and wardrobe-gap rationale. +- Collections/capsules/lookbooks. +- Notification/reminder system for laundry, planned outfits, and underused pieces. +- Full privacy controls: complete account/data deletion, export files, consent audit. +- Personalized wardrobe health dashboard. + +## Verification Results + +- `npm run lint`: completed with 11 warnings, 0 errors. +- `npx tsc --noEmit`: failed with a typed route error in `app/(tabs)/index.tsx`. +- `cd functions && npm run build`: completed successfully. diff --git a/docs/AURA_APP_AUDIT_FIX_PLAN.md b/docs/AURA_APP_AUDIT_FIX_PLAN.md new file mode 100644 index 0000000..39d09c0 --- /dev/null +++ b/docs/AURA_APP_AUDIT_FIX_PLAN.md @@ -0,0 +1,170 @@ +# AURA App Audit Fix Plan + +This plan prioritizes narrow, production-readiness fixes before larger product work. + +## Suggested Order + +1. Restore clean verification. +2. Remove misleading placeholder routes/actions. +3. Standardize core navigation and empty/error states. +4. Wire the highest-value AURA actions. +5. Improve ingestion recovery and closet data quality. +6. Upgrade premium UI consistency. +7. Add performance and edge-case testing. + +## Implementation Plan + +| Priority | Fix | Likely files | Difficulty | Risk | +|---|---|---|---|---| +| 1 | Fix TypeScript route failure for Laundry. Replace `router.push("/(tabs)/laundry")` with the valid route or adjust route registration. | `app/(tabs)/index.tsx`, `app/(tabs)/_layout.tsx`, `app/laundry.tsx` | S | Low | +| 1 | Route Home Studio to real tab Studio or delete/rename placeholder `app/studio.tsx`. | `app/(tabs)/index.tsx`, `app/studio.tsx`, `app/(tabs)/studio.tsx` | S | Medium | +| 1 | Hide or clearly mark UI-only tools from Home. | `app/(tabs)/index.tsx`, `app/packing.tsx`, `app/insights/*`, `src/components/home/SmartToolsGrid.tsx` | S | Low | +| 1 | Remove disabled Closet bulk actions from “More” until wired. | `app/(tabs)/closet.tsx` | S | Low | +| 1 | Add real AURA chat empty state. | `app/(tabs)/ai.tsx`, `src/components/ai/ChatList.tsx`, new `src/components/ai/AuraEmptyState.tsx` | S | Low | +| 1 | Fix Item Detail dark input styles and old constants. | `app/(tabs)/item/[id].tsx` | S | Low | +| 1 | Guard or remove dev logging. | `app/(tabs)/ai.tsx`, `app/(tabs)/closet.tsx`, `app/(tabs)/item/[id].tsx`, `src/addItem/*`, `src/lib/*`, `src/utils/dailyOutfits.ts` | M | Low | +| 2 | Add branded confirmation/error sheets for common flows. | new `src/components/aura/AuraConfirmSheet.tsx`, `src/lib/toast.ts`, screens using `Alert.alert` | M | Medium | +| 2 | Wire Product Link quick add to a direct import/review path. | `app/(tabs)/closet.tsx`, `src/lib/auraAttachments.ts`, `src/lib/aura.ts`, `functions/src/importProductLink.ts`, `app/(tabs)/add.tsx` | M | Medium | +| 2 | Add failed-ingestion recovery actions in Item Detail. | `app/(tabs)/item/[id].tsx`, `src/lib/items.ts`, `src/addItem/hooks/useItemExtraction.ts` | M | Medium | +| 2 | Apply closet preferences to default filters/sort. | `app/(tabs)/closet.tsx`, `src/lib/userProfile.ts`, `src/types/UserProfilePreferences.ts` | S | Low | +| 2 | Wire AURA laundry actions with confirmation. | `app/(tabs)/ai.tsx`, `src/lib/items.ts`, `functions/src/shared/auraPrompt.ts`, `src/types/aura.ts` | M | Medium | +| 2 | Wire AURA arbitrary-date planner actions. | `app/(tabs)/ai.tsx`, `app/(tabs)/calendar.tsx`, `src/utils/dailyOutfits.ts`, `src/types/aura.ts` | M | Medium | +| 2 | Replace placeholder Insights with live unworn/gap data. | `app/insights/unworn.tsx`, `app/insights/gaps.tsx`, `functions/src/shared/detectWardrobeGaps.ts`, `src/lib/items.ts` | M | Medium | +| 3 | Virtualize large Closet and Studio grids. | `app/(tabs)/closet.tsx`, `src/components/closet/ClosetCategorySection.tsx`, `app/(tabs)/studio.tsx` | M/L | Medium | +| 3 | Consolidate route/header patterns. | `app/_layout.tsx`, `app/(tabs)/_layout.tsx`, tool screens, profile screens | M | Medium | +| 3 | Consolidate profile/style/memory save paths. | `src/profile/screens.tsx`, `src/lib/userProfile.ts`, `src/lib/auraMemory.ts`, `src/lib/assistantMemory.ts`, `functions/src/shared/loadAuraUserProfile.ts` | L | High | +| 3 | Add duplicate detection and merge flow. | `src/addItem/*`, `functions/src/ingestItemFromPhotos.ts`, `src/lib/items.ts` | L | Medium | +| 4 | Full privacy deletion. | `src/profile/screens.tsx`, new callable Function, Firestore/Storage cleanup rules | L | High | + +## Specific Fix Notes + +### 1. Clean Verification + +- Fix `app/(tabs)/index.tsx` line around the Laundry CTA. +- Re-run: + - `npm run lint` + - `npx tsc --noEmit` + - `cd functions && npm run build` +- Address lint warnings in: + - `src/addItem/hooks/useAddWizardState.ts` + - `src/addItem/hooks/useItemExtraction.ts` + - `src/components/AiInsightCard.tsx` + - `src/components/PhotoEditorSection.tsx` + - `src/components/closet/ClosetFilterSheet.tsx` + +### 2. Placeholder and Route Cleanup + +- Decide whether `Studio` is top-level or hidden tab route. Prefer one canonical route. +- Remove Home cards for Packing/Insights until live, or route them into AURA prompts instead of dead screens. +- For Insights: + - `Most worn` and `Color balance` need `onPress` or removal. + - `Wardrobe gaps` should use real closet data or AURA backend gap detection. + - `Unworn items` should query and render real items. + +### 3. AURA Action Framework + +Start with client-side deterministic actions before adding a broad tool system: + +- Laundry: + - Parse assistant action payload or use explicit buttons. + - Confirm item names. + - Call `updateLaundryStatus`. +- Planner: + - Let action include `dateKey`. + - Call `savePlannedRecord`. + - Show confirmation with “Open Calendar.” +- Closet edits: + - Start with safe fields: favorite, status, notes, size. + - Use confirmation sheet before destructive edits. +- Product links: + - Use direct import/review instead of chat-only prompt. + +Likely files: `app/(tabs)/ai.tsx`, `src/types/aura.ts`, `src/lib/aura.ts`, `functions/src/askAuraStream.ts`, `functions/src/shared/auraPrompt.ts`. + +### 4. Premium UI System + +Add shared primitives: + +- `src/components/aura/AuraScreenHeader.tsx` +- `src/components/aura/AuraBottomSheet.tsx` +- `src/components/aura/AuraConfirmSheet.tsx` +- `src/components/aura/AuraEmptyState.tsx` +- `src/components/aura/AuraErrorState.tsx` + +Then migrate: + +- Item Detail overflow/delete/wash. +- Add Item permission/upload failures. +- AURA retry errors. +- Closet quick add/product link modal. +- Profile save/delete/logout flows. + +### 5. Ingestion Recovery + +Add a single recovery model: + +- Processing: show progress and what AURA is doing. +- Failed: Retry AI, Edit manually, Remove draft. +- Needs review: Review now, Save as-is, Remove. + +Likely files: + +- `src/components/closet/ClosetProcessingSection.tsx` +- `app/(tabs)/item/[id].tsx` +- `src/addItem/hooks/useItemExtraction.ts` +- `src/addItem/hooks/useItemDraft.ts` +- `functions/src/ingestItemFromPhotos.ts` + +### 6. Data Cleanup + +- Align laundry thresholds between `MAX_WEARS_BEFORE_WASH` and `normalizeLaundryStatus`. +- Backfill `laundryStatus` for all items and reduce reliance on legacy `status`. +- Create one helper for item image source selection and use it everywhere. +- Document the canonical profile fields consumed by AURA. + +Likely files: + +- `src/lib/items.ts` +- `src/lib/itemImage.ts` +- `scripts/backfill-closet-consistency.mjs` +- `src/types/ClothingItem.ts` +- `src/types/UserProfilePreferences.ts` + +## Testing Checklist + +### Commands + +- `cd /Users/athfan/wardrobe-ai/closet` +- `npm run lint` +- `npx tsc --noEmit` +- `cd functions && npm run build` +- `npm run ios` or `npm run start` + +### Manual App Pass + +- New user: register, onboarding, empty closet Home, empty AURA chat. +- Auth: login, forgot password, Google/Apple if available, sign out. +- Home: all quick actions, smart tools, latest chat, latest look, minimum closet card. +- Closet: empty, many items, filters, search, selection mode, quick add, product link, processing failed/retry. +- Add Item: camera denied, photo denied, upload failure, extraction failure, edit existing item, duplicate last item. +- Item Detail: no image, long names, color correction, pattern/material edit, mark worn, send laundry, wash, delete. +- AURA: text prompt, image attach, product link, outfit photo, streaming error, retry, chat drawer actions. +- Calendar: plan today, copy plan, swap item, mark worn, laundry exclusion, past date, future date. +- Laundry: all tabs empty/full, single status changes, bulk changes. +- Profile: every subpage save, avatar upload denied, clear memory, export, logout, delete-account warning. +- Studio: empty closet, large closet, max item limits, one-piece constraints, save, plan, ask AURA. +- Insights/Packing: verify hidden or honest if not wired. + +### Device/Edge Cases + +- Small phone with keyboard open. +- Large phone. +- No internet/backend error. +- Permission denied for camera/photos/location/calendar/microphone. +- Missing item images. +- Long item/brand names. +- Duplicate-looking items. +- 100+ closet items. +- Empty closet. +- All items in laundry. +- User with profile preferences changed after onboarding. diff --git a/docs/AURA_DESIGN_SYSTEM.md b/docs/AURA_DESIGN_SYSTEM.md new file mode 100644 index 0000000..5aade96 --- /dev/null +++ b/docs/AURA_DESIGN_SYSTEM.md @@ -0,0 +1,90 @@ +# AURA Design System + +AURA should feel like a premium dark luxury fashion app: calm, editorial, restrained, and precise. Use hierarchy, spacing, and consistency before adding visual effects. + +## Tokens + +Use semantic tokens from `constants/theme.ts` and shared primitives from `src/components/ui/AuraPrimitives.tsx`. + +- Backgrounds: `colors.background` for screens. +- Normal cards: `colors.surface`. +- Important/elevated cards: `colors.surfaceElevated`. +- Quiet metadata areas: `colors.surfaceMuted`. +- Borders: `colors.border` by default, `colors.borderStrong` only when the surface needs more separation. +- Text: `colors.textPrimary`, `colors.textSecondary`, and `colors.textMuted`. +- Accent: `colors.accent` for the primary action or a single editorial highlight. +- Status: `colors.destructive` and `colors.success` only for real status. + +Spacing should use the 4 point scale: `4 / 8 / 12 / 16 / 20 / 24 / 32 / 40`. + +Radii should use `sm / md / lg / xl / xxl / full`. Avoid inventing one-off radii unless matching an image crop or external asset. + +## Primitives + +Prefer these for new or migrated UI: + +- `AuraScreen` for tab or subpage screen containers. +- `AuraText` for typography and text tones. +- `AuraButton` for labeled actions. +- `AuraIconButton` for icon-only actions. +- `AuraCard` for solid, elevated, muted, glass, inset, and sheet surfaces. +- `AuraDivider` for subtle separators. +- `AuraSectionHeader` for section title/subtitle/action patterns. +- `AuraSheetBackdrop` and `AuraSheetSurface` for modal/sheet surfaces when migration is low risk. +- `AuraSkeleton` for loading placeholders. + +## Button Hierarchy + +Use one primary action per card or section. + +- Primary: the action that advances the user. +- Secondary: useful but lower priority. +- Tertiary or ghost: navigation, filters, or optional actions. +- Destructive: delete, remove, cancel destructive state. + +Do not show disabled future actions. Hide them until they are useful. + +## Chips + +Chips are for real filters, compact selections, or essential metadata. Do not use chips as a catch-all layout device. + +- Keep chip rows short. +- Avoid chip-heavy empty states. +- Avoid using chips for every attribute when text grouping would read calmer. +- Metadata chips should use muted surfaces and medium weights, not bold pills. + +## Glass + +Glass is rare. Use it for: + +- The floating tab bar. +- The chat composer. +- Modal or sheet overlays. +- A hero moment that benefits from material depth. + +Use solid elevated surfaces for normal cards. Avoid stacked glass cards and nested rounded cards. + +## Typography + +Use `AuraText` variants: + +- `hero`: rare landing or major editorial moment. +- `title`: screen title. +- `heading`: large grouped content title. +- `section`: section title. +- `body`: normal copy. +- `bodyStrong`: emphasized body text. +- `caption`: supporting copy. +- `metadata`: compact labels. +- `button`: action labels. + +Most UI should use 400 to 600 weights. Reserve 700 for true hierarchy, not chips, captions, or every card title. + +## Migration Guidelines + +- Replace raw visual constants with semantic tokens when touching a component. +- Migrate text, buttons, cards, and dividers before changing layout. +- Preserve route names, data shapes, Firebase logic, and streaming behavior. +- Do not redesign `AuraLookCard` positioning as part of token cleanup. +- Do not convert a complex screen all at once. Move shared pieces first, then verify lint and typecheck. +- Prefer solid surfaces over glass for repeated cards. diff --git a/docs/LAUNCH_QA_CHECKLIST.md b/docs/LAUNCH_QA_CHECKLIST.md new file mode 100644 index 0000000..be308bd --- /dev/null +++ b/docs/LAUNCH_QA_CHECKLIST.md @@ -0,0 +1,66 @@ +# AURA Launch QA Checklist + +Use a fresh iOS simulator or TestFlight install when possible. Do not reuse a logged-in account unless the step says persistence. + +## 1. Fresh Register +- Start signed out and tap create account. +- Register with a new email and password. +- Expected: account is created, no raw Firebase error appears, and the app routes to onboarding. + +## 2. Complete Onboarding +- Complete every onboarding step with realistic style, size, and preference choices. +- Expected: onboarding saves once, routes to the app shell, and does not return after force quitting and reopening. + +## 3. Add Item Manually +- Open Add Item, choose manual add, enter category, name, color, size, and notes, then save. +- Expected: item appears in Closet with the entered fields and opens from item detail. + +## 4. Add Item From Photo +- Add a clear clothing photo from library or camera. +- Wait for upload, cutout/background removal, and AI extraction. +- Expected: progress state advances, no infinite spinner, item saves with image, category, color, and reviewable metadata. + +## 5. Add Item From Product Link +- Paste a normal product URL into the link flow. +- Confirm the preview and save the draft. +- Expected: preview shows product image/title when available, blocked stores show a friendly fallback, and saved item appears in Closet. + +## 6. Edit Item +- Open an existing item, tap edit, change name/color/size/notes, and save. +- Expected: item detail reflects edits, user-edited fields are not overwritten by ingestion. + +## 7. Delete Item +- Delete a user-uploaded item from item detail, then try bulk deleting another item from Closet selection. +- Expected: item document disappears, owned Storage images are deleted when possible, external product source URLs are not deleted, and cleanup failure shows a friendly notice. + +## 8. Ask AURA Normal Chat +- Ask a simple style question with no attachments. +- Expected: response streams smoothly, stop button works during generation, message order stays correct, and refresh/reopen keeps the chat. + +## 9. Ask AURA For Outfit +- Ask AURA to build an outfit from the closet. +- Expected: response includes usable outfit cards with owned item references, no duplicate/blank cards, and saved feedback actions work. + +## 10. Save And Plan Outfit +- Save a look from AURA, open it from My Looks, then plan it on the calendar. +- Expected: saved look persists, planning creates a calendar entry, and item images render. + +## 11. Calendar Flow +- Create, edit, and remove a planned outfit for today and another date. +- Expected: dates remain correct after app restart, empty states are clear, and planned looks are not hidden by the floating dock. + +## 12. Profile Update +- Update name/avatar where available, change profile preferences, export profile JSON, and clear AURA memory. +- Expected: updates persist, export sheet opens without logging data, and clear memory does not delete closet items. + +## 13. Logout And Login +- Log out, log back in with the same account, then force quit and reopen. +- Expected: existing onboarded users route to the app shell, not onboarding, even if cached profile data loads first. + +## 14. Bad Network And Error Behavior +- Disable network during profile load, AURA chat, add-photo upload, and product-link import. +- Expected: no onboarding reset, no infinite spinner, friendly retry/error states appear, and local cached Closet/Profile data remains readable when available. + +## 15. Rate Limit Test +- Rapidly trigger AURA chat, product-link preview/import, image ingestion, outfit generation, voice transcription, and account delete attempts. +- Expected: each expensive flow eventually returns "Too many requests. Try again in a minute." without exposing internal limiter paths or raw IDs. diff --git a/docs/outfit-planning-architecture.md b/docs/outfit-planning-architecture.md new file mode 100644 index 0000000..08b9fd9 --- /dev/null +++ b/docs/outfit-planning-architecture.md @@ -0,0 +1,89 @@ +# Outfit Planning Architecture + +This note documents the current planner boundaries. It exists because AURA has +multiple outfit-generation paths that should not be consolidated casually. + +## Active systems + +- `src/utils/dailyOutfits.ts` is the canonical daily outfit persistence layer. + `PlannedOutfit` from this file is the saved daily plan shape used by Calendar, + Today, Home, AURA "Plan Today", Studio, and saved-look planning. +- `src/utils/outfitPlanning.ts` is the active Calendar suggestion engine. It + creates `PlannedLook` options for a selected date, then Calendar adapts the + selected look into `dailyOutfits.PlannedOutfit` before saving. +- `src/lib/auraActions.ts` handles shared AURA look actions. For + `planForToday`, it converts an AURA look with `auraLookToPlannedOutfit` and + saves through `savePlannedRecord`. +- `functions/src/shared/outfitEngine.ts` is the server outfit engine used by + `generateAuraSwipeBatch`, `generateOutfitsV1`, and `outfitChatV1`. +- `functions/src/askAura.ts` and `functions/src/askAuraStream.ts` do not call + the server outfit engine. They build AURA context from wardrobe/profile/memory + and ask the model to return `look` or `lookOptions`. +- `src/lib/outfitGenerator.ts` is still active as the client fallback for AURA + Swipe through `src/lib/auraSwipe.ts`. + +## Legacy candidates + +- `src/utils/outfitPlanner.ts` and `src/hooks/usePlannedOutfit.ts` are legacy + planner paths. The current Calendar/Today flows do not use them. +- `src/lib/outfits.ts` is a disabled legacy writer and should stay disabled. +- Calendar component files under `src/components/calendar/` include several + compatibility re-exports. Do not delete them without an import sweep. +- `functions/src/outfitChatV1.ts` is exported and functional, but current app + callers were not found in the client usage sweep. Treat it as compatibility + until production callable usage is confirmed. + +## Flow map + +### Calendar saves a plan + +1. `app/(tabs)/calendar.tsx` listens to wardrobe items and date-key outfit docs. +2. Calendar calls `generateDailyPlan` from `src/utils/outfitPlanning.ts`. +3. Calendar converts the selected `PlannedLook` into + `dailyOutfits.PlannedOutfit`. +4. Calendar saves with `setPlanned`, which delegates to `savePlannedRecord` and + `savePlannedOutfit`. + +### Today tab reads a plan + +1. `app/(tabs)/today.tsx` subscribes with `subscribeOutfitByDate`. +2. It renders `record.plannedOutfit` if one exists. +3. It does not generate or save outfits. + +### AURA Plan Today + +1. AURA look UI emits `planForToday`. +2. `handleSharedAuraLookAction` in `src/lib/auraActions.ts` handles the action. +3. `auraLookToPlannedOutfit` converts closet pieces into the daily plan shape. +4. `savePlannedRecord(uid, new Date(), plannedOutfit)` writes the date-key doc. + +### AURA generates a recommendation + +1. Client calls `askAuraStream` first, with callable `askAura` as fallback. +2. Functions load wardrobe items, profile, memory, weather, and occasion. +3. `buildAuraContext` prepares compact context for the prompt. +4. The model returns structured `look` or `lookOptions`. +5. Nothing is saved to the daily planner until the user taps Plan Today. + +## Shared collection warning + +`users/{uid}/outfits` currently stores two different kinds of documents: + +- Date-key daily docs such as `2026-05-02`, with `dateKey`, `plannedOutfit`, + and/or `wornOutfit`. +- Generated candidate docs from server outfit functions, with generated IDs and + fields like `picks`, `itemIds`, `planned`, `score`, `reason`, and `version`. + +Calendar and Today should only treat documents with `plannedOutfit` or +`wornOutfit` as daily records. Do not replace daily plan payloads with +server-candidate `picks`-only payloads without a schema migration. + +## Adapter checks to add when tests exist + +- `lookToItems` maps a `PlannedLook` slot id object to the expected item slots. +- Calendar's local `lookToPlanned` adapter produces `dailyOutfits.PlannedOutfit` + with `itemsByCategory`, `score`, `reasons`, and `createdAt`. +- `auraLookToPlannedOutfit` keeps only closet-sourced AURA pieces and maps them + to `outerwear`, `top`, `bottom`, and `shoes`. +- `savePlannedRecord` writes date-key daily docs containing `dateKey` and + `plannedOutfit`, not server candidate docs containing only `picks`. diff --git a/eas.json b/eas.json new file mode 100644 index 0000000..09e5a19 --- /dev/null +++ b/eas.json @@ -0,0 +1,29 @@ +{ + "cli": { + "version": ">= 15.0.0", + "appVersionSource": "local" + }, + "build": { + "development": { + "developmentClient": true, + "distribution": "internal" + }, + "preview": { + "credentialsSource": "local", + "distribution": "store", + "environment": "production", + "ios": { + "resourceClass": "m-medium" + } + }, + "production": { + "autoIncrement": true, + "ios": { + "resourceClass": "m-medium" + } + } + }, + "submit": { + "production": {} + } +} diff --git a/eslint.config.js b/eslint.config.js index 5025da6..4a80273 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -3,8 +3,8 @@ const { defineConfig } = require('eslint/config'); const expoConfig = require('eslint-config-expo/flat'); module.exports = defineConfig([ - expoConfig, { - ignores: ['dist/*'], + ignores: ['dist/**', '.expo/**', '.claude/**', 'functions/lib/**'], }, + expoConfig, ]); diff --git a/firestore.rules b/firestore.rules index eb38aad..05d581e 100644 --- a/firestore.rules +++ b/firestore.rules @@ -14,6 +14,10 @@ service cloud.firestore { return status in ["AVAILABLE", "WORN", "IN_LAUNDRY"]; } + function validLaundryStatus(status) { + return status in ["clean", "needs_wash", "in_laundry"]; + } + function validCategory(category) { return category in [ "top", @@ -35,7 +39,7 @@ service cloud.firestore { } function validDraftState(state) { - return state in ["draft", "photo_uploaded", "ingesting", "ready", "failed"]; + return state in ["draft", "awaiting_confirmation", "photo_uploaded", "ingesting", "ready", "failed", "cancelled"]; } function validOptionalString(value) { @@ -54,6 +58,18 @@ service cloud.firestore { return value == null || value is map; } + function validUserRootDoc(data, uid) { + return data is map + && data.size() <= 80 + && noForeignUidField(data, uid) + && (!("email" in data) || validOptionalString(data.email)) + && (!("displayName" in data) || validOptionalString(data.displayName)) + && (!("name" in data) || validOptionalString(data.name)) + && (!("photoURL" in data) || validOptionalString(data.photoURL)) + && (!("profilePreferences" in data) || data.profilePreferences is map) + && (!("updatedAt" in data) || data.updatedAt is number || data.updatedAt is timestamp); + } + function validCrop(value) { return value == null || ( value is map @@ -95,6 +111,9 @@ service cloud.firestore { let changed = data.diff(resource.data).affectedKeys(); return data is map && (!changed.hasAny(["status"]) || validStatus(data.status)) + && (!changed.hasAny(["laundryStatus"]) || validLaundryStatus(data.laundryStatus)) + && (!changed.hasAny(["laundryUpdatedAt"]) || data.laundryUpdatedAt is timestamp || data.laundryUpdatedAt is number) + && (!changed.hasAny(["lastWornAt"]) || data.lastWornAt is timestamp || data.lastWornAt is number || data.lastWornAt == null) && (!changed.hasAny(["category"]) || validCategory(data.category)) && (!changed.hasAny(["subCategory"]) || validOptionalString(data.subCategory)) && (!changed.hasAny(["brand"]) || validOptionalString(data.brand)) @@ -119,9 +138,9 @@ service cloud.firestore { && (!changed.hasAny(["size"]) || validOptionalString(data.size)) && (!changed.hasAny(["notes"]) || validOptionalString(data.notes)) && (!changed.hasAny(["careTags"]) || data.careTags == null || data.careTags is list) - && (!changed.hasAny(["lastWashedAt"]) || validOptionalNumber(data.lastWashedAt)) - && (!changed.hasAny(["lastWornDate"]) || validOptionalString(data.lastWornDate)) - && (!changed.hasAny(["lastWashedDate"]) || validOptionalString(data.lastWashedDate)) + && (!changed.hasAny(["lastWashedAt"]) || data.lastWashedAt is timestamp || data.lastWashedAt is number || data.lastWashedAt == null) + && (!changed.hasAny(["lastWornDate"]) || data.lastWornDate is timestamp || data.lastWornDate is number || data.lastWornDate is string || data.lastWornDate == null) + && (!changed.hasAny(["lastWashedDate"]) || data.lastWashedDate is timestamp || data.lastWashedDate is number || data.lastWashedDate is string || data.lastWashedDate == null) && (!changed.hasAny(["pattern"]) || validOptionalString(data.pattern)) && (!changed.hasAny(["material"]) || validOptionalString(data.material)) && (!changed.hasAny(["occasionTags"]) || data.occasionTags == null || data.occasionTags is list) @@ -189,21 +208,107 @@ service cloud.firestore { && (!("lastMessagePreview" in data) || validOptionalString(data.lastMessagePreview)) && (!("messageCount" in data) || data.messageCount is number) && (!("threadId" in data) || validOptionalString(data.threadId)) + && (!("pinned" in data) || data.pinned is bool) + && (!("archived" in data) || data.archived is bool) + && (!("titleEdited" in data) || data.titleEdited is bool) && noForeignUidField(data, uid); } function validAiChatMessageDoc(data, uid) { return data is map + && data.size() <= 50 && data.keys().hasAll(["type", "createdAt"]) && data.type in ["user", "assistant", "outfit", "system/action"] && data.createdAt is number - && (!("text" in data) || validOptionalString(data.text)) - && (!("outfits" in data) || data.outfits is list) + && (!("kind" in data) || validOptionalString(data.kind)) + && (!("text" in data) || data.text == null || (data.text is string && data.text.size() <= 12000)) + && (!("attachments" in data) || (data.attachments is list && data.attachments.size() <= 12)) + && (!("streaming" in data) || data.streaming is bool) + && (!("outfits" in data) || (data.outfits is list && data.outfits.size() <= 12)) + && (!("aura" in data) || data.aura is map) + && noForeignUidField(data, uid); + } + + function validOwnedSmallDoc(data, uid) { + return data is map + && data.size() <= 80 + && noForeignUidField(data, uid) + && (!("createdAt" in data) || data.createdAt is number || data.createdAt is timestamp) + && (!("updatedAt" in data) || data.updatedAt is number || data.updatedAt is timestamp); + } + + function validCommerceEventDoc(data, uid) { + return data is map + && data.size() <= 40 + && noForeignUidField(data, uid) + && (!("userId" in data) || data.userId == uid) + && (!("type" in data) || data.type is string) + && (!("eventName" in data) || data.eventName is string) + && (!("suggestionId" in data) || validOptionalString(data.suggestionId)) + && (!("itemType" in data) || validOptionalString(data.itemType)) + && (!("category" in data) || validOptionalString(data.category)) + && (!("sourceScreen" in data) || validOptionalString(data.sourceScreen)) + && (!("merchant" in data) || validOptionalString(data.merchant)) + && (!("timestamp" in data) || data.timestamp is number) + && (!("createdAt" in data) || data.createdAt is number || data.createdAt is timestamp); + } + + function validLaunchEventDoc(data, uid) { + return data is map + && data.size() <= 20 + && noForeignUidField(data, uid) + && (!("userId" in data) || data.userId == uid) + && (!("type" in data) || data.type is string) + && (!("eventName" in data) || data.eventName is string) + && (!("sessionId" in data) || validOptionalString(data.sessionId)) + && (!("platform" in data) || validOptionalString(data.platform)) + && (!("appVersion" in data) || validOptionalString(data.appVersion)) + && (!("buildNumber" in data) || data.buildNumber == null || data.buildNumber is string || data.buildNumber is number) + && (!("timestamp" in data) || data.timestamp is number) + && (!("properties" in data) || data.properties is map) + && (!("createdAt" in data) || data.createdAt is number || data.createdAt is timestamp); + } + + function validStyleEventDoc(data, uid) { + return data is map + && data.size() <= 50 + && data.keys().hasAll(["type", "createdAt"]) + && data.type is string + && (data.createdAt is number || data.createdAt is timestamp) + && noForeignUidField(data, uid); + } + + function validSavedLookDoc(data, uid) { + return data is map + && data.size() <= 50 + && data.keys().hasOnly([ + "id", + "pieces", + "vibe", + "title", + "createdAt", + "updatedAt", + "source", + "sessionId", + "itemIds", + "stylingNote", + "previewImageUrls", + "outfitSnapshot", + "look", + "savedLookEventWritten", + "feedbackProcessed" + ]) && noForeignUidField(data, uid); } + match /{document=**} { + allow read, write: if false; + } + match /users/{uid} { - allow read, write: if isOwner(uid); + allow read: if isOwner(uid); + allow create, update: if isOwner(uid) && validUserRootDoc(request.resource.data, uid); + allow delete: if false; match /items/{itemId} { allow read: if isOwner(uid); @@ -232,13 +337,22 @@ service cloud.firestore { } match /ai/{docId} { - allow read, write: if isOwner(uid); + allow read: if isOwner(uid); + allow create, update: if isOwner(uid) && validOwnedSmallDoc(request.resource.data, uid); + allow delete: if isOwner(uid); } match /assistantProfile/{docId} { allow read, write: if isOwner(uid) && (docId == "main" || docId == "behavior"); } + match /profile/{docId} { + allow read, write: if isOwner(uid) && ( + docId == "styleProfile" || + docId == "learnedStyleMemory" + ); + } + match /aiChats/{chatId} { allow read: if isOwner(uid); allow create, update: if isOwner(uid) && validAiChatDoc(request.resource.data, uid); @@ -249,6 +363,52 @@ service cloud.firestore { allow create, update: if isOwner(uid) && validAiChatMessageDoc(request.resource.data, uid); allow delete: if isOwner(uid); } + + match /sessionContext/{docId} { + allow read, write: if isOwner(uid) && docId == "current"; + } + } + + match /styleEvents/{eventId} { + allow read: if isOwner(uid); + allow create, update: if isOwner(uid) && validStyleEventDoc(request.resource.data, uid); + allow delete: if isOwner(uid); + } + + match /savedLooks/{lookId} { + allow read: if isOwner(uid); + allow create, update: if isOwner(uid) && validSavedLookDoc(request.resource.data, uid); + allow delete: if isOwner(uid); + } + + match /auraLooks/{lookId} { + allow read: if isOwner(uid); + allow create, update: if isOwner(uid) && validOwnedSmallDoc(request.resource.data, uid); + allow delete: if isOwner(uid); + } + + match /savedOutfits/{lookId} { + allow read: if isOwner(uid); + allow create, update: if isOwner(uid) && validOwnedSmallDoc(request.resource.data, uid); + allow delete: if isOwner(uid); + } + + match /outfitFeedback/{feedbackId} { + allow read: if isOwner(uid); + allow create, update: if isOwner(uid) && validOwnedSmallDoc(request.resource.data, uid); + allow delete: if isOwner(uid); + } + + match /commerceEvents/{eventId} { + allow read: if isOwner(uid); + allow create: if isOwner(uid) && validCommerceEventDoc(request.resource.data, uid); + allow update, delete: if false; + } + + match /launchEvents/{eventId} { + allow read: if isOwner(uid); + allow create: if isOwner(uid) && validLaunchEventDoc(request.resource.data, uid); + allow update, delete: if false; } } diff --git a/functions/package-lock.json b/functions/package-lock.json index 53e132a..4cd7180 100644 --- a/functions/package-lock.json +++ b/functions/package-lock.json @@ -6,6 +6,8 @@ "": { "name": "functions", "dependencies": { + "@imgly/background-removal-node": "^1.4.5", + "cheerio": "^1.2.0", "firebase-admin": "^13.6.0", "firebase-functions": "^7.0.0", "onnxruntime-node": "^1.24.2", @@ -22,7 +24,7 @@ "typescript": "^5.7.3" }, "engines": { - "node": "24" + "node": "22" } }, "node_modules/@babel/code-frame": { @@ -1426,6 +1428,88 @@ "url": "https://opencollective.com/libvips" } }, + "node_modules/@imgly/background-removal-node": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/@imgly/background-removal-node/-/background-removal-node-1.4.5.tgz", + "integrity": "sha512-/s9K88qhKy1jPhrSkBxurUqCVqJ8KHWCc+5yWdppdC4fuSrGC8mK8WQtmULs2ASEr8naY1qpvZu0EL5jr2Hqtg==", + "license": "SEE LICENSE IN LICENSE.md", + "dependencies": { + "@types/lodash": "~4.14.195", + "@types/ndarray": "~1.0.14", + "@types/node": "~20.3.1", + "lodash": "~4.17.21", + "ndarray": "~1.0.19", + "onnxruntime-node": "~1.17.0", + "sharp": "~0.32.4", + "zod": "~3.21.4" + } + }, + "node_modules/@imgly/background-removal-node/node_modules/@types/lodash": { + "version": "4.14.202", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.202.tgz", + "integrity": "sha512-OvlIYQK9tNneDlS0VN54LLd5uiPCBOp7gS5Z0f1mjoJYBrtStzgmJBxONW3U6OZqdtNzZPmn9BS/7WI7BFFcFQ==", + "license": "MIT" + }, + "node_modules/@imgly/background-removal-node/node_modules/@types/node": { + "version": "20.3.3", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.3.3.tgz", + "integrity": "sha512-wheIYdr4NYML61AjC8MKj/2jrR/kDQri/CIpVoZwldwhnIrD/j9jIU5bJ8yBKuB2VhpFV7Ab6G2XkBjv9r9Zzw==", + "license": "MIT" + }, + "node_modules/@imgly/background-removal-node/node_modules/onnxruntime-common": { + "version": "1.17.3", + "resolved": "https://registry.npmjs.org/onnxruntime-common/-/onnxruntime-common-1.17.3.tgz", + "integrity": "sha512-IkbaDelNVX8cBfHFgsNADRIq2TlXMFWW+nG55mwWvQT4i0NZb32Jf35Pf6h9yjrnK78RjcnlNYaI37w394ovMw==", + "license": "MIT" + }, + "node_modules/@imgly/background-removal-node/node_modules/onnxruntime-node": { + "version": "1.17.3", + "resolved": "https://registry.npmjs.org/onnxruntime-node/-/onnxruntime-node-1.17.3.tgz", + "integrity": "sha512-NtbN1pfApTSEjVq46LrJ396aPP2Gjhy+oYZi5Bu1leDXAEvVap/BQ8CZELiLs7z0UnXy3xjJW23HiB4P3//FIw==", + "hasInstallScript": true, + "license": "MIT", + "os": [ + "win32", + "darwin", + "linux" + ], + "dependencies": { + "onnxruntime-common": "1.17.3", + "tar": "^7.0.1" + } + }, + "node_modules/@imgly/background-removal-node/node_modules/sharp": { + "version": "0.32.6", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.32.6.tgz", + "integrity": "sha512-KyLTWwgcR9Oe4d9HwCwNM2l7+J0dUQwn/yf7S0EnTtb0eVS4RxO0eUSvxPtzT4F3SY+C4K6fqdv/DO27sJ/v/w==", + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "color": "^4.2.3", + "detect-libc": "^2.0.2", + "node-addon-api": "^6.1.0", + "prebuild-install": "^7.1.1", + "semver": "^7.5.4", + "simple-get": "^4.0.1", + "tar-fs": "^3.0.4", + "tunnel-agent": "^0.6.0" + }, + "engines": { + "node": ">=14.15.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@imgly/background-removal-node/node_modules/zod": { + "version": "3.21.4", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.21.4.tgz", + "integrity": "sha512-m46AKbrzKVzOzs/DZgVnG5H55N1sv1M8qZU3A8RIKbs3mrACDNeIOeilDymVb2HdmP8uwshOCF4uJ8uM9rCqJw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, "node_modules/@isaacs/cliui": { "version": "8.0.2", "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", @@ -1476,6 +1560,18 @@ "url": "https://github.com/chalk/strip-ansi?sponsor=1" } }, + "node_modules/@isaacs/fs-minipass": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", + "integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==", + "license": "ISC", + "dependencies": { + "minipass": "^7.0.4" + }, + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/@istanbuljs/load-nyc-config": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz", @@ -2042,6 +2138,19 @@ "@tybys/wasm-util": "^0.10.0" } }, + "node_modules/@nodable/entities": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@nodable/entities/-/entities-2.1.0.tgz", + "integrity": "sha512-nyT7T3nbMyBI/lvr6L5TyWbFJAI9FTgVRakNoBqCD+PmID8DzFrrNdLLtHMwMszOtqZa8PAOV24ZqDnQrhQINA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/nodable" + } + ], + "license": "MIT", + "optional": true + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -2433,6 +2542,12 @@ "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", "license": "MIT" }, + "node_modules/@types/ndarray": { + "version": "1.0.14", + "resolved": "https://registry.npmjs.org/@types/ndarray/-/ndarray-1.0.14.tgz", + "integrity": "sha512-oANmFZMnFQvb219SSBIhI1Ih/r4CvHDOzkWyJS/XRqkMrGH5/kaPSA1hQhdIBzouaE+5KpE/f5ylI9cujmckQg==", + "license": "MIT" + }, "node_modules/@types/node": { "version": "22.19.11", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.11.tgz", @@ -3383,6 +3498,20 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/b4a": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.8.0.tgz", + "integrity": "sha512-qRuSmNSkGQaHwNbM7J78Wwy+ghLEYF1zNrSeMxj4Kgw6y33O3mXcQ6Ie9fRvfU/YnxWkOchPXbaLb73TkIsfdg==", + "license": "Apache-2.0", + "peerDependencies": { + "react-native-b4a": "*" + }, + "peerDependenciesMeta": { + "react-native-b4a": { + "optional": true + } + } + }, "node_modules/babel-jest": { "version": "30.2.0", "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-30.2.0.tgz", @@ -3494,6 +3623,97 @@ "dev": true, "license": "MIT" }, + "node_modules/bare-events": { + "version": "2.8.2", + "resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.8.2.tgz", + "integrity": "sha512-riJjyv1/mHLIPX4RwiK+oW9/4c3TEUeORHKefKAKnZ5kyslbN+HXowtbaVEqt4IMUB7OXlfixcs6gsFeo/jhiQ==", + "license": "Apache-2.0", + "peerDependencies": { + "bare-abort-controller": "*" + }, + "peerDependenciesMeta": { + "bare-abort-controller": { + "optional": true + } + } + }, + "node_modules/bare-fs": { + "version": "4.7.1", + "resolved": "https://registry.npmjs.org/bare-fs/-/bare-fs-4.7.1.tgz", + "integrity": "sha512-WDRsyVN52eAx/lBamKD6uyw8H4228h/x0sGGGegOamM2cd7Pag88GfMQalobXI+HaEUxpCkbKQUDOQqt9wawRw==", + "license": "Apache-2.0", + "dependencies": { + "bare-events": "^2.5.4", + "bare-path": "^3.0.0", + "bare-stream": "^2.6.4", + "bare-url": "^2.2.2", + "fast-fifo": "^1.3.2" + }, + "engines": { + "bare": ">=1.16.0" + }, + "peerDependencies": { + "bare-buffer": "*" + }, + "peerDependenciesMeta": { + "bare-buffer": { + "optional": true + } + } + }, + "node_modules/bare-os": { + "version": "3.9.0", + "resolved": "https://registry.npmjs.org/bare-os/-/bare-os-3.9.0.tgz", + "integrity": "sha512-JTjuZyNIDpw+GytMO4a6TK1VXdVKKJr6DRxEHasyuYyShV2deuiHJK/ahGZlebc+SG0/wJCB9XK8gprBGDFi/Q==", + "license": "Apache-2.0", + "engines": { + "bare": ">=1.14.0" + } + }, + "node_modules/bare-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/bare-path/-/bare-path-3.0.0.tgz", + "integrity": "sha512-tyfW2cQcB5NN8Saijrhqn0Zh7AnFNsnczRcuWODH0eYAXBsJ5gVxAUuNr7tsHSC6IZ77cA0SitzT+s47kot8Mw==", + "license": "Apache-2.0", + "dependencies": { + "bare-os": "^3.0.1" + } + }, + "node_modules/bare-stream": { + "version": "2.13.0", + "resolved": "https://registry.npmjs.org/bare-stream/-/bare-stream-2.13.0.tgz", + "integrity": "sha512-3zAJRZMDFGjdn+RVnNpF9kuELw+0Fl3lpndM4NcEOhb9zwtSo/deETfuIwMSE5BXanA0FrN1qVjffGwAg2Y7EA==", + "license": "Apache-2.0", + "dependencies": { + "streamx": "^2.25.0", + "teex": "^1.0.1" + }, + "peerDependencies": { + "bare-abort-controller": "*", + "bare-buffer": "*", + "bare-events": "*" + }, + "peerDependenciesMeta": { + "bare-abort-controller": { + "optional": true + }, + "bare-buffer": { + "optional": true + }, + "bare-events": { + "optional": true + } + } + }, + "node_modules/bare-url": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/bare-url/-/bare-url-2.4.2.tgz", + "integrity": "sha512-/9a2j4ac6ckpmAHvod/ob7x439OAHst/drc2Clnq+reRYd/ovddwcF4LfoxHyNk5AuGBnPg+HqFjmE/Zpq6v0A==", + "license": "Apache-2.0", + "dependencies": { + "bare-path": "^3.0.0" + } + }, "node_modules/base64-js": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", @@ -3537,6 +3757,17 @@ "node": "*" } }, + "node_modules/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "license": "MIT", + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, "node_modules/body-parser": { "version": "1.20.4", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz", @@ -3576,6 +3807,12 @@ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", "license": "MIT" }, + "node_modules/boolbase": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", + "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==", + "license": "ISC" + }, "node_modules/boolean": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/boolean/-/boolean-3.2.0.tgz", @@ -3584,9 +3821,9 @@ "license": "MIT" }, "node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", + "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", "dev": true, "license": "MIT", "dependencies": { @@ -3653,6 +3890,30 @@ "node-int64": "^0.4.0" } }, + "node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, "node_modules/buffer-equal-constant-time": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", @@ -3795,6 +4056,57 @@ "node": ">=10" } }, + "node_modules/cheerio": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/cheerio/-/cheerio-1.2.0.tgz", + "integrity": "sha512-WDrybc/gKFpTYQutKIK6UvfcuxijIZfMfXaYm8NMsPQxSYvf+13fXUJ4rztGGbJcBQ/GF55gvrZ0Bc0bj/mqvg==", + "license": "MIT", + "dependencies": { + "cheerio-select": "^2.1.0", + "dom-serializer": "^2.0.0", + "domhandler": "^5.0.3", + "domutils": "^3.2.2", + "encoding-sniffer": "^0.2.1", + "htmlparser2": "^10.1.0", + "parse5": "^7.3.0", + "parse5-htmlparser2-tree-adapter": "^7.1.0", + "parse5-parser-stream": "^7.1.2", + "undici": "^7.19.0", + "whatwg-mimetype": "^4.0.0" + }, + "engines": { + "node": ">=20.18.1" + }, + "funding": { + "url": "https://github.com/cheeriojs/cheerio?sponsor=1" + } + }, + "node_modules/cheerio-select": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/cheerio-select/-/cheerio-select-2.1.0.tgz", + "integrity": "sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g==", + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0", + "css-select": "^5.1.0", + "css-what": "^6.1.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/chownr": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", + "integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, "node_modules/ci-info": { "version": "4.4.0", "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.4.0.tgz", @@ -3895,11 +4207,23 @@ "license": "MIT", "peer": true }, + "node_modules/color": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz", + "integrity": "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1", + "color-string": "^1.9.0" + }, + "engines": { + "node": ">=12.5.0" + } + }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "devOptional": true, "license": "MIT", "dependencies": { "color-name": "~1.1.4" @@ -3912,9 +4236,18 @@ "version": "1.1.4", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "devOptional": true, "license": "MIT" }, + "node_modules/color-string": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/color-string/-/color-string-1.9.1.tgz", + "integrity": "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==", + "license": "MIT", + "dependencies": { + "color-name": "^1.0.0", + "simple-swizzle": "^0.2.2" + } + }, "node_modules/combined-stream": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", @@ -4011,6 +4344,43 @@ "node": ">= 8" } }, + "node_modules/css-select": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz", + "integrity": "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==", + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0", + "css-what": "^6.1.0", + "domhandler": "^5.0.2", + "domutils": "^3.0.1", + "nth-check": "^2.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/css-what": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz", + "integrity": "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">= 6" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/data-uri-to-buffer": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", + "integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, "node_modules/data-view-buffer": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz", @@ -4082,6 +4452,21 @@ } } }, + "node_modules/decompress-response": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "license": "MIT", + "dependencies": { + "mimic-response": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/dedent": { "version": "1.7.1", "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.1.tgz", @@ -4098,6 +4483,15 @@ } } }, + "node_modules/deep-extend": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", + "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", + "license": "MIT", + "engines": { + "node": ">=4.0.0" + } + }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -4231,6 +4625,61 @@ "node": ">=6.0.0" } }, + "node_modules/dom-serializer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", + "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.2", + "entities": "^4.2.0" + }, + "funding": { + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + } + }, + "node_modules/domelementtype": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", + "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "BSD-2-Clause" + }, + "node_modules/domhandler": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", + "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", + "license": "BSD-2-Clause", + "dependencies": { + "domelementtype": "^2.3.0" + }, + "engines": { + "node": ">= 4" + }, + "funding": { + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, + "node_modules/domutils": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz", + "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==", + "license": "BSD-2-Clause", + "dependencies": { + "dom-serializer": "^2.0.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3" + }, + "funding": { + "url": "https://github.com/fb55/domutils?sponsor=1" + } + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -4320,16 +4769,52 @@ "node": ">= 0.8" } }, + "node_modules/encoding-sniffer": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/encoding-sniffer/-/encoding-sniffer-0.2.1.tgz", + "integrity": "sha512-5gvq20T6vfpekVtqrYQsSCFZ1wEg5+wW0/QaZMWkFr6BqD3NfKs0rLCx4rrVlSWJeZb5NBJgVLswK/w2MWU+Gw==", + "license": "MIT", + "dependencies": { + "iconv-lite": "^0.6.3", + "whatwg-encoding": "^3.1.1" + }, + "funding": { + "url": "https://github.com/fb55/encoding-sniffer?sponsor=1" + } + }, + "node_modules/encoding-sniffer/node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/end-of-stream": { "version": "1.4.5", "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", "license": "MIT", - "optional": true, "dependencies": { "once": "^1.4.0" } }, + "node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/error-ex": { "version": "1.3.4", "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", @@ -4880,6 +5365,15 @@ "node": ">=6" } }, + "node_modules/events-universal": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/events-universal/-/events-universal-1.0.1.tgz", + "integrity": "sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==", + "license": "Apache-2.0", + "dependencies": { + "bare-events": "^2.7.0" + } + }, "node_modules/execa": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", @@ -4924,6 +5418,15 @@ "node": ">= 0.8.0" } }, + "node_modules/expand-template": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", + "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", + "license": "(MIT OR WTFPL)", + "engines": { + "node": ">=6" + } + }, "node_modules/expect": { "version": "30.2.0", "resolved": "https://registry.npmjs.org/expect/-/expect-30.2.0.tgz", @@ -5025,6 +5528,12 @@ "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", "license": "MIT" }, + "node_modules/fast-fifo": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/fast-fifo/-/fast-fifo-1.3.2.tgz", + "integrity": "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==", + "license": "MIT" + }, "node_modules/fast-glob": { "version": "3.3.3", "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", @@ -5070,9 +5579,9 @@ "license": "MIT" }, "node_modules/fast-xml-builder": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.0.0.tgz", - "integrity": "sha512-fpZuDogrAgnyt9oDDz+5DBz0zgPdPZz6D4IR7iESxRXElrlGTRkHJ9eEt+SACRJwT0FNFrt71DFQIUFBJfX/uQ==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.1.5.tgz", + "integrity": "sha512-4TJn/8FKLeslLAH3dnohXqE3QSoxkhvaMzepOIZytwJXZO69Bfz0HBdDHzOTOon6G59Zrk6VQ2bEiv1t61rfkA==", "funding": [ { "type": "github", @@ -5080,12 +5589,15 @@ } ], "license": "MIT", - "optional": true + "optional": true, + "dependencies": { + "path-expression-matcher": "^1.1.3" + } }, "node_modules/fast-xml-parser": { - "version": "5.4.1", - "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.4.1.tgz", - "integrity": "sha512-BQ30U1mKkvXQXXkAGcuyUA/GA26oEB7NzOtsxCDtyu62sjGw5QraKFhx2Em3WQNjPw9PG6MQ9yuIIgkSDfGu5A==", + "version": "5.7.2", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.7.2.tgz", + "integrity": "sha512-P7oW7tLbYnhOLQk/Gv7cZgzgMPP/XN03K02/Jy6Y/NHzyIAIpxuZIM/YqAkfiXFPxA2CTm7NtCijK9EDu09u2w==", "funding": [ { "type": "github", @@ -5095,8 +5607,10 @@ "license": "MIT", "optional": true, "dependencies": { - "fast-xml-builder": "^1.0.0", - "strnum": "^2.1.2" + "@nodable/entities": "^2.1.0", + "fast-xml-builder": "^1.1.5", + "path-expression-matcher": "^1.5.0", + "strnum": "^2.2.3" }, "bin": { "fxparser": "src/cli/cli.js" @@ -5135,6 +5649,29 @@ "bser": "2.1.1" } }, + "node_modules/fetch-blob": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz", + "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "paypal", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "dependencies": { + "node-domexception": "^1.0.0", + "web-streams-polyfill": "^3.0.3" + }, + "engines": { + "node": "^12.20 || >= 14.13" + } + }, "node_modules/file-entry-cache": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz", @@ -5212,21 +5749,20 @@ } }, "node_modules/firebase-admin": { - "version": "13.6.1", - "resolved": "https://registry.npmjs.org/firebase-admin/-/firebase-admin-13.6.1.tgz", - "integrity": "sha512-Zgc6yPtmPxAZo+FoK6LMG6zpSEsoSK8ifIR+IqF4oWuC3uWZU40OjxgfLTSFcsRlj/k/wD66zNv2UiTRreCNSw==", + "version": "13.8.0", + "resolved": "https://registry.npmjs.org/firebase-admin/-/firebase-admin-13.8.0.tgz", + "integrity": "sha512-iawoQkmZbsA+2DY5UEuB8f6jSlskzzySoye0D2F6e3zlDZX9DUcXf0HhZqLUn/P6WhLGvTf6ZtCmshZvhAgTYg==", "license": "Apache-2.0", "dependencies": { "@fastify/busboy": "^3.0.0", "@firebase/database-compat": "^2.0.0", "@firebase/database-types": "^1.0.6", - "@types/node": "^22.8.7", "farmhash-modern": "^1.1.0", "fast-deep-equal": "^3.1.1", - "google-auth-library": "^9.14.2", + "google-auth-library": "^10.6.1", "jsonwebtoken": "^9.0.0", "jwks-rsa": "^3.1.0", - "node-forge": "^1.3.1", + "node-forge": "^1.4.0", "uuid": "^11.0.2" }, "engines": { @@ -5234,13 +5770,85 @@ }, "optionalDependencies": { "@google-cloud/firestore": "^7.11.0", - "@google-cloud/storage": "^7.14.0" + "@google-cloud/storage": "^7.19.0" + } + }, + "node_modules/firebase-admin/node_modules/gaxios": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-7.1.4.tgz", + "integrity": "sha512-bTIgTsM2bWn3XklZISBTQX7ZSddGW+IO3bMdGaemHZ3tbqExMENHLx6kKZ/KlejgrMtj8q7wBItt51yegqalrA==", + "license": "Apache-2.0", + "dependencies": { + "extend": "^3.0.2", + "https-proxy-agent": "^7.0.1", + "node-fetch": "^3.3.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/firebase-admin/node_modules/gcp-metadata": { + "version": "8.1.2", + "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-8.1.2.tgz", + "integrity": "sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg==", + "license": "Apache-2.0", + "dependencies": { + "gaxios": "^7.0.0", + "google-logging-utils": "^1.0.0", + "json-bigint": "^1.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/firebase-admin/node_modules/google-auth-library": { + "version": "10.6.2", + "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-10.6.2.tgz", + "integrity": "sha512-e27Z6EThmVNNvtYASwQxose/G57rkRuaRbQyxM2bvYLLX/GqWZ5chWq2EBoUchJbCc57eC9ArzO5wMsEmWftCw==", + "license": "Apache-2.0", + "dependencies": { + "base64-js": "^1.3.0", + "ecdsa-sig-formatter": "^1.0.11", + "gaxios": "^7.1.4", + "gcp-metadata": "8.1.2", + "google-logging-utils": "1.1.3", + "jws": "^4.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/firebase-admin/node_modules/google-logging-utils": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-1.1.3.tgz", + "integrity": "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA==", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, + "node_modules/firebase-admin/node_modules/node-fetch": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", + "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", + "license": "MIT", + "dependencies": { + "data-uri-to-buffer": "^4.0.0", + "fetch-blob": "^3.1.4", + "formdata-polyfill": "^4.0.10" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/node-fetch" } }, "node_modules/firebase-functions": { - "version": "7.0.5", - "resolved": "https://registry.npmjs.org/firebase-functions/-/firebase-functions-7.0.5.tgz", - "integrity": "sha512-uG2dR5AObLuUrWWjj/de5XxNHCVi+Ehths0DSRcLjHJdgw1TSejwoZZ5na6gVrl3znNjRdBRy5Br5UlhaIU3Ww==", + "version": "7.2.5", + "resolved": "https://registry.npmjs.org/firebase-functions/-/firebase-functions-7.2.5.tgz", + "integrity": "sha512-K+pP0AknluAguLRbD96hibyXbnOgwnvd4hkExWdGrxnNCLoj8LBFj08uvJYxyvhsCgYzQumrUaHBW4lsXKSiRg==", "license": "MIT", "dependencies": { "@types/cors": "^2.8.5", @@ -5309,9 +5917,9 @@ } }, "node_modules/flatted": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", - "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", "dev": true, "license": "ISC" }, @@ -5367,6 +5975,18 @@ "node": ">= 0.12" } }, + "node_modules/formdata-polyfill": { + "version": "4.0.10", + "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", + "integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==", + "license": "MIT", + "dependencies": { + "fetch-blob": "^3.1.2" + }, + "engines": { + "node": ">=12.20.0" + } + }, "node_modules/forwarded": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", @@ -5385,6 +6005,12 @@ "node": ">= 0.6" } }, + "node_modules/fs-constants": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", + "license": "MIT" + }, "node_modules/fs.realpath": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", @@ -5460,6 +6086,7 @@ "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-6.7.1.tgz", "integrity": "sha512-LDODD4TMYx7XXdpwxAVRAIAuB0bzv0s+ywFonY46k126qzQHT9ygyoa9tncmOiQmmDrik65UYsEkv3lbfqQ3yQ==", "license": "Apache-2.0", + "optional": true, "dependencies": { "extend": "^3.0.2", "https-proxy-agent": "^7.0.1", @@ -5480,6 +6107,7 @@ "https://github.com/sponsors/ctavan" ], "license": "MIT", + "optional": true, "bin": { "uuid": "dist/bin/uuid" } @@ -5489,6 +6117,7 @@ "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-6.1.1.tgz", "integrity": "sha512-a4tiq7E0/5fTjxPAaH4jpjkSv/uCaU2p5KC6HVGrvl0cDjA8iBZv4vv1gyzlmK0ZUKqwpOyQMKzZQe3lTit77A==", "license": "Apache-2.0", + "optional": true, "dependencies": { "gaxios": "^6.1.1", "google-logging-utils": "^0.0.2", @@ -5609,6 +6238,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/github-from-package": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", + "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", + "license": "MIT" + }, "node_modules/glob": { "version": "10.5.0", "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", @@ -5657,9 +6292,9 @@ } }, "node_modules/glob/node_modules/brace-expansion": { - "version": "5.0.3", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.3.tgz", - "integrity": "sha512-fy6KJm2RawA5RcHkLa1z/ScpBeA762UF9KmZQxwIbDtRJrgLzM10depAiEQ+CXYcoiqW1/m96OAAoke2nE9EeA==", + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", + "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", "dev": true, "license": "MIT", "peer": true, @@ -5762,6 +6397,7 @@ "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-9.15.1.tgz", "integrity": "sha512-Jb6Z0+nvECVz+2lzSMt9u98UsoakXxA2HGHMCxh+so3n90XgYWkq5dur19JAJV7ONiJY22yBTyJB1TSkvPq9Ng==", "license": "Apache-2.0", + "optional": true, "dependencies": { "base64-js": "^1.3.0", "ecdsa-sig-formatter": "^1.0.11", @@ -5817,6 +6453,7 @@ "resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-0.0.2.tgz", "integrity": "sha512-NEgUnEcBiP5HrPzufUkBzJOD/Sxsco3rLNo1F1TNf7ieU8ryUzBhqba8r756CjLX7rn3fHl6iLEwPYuqpoKgQQ==", "license": "Apache-2.0", + "optional": true, "engines": { "node": ">=14" } @@ -5853,6 +6490,7 @@ "resolved": "https://registry.npmjs.org/gtoken/-/gtoken-7.1.0.tgz", "integrity": "sha512-pCcEwRi+TKpMlxAQObHDQ56KawURgyAf6jtIY046fJ5tIv3zDe/LEIubckAO8fj6JnAxLdmWkUfNyulQ2iKdEw==", "license": "MIT", + "optional": true, "dependencies": { "gaxios": "^6.0.0", "jws": "^4.0.0" @@ -5977,6 +6615,37 @@ "license": "MIT", "peer": true }, + "node_modules/htmlparser2": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.1.0.tgz", + "integrity": "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==", + "funding": [ + "https://github.com/fb55/htmlparser2?sponsor=1", + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.2.2", + "entities": "^7.0.1" + } + }, + "node_modules/htmlparser2/node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/http-errors": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", @@ -6067,6 +6736,26 @@ "node": ">=0.10.0" } }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -6143,6 +6832,12 @@ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "license": "ISC" }, + "node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "license": "ISC" + }, "node_modules/internal-slot": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", @@ -6158,6 +6853,12 @@ "node": ">= 0.4" } }, + "node_modules/iota-array": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/iota-array/-/iota-array-1.0.0.tgz", + "integrity": "sha512-pZ2xT+LOHckCatGQ3DcG/a+QuEqvoxqkiL7tvE8nn3uuu+f6i1TtpB5/FtWFbxUuVr5PZCx8KskuGatbJDXOWA==", + "license": "MIT" + }, "node_modules/ipaddr.js": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", @@ -6246,6 +6947,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-buffer": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.6.tgz", + "integrity": "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==", + "license": "MIT" + }, "node_modules/is-callable": { "version": "1.2.7", "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", @@ -6505,6 +7212,7 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "devOptional": true, "license": "MIT", "engines": { "node": ">=8" @@ -7236,9 +7944,9 @@ } }, "node_modules/jest-util/node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", "peer": true, @@ -7567,7 +8275,6 @@ "version": "4.17.23", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz", "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", - "dev": true, "license": "MIT" }, "node_modules/lodash.camelcase": { @@ -7830,6 +8537,18 @@ "node": ">=6" } }, + "node_modules/mimic-response": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/minimatch": { "version": "3.1.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", @@ -7847,7 +8566,6 @@ "version": "1.2.8", "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", - "dev": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/ljharb" @@ -7857,19 +8575,41 @@ "version": "7.1.3", "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", - "dev": true, "license": "BlueOak-1.0.0", - "peer": true, "engines": { "node": ">=16 || 14 >=14.17" } }, + "node_modules/minizlib": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.1.0.tgz", + "integrity": "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==", + "license": "MIT", + "dependencies": { + "minipass": "^7.1.2" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/mkdirp-classic": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", + "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", + "license": "MIT" + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, + "node_modules/napi-build-utils": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", + "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==", + "license": "MIT" + }, "node_modules/napi-postinstall": { "version": "0.3.4", "resolved": "https://registry.npmjs.org/napi-postinstall/-/napi-postinstall-0.3.4.tgz", @@ -7901,6 +8641,16 @@ "dev": true, "license": "MIT" }, + "node_modules/ndarray": { + "version": "1.0.19", + "resolved": "https://registry.npmjs.org/ndarray/-/ndarray-1.0.19.tgz", + "integrity": "sha512-B4JHA4vdyZU30ELBw3g7/p9bZupyew5a7tX1Y/gGeF2hafrPaQZhgrGQfsvgfYbgdFZjYwuEcnaobeM/WMW+HQ==", + "license": "MIT", + "dependencies": { + "iota-array": "^1.0.0", + "is-buffer": "^1.0.2" + } + }, "node_modules/negotiator": { "version": "0.6.3", "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", @@ -7910,11 +8660,50 @@ "node": ">= 0.6" } }, + "node_modules/node-abi": { + "version": "3.89.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.89.0.tgz", + "integrity": "sha512-6u9UwL0HlAl21+agMN3YAMXcKByMqwGx+pq+P76vii5f7hTPtKDp08/H9py6DY+cfDw7kQNTGEj/rly3IgbNQA==", + "license": "MIT", + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/node-addon-api": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-6.1.0.tgz", + "integrity": "sha512-+eawOlIgy680F0kBzPUNFhMZGtJ1YmqM6l4+Crf4IkImjYrO/mqPwRMh352g23uIaQKFItcQ64I7KMaJxHgAVA==", + "license": "MIT" + }, + "node_modules/node-domexception": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", + "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", + "deprecated": "Use your platform's native DOMException instead", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "github", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "engines": { + "node": ">=10.5.0" + } + }, "node_modules/node-fetch": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", "license": "MIT", + "optional": true, "dependencies": { "whatwg-url": "^5.0.0" }, @@ -7931,9 +8720,9 @@ } }, "node_modules/node-forge": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.3.3.tgz", - "integrity": "sha512-rLvcdSyRCyouf6jcOIPe/BgwG/d7hKjzMKOas33/pHEr6gbq18IK9zV7DiPvzsz0oBJPme6qr6H6kGZuI9/DZg==", + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.4.0.tgz", + "integrity": "sha512-LarFH0+6VfriEhqMMcLX2F7SwSXeWwnEAJEsYm5QKWchiVYVvJyV9v7UDvUv+w5HO23ZpQTXDv/GxdDdMyOuoQ==", "license": "(BSD-3-Clause OR GPL-2.0)", "engines": { "node": ">= 6.13.0" @@ -7980,6 +8769,18 @@ "node": ">=8" } }, + "node_modules/nth-check": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", + "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==", + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0" + }, + "funding": { + "url": "https://github.com/fb55/nth-check?sponsor=1" + } + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -8110,7 +8911,6 @@ "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", - "devOptional": true, "license": "ISC", "dependencies": { "wrappy": "1" @@ -8297,6 +9097,55 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parse5-htmlparser2-tree-adapter": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/parse5-htmlparser2-tree-adapter/-/parse5-htmlparser2-tree-adapter-7.1.0.tgz", + "integrity": "sha512-ruw5xyKs6lrpo9x9rCZqZZnIUntICjQAd0Wsmp396Ul9lN/h+ifgVV1x1gZHi8euej6wTfpqX8j+BFQxF0NS/g==", + "license": "MIT", + "dependencies": { + "domhandler": "^5.0.3", + "parse5": "^7.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parse5-parser-stream": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/parse5-parser-stream/-/parse5-parser-stream-7.1.2.tgz", + "integrity": "sha512-JyeQc9iwFLn5TbvvqACIF/VXG6abODeB3Fwmv/TGdLk2LfbWkaySGY72at4+Ty7EkPZj854u4CrICqNk2qIbow==", + "license": "MIT", + "dependencies": { + "parse5": "^7.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parse5/node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/parseurl": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", @@ -8316,6 +9165,22 @@ "node": ">=8" } }, + "node_modules/path-expression-matcher": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.5.0.tgz", + "integrity": "sha512-cbrerZV+6rvdQrrD+iGMcZFEiiSrbv9Tfdkvnusy6y0x0GKBXREFg/Y65GhIfm0tnLntThhzCnfKwp1WRjeCyQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "optional": true, + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/path-is-absolute": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", @@ -8370,9 +9235,9 @@ "peer": true }, "node_modules/path-to-regexp": { - "version": "0.1.12", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz", - "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==", + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz", + "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==", "license": "MIT" }, "node_modules/path-type": { @@ -8394,9 +9259,9 @@ "peer": true }, "node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", "dev": true, "license": "MIT", "engines": { @@ -8501,6 +9366,67 @@ "node": ">= 0.4" } }, + "node_modules/prebuild-install": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", + "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==", + "deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.", + "license": "MIT", + "dependencies": { + "detect-libc": "^2.0.0", + "expand-template": "^2.0.3", + "github-from-package": "0.0.0", + "minimist": "^1.2.3", + "mkdirp-classic": "^0.5.3", + "napi-build-utils": "^2.0.0", + "node-abi": "^3.3.0", + "pump": "^3.0.0", + "rc": "^1.2.7", + "simple-get": "^4.0.0", + "tar-fs": "^2.0.0", + "tunnel-agent": "^0.6.0" + }, + "bin": { + "prebuild-install": "bin.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/prebuild-install/node_modules/chownr": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", + "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", + "license": "ISC" + }, + "node_modules/prebuild-install/node_modules/tar-fs": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz", + "integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==", + "license": "MIT", + "dependencies": { + "chownr": "^1.1.1", + "mkdirp-classic": "^0.5.2", + "pump": "^3.0.0", + "tar-stream": "^2.1.4" + } + }, + "node_modules/prebuild-install/node_modules/tar-stream": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", + "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "license": "MIT", + "dependencies": { + "bl": "^4.0.3", + "end-of-stream": "^1.4.1", + "fs-constants": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -8555,9 +9481,9 @@ } }, "node_modules/protobufjs": { - "version": "7.5.4", - "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.4.tgz", - "integrity": "sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg==", + "version": "7.5.5", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.5.tgz", + "integrity": "sha512-3wY1AxV+VBNW8Yypfd1yQY9pXnqTAN+KwQxL8iYm3/BjKYMNg4i0owhEe26PWDOMaIrzeeF98Lqd5NGz4omiIg==", "hasInstallScript": true, "license": "BSD-3-Clause", "dependencies": { @@ -8591,6 +9517,16 @@ "node": ">= 0.10" } }, + "node_modules/pump": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", + "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -8679,6 +9615,30 @@ "node": ">= 0.8" } }, + "node_modules/rc": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", + "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", + "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", + "dependencies": { + "deep-extend": "^0.6.0", + "ini": "~1.3.0", + "minimist": "^1.2.0", + "strip-json-comments": "~2.0.1" + }, + "bin": { + "rc": "cli.js" + } + }, + "node_modules/rc/node_modules/strip-json-comments": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/react-is": { "version": "18.3.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", @@ -8692,7 +9652,6 @@ "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", "license": "MIT", - "optional": true, "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", @@ -9334,6 +10293,66 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/simple-concat": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", + "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/simple-get": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz", + "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "decompress-response": "^6.0.0", + "once": "^1.3.1", + "simple-concat": "^1.0.0" + } + }, + "node_modules/simple-swizzle": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.4.tgz", + "integrity": "sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw==", + "license": "MIT", + "dependencies": { + "is-arrayish": "^0.3.1" + } + }, + "node_modules/simple-swizzle/node_modules/is-arrayish": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.4.tgz", + "integrity": "sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA==", + "license": "MIT" + }, "node_modules/slash": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", @@ -9440,12 +10459,22 @@ "license": "MIT", "optional": true }, + "node_modules/streamx": { + "version": "2.25.0", + "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.25.0.tgz", + "integrity": "sha512-0nQuG6jf1w+wddNEEXCF4nTg3LtufWINB5eFEN+5TNZW7KWJp6x87+JFL43vaAUPyCfH1wID+mNVyW6OHtFamg==", + "license": "MIT", + "dependencies": { + "events-universal": "^1.0.0", + "fast-fifo": "^1.3.2", + "text-decoder": "^1.1.0" + } + }, "node_modules/string_decoder": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", "license": "MIT", - "optional": true, "dependencies": { "safe-buffer": "~5.2.0" } @@ -9663,9 +10692,9 @@ } }, "node_modules/strnum": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.1.2.tgz", - "integrity": "sha512-l63NF9y/cLROq/yqKXSLtcMeeyOfnSQlfMSlzFt/K73oIaD8DGaQWd7Z34X9GPiKqP5rbSh84Hl4bOlLcjiSrQ==", + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.2.3.tgz", + "integrity": "sha512-oKx6RUCuHfT3oyVjtnrmn19H1SiCqgJSg+54XqURKp5aCMbrXrhLjRN9TjuwMjiYstZ0MzDrHqkGZ5dFTKd+zg==", "funding": [ { "type": "github", @@ -9725,6 +10754,57 @@ "url": "https://opencollective.com/synckit" } }, + "node_modules/tar": { + "version": "7.5.13", + "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.13.tgz", + "integrity": "sha512-tOG/7GyXpFevhXVh8jOPJrmtRpOTsYqUIkVdVooZYJS/z8WhfQUX8RJILmeuJNinGAMSu1veBr4asSHFt5/hng==", + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/fs-minipass": "^4.0.0", + "chownr": "^3.0.0", + "minipass": "^7.1.2", + "minizlib": "^3.1.0", + "yallist": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/tar-fs": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-3.1.2.tgz", + "integrity": "sha512-QGxxTxxyleAdyM3kpFs14ymbYmNFrfY+pHj7Z8FgtbZ7w2//VAgLMac7sT6nRpIHjppXO2AwwEOg0bPFVRcmXw==", + "license": "MIT", + "dependencies": { + "pump": "^3.0.0", + "tar-stream": "^3.1.5" + }, + "optionalDependencies": { + "bare-fs": "^4.0.1", + "bare-path": "^3.0.0" + } + }, + "node_modules/tar-stream": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-3.1.8.tgz", + "integrity": "sha512-U6QpVRyCGHva435KoNWy9PRoi2IFYCgtEhq9nmrPPpbRacPs9IH4aJ3gbrFC8dPcXvdSZ4XXfXT5Fshbp2MtlQ==", + "license": "MIT", + "dependencies": { + "b4a": "^1.6.4", + "bare-fs": "^4.5.5", + "fast-fifo": "^1.2.0", + "streamx": "^2.15.0" + } + }, + "node_modules/tar/node_modules/yallist": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", + "integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, "node_modules/teeny-request": { "version": "9.0.0", "resolved": "https://registry.npmjs.org/teeny-request/-/teeny-request-9.0.0.tgz", @@ -9783,6 +10863,15 @@ "uuid": "dist/bin/uuid" } }, + "node_modules/teex": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/teex/-/teex-1.0.1.tgz", + "integrity": "sha512-eYE6iEI62Ni1H8oIa7KlDU6uQBtqr4Eajni3wX7rpfXD8ysFx8z0+dri+KWEPWpBsxXfxu58x/0jvTVT1ekOSg==", + "license": "MIT", + "dependencies": { + "streamx": "^2.12.5" + } + }, "node_modules/test-exclude": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz", @@ -9822,6 +10911,15 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/text-decoder": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/text-decoder/-/text-decoder-1.2.7.tgz", + "integrity": "sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ==", + "license": "Apache-2.0", + "dependencies": { + "b4a": "^1.6.4" + } + }, "node_modules/text-table": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", @@ -9863,7 +10961,8 @@ "version": "0.0.3", "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", - "license": "MIT" + "license": "MIT", + "optional": true }, "node_modules/ts-deepmerge": { "version": "2.0.7", @@ -9937,6 +11036,18 @@ "dev": true, "license": "0BSD" }, + "node_modules/tunnel-agent": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", + "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + }, + "engines": { + "node": "*" + } + }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", @@ -10098,6 +11209,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/undici": { + "version": "7.25.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.25.0.tgz", + "integrity": "sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ==", + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, "node_modules/undici-types": { "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", @@ -10195,8 +11315,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", - "license": "MIT", - "optional": true + "license": "MIT" }, "node_modules/utils-merge": { "version": "1.0.1", @@ -10256,11 +11375,21 @@ "makeerror": "1.0.12" } }, + "node_modules/web-streams-polyfill": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", + "integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==", + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, "node_modules/webidl-conversions": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", - "license": "BSD-2-Clause" + "license": "BSD-2-Clause", + "optional": true }, "node_modules/websocket-driver": { "version": "0.7.4", @@ -10285,11 +11414,46 @@ "node": ">=0.8.0" } }, + "node_modules/whatwg-encoding": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", + "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", + "deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation", + "license": "MIT", + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-encoding/node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/whatwg-mimetype": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/whatwg-url": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", "license": "MIT", + "optional": true, "dependencies": { "tr46": "~0.0.3", "webidl-conversions": "^3.0.0" @@ -10522,7 +11686,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", - "devOptional": true, "license": "ISC" }, "node_modules/write-file-atomic": { diff --git a/functions/package.json b/functions/package.json index 55c7890..e9ab6ca 100644 --- a/functions/package.json +++ b/functions/package.json @@ -2,7 +2,7 @@ "name": "functions", "scripts": { "lint": "ESLINT_USE_FLAT_CONFIG=false eslint --ext .js,.ts .", - "build": "tsc", + "build": "rm -rf lib && tsc && node scripts/write-main.cjs", "build:watch": "tsc --watch", "serve": "npm run build && firebase emulators:start --only functions", "shell": "npm run build && firebase functions:shell", @@ -11,14 +11,16 @@ "logs": "firebase functions:log" }, "engines": { - "node": "24" + "node": "22" }, "main": "lib/index.js", "dependencies": { + "@imgly/background-removal-node": "^1.4.5", + "cheerio": "^1.2.0", "firebase-admin": "^13.6.0", "firebase-functions": "^7.0.0", - "openai": "^5.12.2", "onnxruntime-node": "^1.24.2", + "openai": "^5.12.2", "sharp": "^0.34.5" }, "devDependencies": { diff --git a/functions/scripts/write-main.cjs b/functions/scripts/write-main.cjs new file mode 100644 index 0000000..d04ccbe --- /dev/null +++ b/functions/scripts/write-main.cjs @@ -0,0 +1,19 @@ +const fs = require("node:fs"); +const path = require("node:path"); + +const libDir = path.join(__dirname, "..", "lib"); +const deployEntry = path.join(libDir, "functions", "src", "index.js"); +const mainEntry = path.join(libDir, "index.js"); + +if (!fs.existsSync(deployEntry)) { + throw new Error(`Missing compiled functions entry: ${deployEntry}`); +} + +fs.writeFileSync( + mainEntry, + [ + '"use strict";', + 'module.exports = require("./functions/src/index.js");', + "", + ].join("\n"), +); diff --git a/functions/src/affiliate/affiliateLinks.ts b/functions/src/affiliate/affiliateLinks.ts new file mode 100644 index 0000000..0bcdca0 --- /dev/null +++ b/functions/src/affiliate/affiliateLinks.ts @@ -0,0 +1,100 @@ +import { HttpsError, onCall } from "firebase-functions/v2/https"; +import { logger } from "firebase-functions/v2"; + +type BuildAffiliateUrlOptions = { + customId?: string | null; +}; + +const NON_MERCHANT_HOST_RE = + /(^|\.)((google|googleadservices|gstatic|serpapi|skimresources)\.com|google\.[a-z.]+)$/i; + +function normalizedHttpUrl(productUrl: string) { + try { + const url = new URL(productUrl); + if (url.protocol !== "https:" && url.protocol !== "http:") { + throw new Error("unsupported_protocol"); + } + return url.toString(); + } catch { + throw new HttpsError("invalid-argument", "A valid product URL is required."); + } +} + +export function isDirectMerchantUrl(productUrl: string) { + try { + const url = new URL(productUrl); + if (url.protocol !== "https:" && url.protocol !== "http:") return false; + const host = url.hostname.replace(/^www\d*\./i, "").toLowerCase(); + if (!host || NON_MERCHANT_HOST_RE.test(host)) return false; + return true; + } catch { + return false; + } +} + +function cleanCustomId(customId?: string | null) { + return String(customId ?? "") + .trim() + .replace(/[^a-zA-Z0-9_.:-]+/g, "-") + .slice(0, 80); +} + +export function buildAffiliateUrl( + productUrl: string, + options: BuildAffiliateUrlOptions = {}, +) { + const safeProductUrl = normalizedHttpUrl(productUrl); + const parsedProductUrl = new URL(safeProductUrl); + if (/skimresources\.com$/i.test(parsedProductUrl.hostname)) { + return safeProductUrl; + } + if (!isDirectMerchantUrl(safeProductUrl)) { + return safeProductUrl; + } + + const skimlinksId = String(process.env.SKIMLINKS_ID ?? "").trim(); + if (!skimlinksId) { + logger.warn("[AFFILIATE_LINKS] SKIMLINKS_ID missing; returning product URL."); + return safeProductUrl; + } + + const affiliateUrl = new URL("https://go.skimresources.com/"); + affiliateUrl.searchParams.set("id", skimlinksId); + affiliateUrl.searchParams.set("xs", "1"); + affiliateUrl.searchParams.set("url", safeProductUrl); + const customId = cleanCustomId(options.customId); + if (customId) affiliateUrl.searchParams.set("xcust", customId); + return affiliateUrl.toString(); +} + +export const wrapAffiliateLinks = onCall(async (request) => { + const uid = request.auth?.uid; + if (!uid) { + throw new HttpsError("unauthenticated", "Please sign in first."); + } + + const urls: unknown[] = Array.isArray(request.data?.productUrls) + ? request.data.productUrls + : [request.data?.productUrl]; + const productUrls = urls + .map((value: unknown) => String(value ?? "").trim()) + .filter(Boolean) + .slice(0, 10); + + if (!productUrls.length) { + throw new HttpsError("invalid-argument", "At least one product URL is required."); + } + + return { + ok: true, + links: productUrls.map((productUrl: string, index: number) => { + const safeProductUrl = normalizedHttpUrl(productUrl); + return { + productUrl: safeProductUrl, + affiliateUrl: buildAffiliateUrl(safeProductUrl, { + customId: `${uid}:${index}`, + }), + }; + }), + }; +}); diff --git a/functions/src/askAura.ts b/functions/src/askAura.ts index 91f5197..77bdd2e 100644 --- a/functions/src/askAura.ts +++ b/functions/src/askAura.ts @@ -4,13 +4,126 @@ import OpenAI from "openai"; import { getFirestore } from "firebase-admin/firestore"; import { AURA_INSTRUCTIONS } from "./shared/auraPrompt"; +import { + candidatePreviewResponse, + candidatesFromProductExtractions, + extractImageCandidates, + fallbackImageCandidates, + rankProductExtractionImages, + type AuraCandidateItem, +} from "./shared/auraCandidatePreview"; +import { classifyAuraLinkIntent, extractUrlsFromText } from "./shared/auraLinkIntent"; import { buildAuraContext } from "./shared/buildAuraContext"; +import { loadCompactAuraMemoryContext } from "./shared/auraMemory"; +import { + analyzeOutfitPhoto, + isOutfitPhotoRequest, + safeAuraVisionError, +} from "./shared/auraOutfitPhotoAnalysis"; +import { dedupeAuraLookAccessories } from "./shared/auraAccessorySelection"; +import { type WardrobeGapSuggestion } from "./shared/detectWardrobeGaps"; import { loadAuraUserProfile } from "./shared/loadAuraUserProfile"; +import { handleLaundryIntent } from "./shared/laundryIntent"; +import { + ProductLinkError, + extractProductFromUrl, +} from "./shared/productLinkExtractor"; +import { requireOpenAiApiKey } from "./shared/env"; +import { + RATE_LIMITS, + assertFunctionRateLimit, + redactUid, +} from "./shared/rateLimit"; +import { redactUrlForLogs, validateSafeUrlForFetch } from "./shared/safeFetch"; +import { buildManualOutfitLookFromPrompt } from "./shared/manualOutfitLook"; +import { buildConcreteOutfitLook } from "./shared/auraConcreteLook"; +import { scoreLookStylingFromPieces } from "./shared/styling/stylingScore"; +import type { + StylingItem, + StylingScoreResult, +} from "./shared/styling/types"; const db = getFirestore(); +const AURA_BACKEND_VERSION = "candidate-preview-url-v9-zara-product-api"; +const DEBUG_AURA_SPARSE = + process.env.FUNCTIONS_EMULATOR === "true" || process.env.NODE_ENV !== "production"; + +function sanitizeUserInput(input: string): string { + return input + .trim() + .replace(/\0/g, "") + .slice(0, 2000) + .replace(/ignore previous instructions/gi, "") + .replace(/forget everything/gi, "") + .replace(/you are now/gi, "") + .replace(/system:/gi, "") + .replace(/assistant:/gi, ""); +} + +function styleCoreNoteFromClientContext(value: unknown) { + const context = (value && typeof value === "object" + ? (value as Record).minimumCloset + : null) as Record | null; + if (!context || typeof context !== "object") return ""; + + const progress = String(context.styleCoreProgress ?? "").trim(); + const nextBestAdd = String(context.nextBestAdd ?? "").trim(); + const outfitRange = Number(context.outfitRange ?? 0); + const nudge = String(context.nudge ?? "").trim(); + + return [ + progress ? `Style core: ${progress}.` : "", + nextBestAdd ? `Next best add: ${nextBestAdd}.` : "", + Number.isFinite(outfitRange) && outfitRange > 0 ? `Outfit range signal: ${outfitRange}+ paths.` : "", + nudge, + "Use this naturally; do not say minimum closet target, estimated combinations, or missing categories.", + ].filter(Boolean).join(" "); +} + +function cleanStringList(value: unknown, maxLength: number) { + if (!Array.isArray(value)) return []; + const seen = new Set(); + const out: string[] = []; + for (const entry of value) { + const text = String(entry ?? "").trim(); + if (!text || seen.has(text)) continue; + seen.add(text); + out.push(text); + if (out.length >= maxLength) break; + } + return out; +} + +function requiredItemIdsFromClientContext(value: unknown) { + const context = value && typeof value === "object" + ? (value as Record) + : {}; + return cleanStringList(context.requiredItemIds, 8); +} + +function requiredItemsPromptNote(requiredItemIds: string[]) { + if (!requiredItemIds.length) return "No required closet anchor items."; + return [ + "Required closet anchor:", + `- Every outfit/look option must include these exact closet item IDs: ${requiredItemIds.join(", ")}.`, + "- Do not substitute another closet item for these anchors.", + "- Never mention these raw IDs in the user-facing reply.", + "- If you cannot use the required item, return chat copy explaining that instead of inventing a random outfit.", + ].join("\n"); +} + +async function assertRequiredItemsBelongToUser(uid: string, requiredItemIds: string[]) { + if (!requiredItemIds.length) return; + const snaps = await Promise.all( + requiredItemIds.map((itemId) => db.doc(`users/${uid}/items/${itemId}`).get()), + ); + if (snaps.some((snap) => !snap.exists)) { + throw new HttpsError("failed-precondition", "Selected closet item is no longer available."); + } +} type AuraResponse = { - presentation: "chat" | "card"; + presentation: "chat" | "card" | "candidate_preview" | "outfit_analysis" | "laundry_confirmation"; title: string; reply: string; reason: string; @@ -18,12 +131,21 @@ type AuraResponse = { ownedPieces: string[]; recommendedAdditions: string[]; swapSuggestion: string; + missingPieces?: string[]; + upgradeSuggestions?: string[]; + upgradeSuggestionItems?: { + label: string; + searchQuery?: string; + }[]; chips: string[]; look: { lookTitle: string; vibe: string; shortExplanation: string; stylingNote: string; + personalizationLabel?: string; + personalizationNote?: string; + stylingIntelligence?: StylingScoreResult | null; pieces: { role: "top" | "bottom" | "shoes" | "outerwear" | "accessory"; itemName: string; @@ -35,11 +157,55 @@ type AuraResponse = { addToComplete: string[]; alternates: string[]; actions: ( - "saveLook" | "planForToday" | "showMoreLikeThis" | "shopMissingPieces" | "useOnlyMyCloset" | "makeItDressier" + "saveLook" | "planForToday" | "likeLook" | "notMyVibe" | "showMoreLikeThis" | "lessLikeThis" | "shopMissingPieces" | "useOnlyMyCloset" | "makeItDressier" )[]; } | null; + lookOptions?: { + lookTitle: string; + vibe: string; + shortExplanation: string; + stylingNote: string; + personalizationLabel?: string; + personalizationNote?: string; + stylingIntelligence?: StylingScoreResult | null; + pieces: { + role: "top" | "bottom" | "shoes" | "outerwear" | "accessory"; + itemName: string; + source: "closet" | "suggested"; + itemId: string | null; + imageUrl: string | null; + }[]; + fromCloset: string[]; + addToComplete: string[]; + alternates: string[]; + actions: ( + "saveLook" | "planForToday" | "likeLook" | "notMyVibe" | "showMoreLikeThis" | "lessLikeThis" | "shopMissingPieces" | "useOnlyMyCloset" | "makeItDressier" + )[]; + }[]; + candidates?: AuraCandidateItem[]; + candidateItems?: AuraCandidateItem[]; + stylingIntelligence?: StylingScoreResult | null; + laundryAction?: { + targetStatus: "clean" | "needs_wash" | "in_laundry"; + matches: { itemId: string; label: string; subtitle?: string }[]; + }; +}; + +type AuraAttachment = { + type?: "image"; + uri?: string; + role?: string | null; + groupId?: string | null; + mimeType?: string | null; + storagePath?: string | null; + width?: number | null; + height?: number | null; }; +const ADD_IMAGE_RE = + /\b(add|save|store|put|upload|log)\b[\s\S]{0,80}\b(closet|wardrobe|item|items|these|this|all)\b|\b(add|save)\s+(this|these|all|item|items)\b/i; +const BATCH_IMAGE_RE = /\b(these|all|each|separate|multiple|items)\b/i; + function fallbackAuraResponse(raw: string): AuraResponse { return { presentation: "chat", @@ -50,11 +216,84 @@ function fallbackAuraResponse(raw: string): AuraResponse { ownedPieces: [], recommendedAdditions: [], swapSuggestion: "", + missingPieces: [], + upgradeSuggestions: [], + upgradeSuggestionItems: [], chips: [], look: null, + lookOptions: [], }; } +function uniqueTrimmed(values: string[] | undefined, max = 3) { + return Array.from( + new Set( + (values ?? []) + .map((value) => String(value ?? "").trim()) + .filter(Boolean) + ) + ).slice(0, max); +} + +function normalizeSuggestionItems( + items: AuraResponse["upgradeSuggestionItems"] | undefined, + fallbackSuggestions: string[], + contextSuggestions: WardrobeGapSuggestion[] +) { + const entries: [string, { label: string; searchQuery?: string }][] = []; + for (const item of items ?? []) { + const label = String(item?.label ?? "").trim(); + if (!label) continue; + const searchQuery = String(item?.searchQuery ?? "").trim(); + entries.push([label.toLowerCase(), { label, searchQuery: searchQuery || undefined }]); + } + const normalizedItems = Array.from(new Map(entries).values()); + + if (normalizedItems.length) return normalizedItems.slice(0, 3); + + return fallbackSuggestions + .map((label) => { + const match = contextSuggestions.find( + (suggestion) => suggestion.label.toLowerCase() === label.toLowerCase() + ); + return { + label, + searchQuery: match?.searchQuery, + }; + }) + .filter((item) => item.label) + .slice(0, 3); +} + +const MULTI_LOOK_REQUEST_RE = + /\b((?:2|3|4|two|three|four)\s+(?:outfits?|looks?|options?|directions?)|multiple\s+(?:outfits?|looks?|options?|directions?)|safe,\s*balanced,\s*(?:and\s*)?bold|safe\s+balanced\s+bold|different\s+directions|few\s+outfits?)\b/i; + +function parseRequestedLookCount(message: string) { + const text = String(message ?? "").toLowerCase(); + if (/\b(4|four)\s+(?:outfits?|looks?|options?|directions?)\b/.test(text)) return 4; + if (/\b(3|three)\s+(?:outfits?|looks?|options?|directions?)\b/.test(text)) return 3; + if (/\b(2|two)\s+(?:outfits?|looks?|options?|directions?)\b/.test(text)) return 2; + if (/\bsafe,\s*balanced,\s*(?:and\s*)?bold\b|\bsafe\s+balanced\s+bold\b/.test(text)) return 3; + if (/\bmultiple\s+(?:outfits?|looks?|options?|directions?)\b|\bdifferent\s+directions\b|\bfew\s+outfits?\b/.test(text)) return 3; + return 1; +} + +function wantsMultipleLooks(message: string) { + return MULTI_LOOK_REQUEST_RE.test(String(message ?? "")); +} + +function buildMultiLookRequestNote(userMessage: string) { + if (!wantsMultipleLooks(userMessage)) return ""; + const count = Math.min(3, Math.max(2, parseRequestedLookCount(userMessage))); + return ( + "\n\nMulti-look requirement:\n" + + "The user is explicitly asking for multiple outfit options.\n" + + `Return presentation "card" and provide ${count} structured looks in lookOptions whenever you can do so safely.\n` + + "Do not answer this with only prose, outfitItems, ownedPieces, or recommendedAdditions if structured looks are possible.\n" + + "If only one structured look is possible, still return that one structured look and leave a concise reply." + ); +} + function shortenLookReply(text: string) { const trimmed = String(text ?? "").trim(); if (!trimmed) return trimmed; @@ -64,36 +303,340 @@ function shortenLookReply(text: string) { return `${trimmed.slice(0, 117).trimEnd()}...`; } +function uniqueActions(actions: string[]) { + return Array.from(new Set(actions.filter(Boolean))); +} + +const PERSONALIZATION_BLOCKLIST = [ + /stored preferences?/i, + /profile data/i, + /learned memory/i, + /memory suggests/i, + /your profile says/i, + /\bdata suggests\b/i, +]; + +function cleanPersonalizationText(text: string, maxLength: number) { + const trimmed = String(text ?? "") + .replace(/\s+/g, " ") + .replace(/^["'`\-–—\s]+|["'`\-–—\s]+$/g, "") + .trim(); + if (!trimmed) return ""; + if (PERSONALIZATION_BLOCKLIST.some((pattern) => pattern.test(trimmed))) { + return ""; + } + if (trimmed.length <= maxLength) return trimmed; + return `${trimmed.slice(0, maxLength - 3).trimEnd()}...`; +} + +function fallbackPersonalization( + auraContext?: { + preferenceContext?: { + explicitProfile?: { + styleVibes?: string[]; + preferredFits?: string[]; + favoriteColors?: string[]; + experimentationLevel?: "low" | "medium" | "high"; + } | null; + learnedProfile?: { + confidence?: number; + inferredFavoriteColors?: string[]; + inferredFits?: string[]; + } | null; + session?: { + currentOccasion?: string; + vibeForThisSession?: string; + } | null; + } | null; + } +) { + const explicit = auraContext?.preferenceContext?.explicitProfile; + const learned = auraContext?.preferenceContext?.learnedProfile; + const session = auraContext?.preferenceContext?.session; + const learnedConfidence = learned?.confidence ?? 0; + + if (session?.currentOccasion && session?.vibeForThisSession) { + return { + label: `Right for ${session.currentOccasion}`, + note: `It keeps the ${session.vibeForThisSession} tone, but still feels polished enough for ${session.currentOccasion}.`, + }; + } + + if (session?.vibeForThisSession) { + return { + label: `Set to ${session.vibeForThisSession}`, + note: `This keeps the mood pointed toward ${session.vibeForThisSession} without losing polish.`, + }; + } + if (session?.currentOccasion) { + return { + label: `Built for ${session.currentOccasion}`, + note: `It is shaped around ${session.currentOccasion} first, so it feels right for the moment instead of generic.`, + }; + } + if (explicit?.styleVibes?.length && explicit?.favoriteColors?.length) { + const vibe = explicit.styleVibes[0]; + const color = explicit.favoriteColors[0]; + if (explicit.experimentationLevel === "high") { + return { + label: "A touch bolder", + note: `It still sits inside your ${vibe} lane, just with a slightly bolder ${color}-leaning twist.`, + }; + } + return { + label: `Feels like your ${vibe} side`, + note: `It stays close to your ${vibe} direction and keeps the palette near shades that already feel easy on you.`, + }; + } + if (explicit?.preferredFits?.length) { + const fit = explicit.preferredFits[0]; + return { + label: `Leans ${fit} in fit`, + note: `The proportions stay closer to your natural ${fit} fit.`, + }; + } + if (explicit?.styleVibes?.length) { + const vibe = explicit.styleVibes[0]; + if (explicit.experimentationLevel === "high") { + return { + label: "A little bolder than usual", + note: `It still sits inside your ${vibe} lane, just with a more adventurous edge.`, + }; + } + if (explicit.experimentationLevel === "low") { + return { + label: "Closer to your usual lane", + note: `This keeps things grounded in your usual ${vibe} direction.`, + }; + } + return { + label: `Works for your ${vibe} style`, + note: `It stays close to the ${vibe} direction that already feels natural on you.`, + }; + } + if (learnedConfidence >= 0.62 && learned?.inferredFavoriteColors?.length) { + return { + label: "Closer to your usual palette", + note: "The palette stays near the colors you seem to keep coming back to, so it still feels familiar in a good way.", + }; + } + if (learnedConfidence >= 0.62 && learned?.inferredFits?.length) { + return { + label: `Feels more ${learned.inferredFits[0]}`, + note: "The proportions stay near the shapes you seem to respond to best.", + }; + } + if (learnedConfidence >= 0.42 && learned?.inferredFavoriteColors?.length) { + return { + label: "Still feels like you", + note: "This keeps the palette in a range that already seems easy for you to wear.", + }; + } + return {label: "", note: ""}; +} + +function stylingItemsFromRecords( + items?: (Record & { id: string })[] | null, +): StylingItem[] { + return (items ?? []).map((item) => ({ + ...(item as StylingItem), + id: item.id, + source: "closet", + })); +} + +function normalizeLookSurface( + look: NonNullable, + auraContext?: { + occasion?: string | null; + wardrobe?: { + footwear?: Record[]; + }; + preferenceContext?: { + explicitProfile?: { + styleVibes?: string[]; + preferredFits?: string[]; + experimentationLevel?: "low" | "medium" | "high"; + favoriteColors?: string[]; + } | null; + learnedProfile?: { + confidence?: number; + inferredFavoriteColors?: string[]; + inferredFits?: string[]; + } | null; + session?: { + currentOccasion?: string; + vibeForThisSession?: string; + } | null; + } | null; + }, + closetItems?: StylingItem[], + userMessage?: string, +) { + if (look.stylingNote) { + look.stylingNote = shortenLookReply(look.stylingNote); + } + + const accessorySelection = dedupeAuraLookAccessories( + look, + auraContext?.preferenceContext ?? null + ); + look.pieces = accessorySelection.pieces; + if (DEBUG_AURA_SPARSE && accessorySelection.discarded.length > 0) { + logger.info("[AURA_ACCESSORY_SLOT]", { + discarded: accessorySelection.discarded.map((entry) => entry.reason), + }); + } + + const ownedPieces = look.pieces + .filter((piece) => piece.source === "closet") + .map((piece) => piece.itemName) + .filter(Boolean); + const suggestedPieces = look.pieces + .filter((piece) => piece.source === "suggested") + .map((piece) => piece.itemName) + .filter(Boolean); + + look.fromCloset = Array.from(new Set(ownedPieces)); + look.addToComplete = Array.from( + new Set(suggestedPieces.filter((piece) => !look.fromCloset.includes(piece))) + ); + look.actions = uniqueActions([ + ...(look.actions ?? []), + "likeLook", + "notMyVibe", + "showMoreLikeThis", + "lessLikeThis", + "useOnlyMyCloset", + "makeItDressier", + "shopMissingPieces", + ]) as NonNullable["actions"]; + + const personalization = fallbackPersonalization(auraContext); + look.personalizationLabel = + cleanPersonalizationText(String(look.personalizationLabel ?? ""), 38) || + personalization.label; + look.personalizationNote = + cleanPersonalizationText(String(look.personalizationNote ?? ""), 150) || + personalization.note; + + const stylingIntelligence = scoreLookStylingFromPieces( + look.pieces, + closetItems ?? [], + { + requestText: userMessage, + occasion: + auraContext?.occasion ?? + auraContext?.preferenceContext?.session?.currentOccasion ?? + null, + vibe: look.vibe, + }, + ); + if (stylingIntelligence) { + look.stylingIntelligence = stylingIntelligence; + } + + return { + ownedPieces: look.fromCloset, + recommendedAdditions: look.addToComplete, + }; +} + function normalizeAuraResponse( response: AuraResponse, + userMessage?: string, auraContext?: { + occasion?: string | null; wardrobe?: { footwear?: Record[]; }; - } + categoryCounts?: { + tops: number; + bottoms: number; + footwear: number; + outerwear: number; + accessories: number; + }; + isSparseWardrobe?: boolean; + wardrobeGaps?: { + missingCore?: { label: string }[]; + weakAreas?: { label: string }[]; + suggestions?: WardrobeGapSuggestion[]; + }; + preferenceContext?: { + explicitProfile?: { + styleVibes?: string[]; + preferredFits?: string[]; + experimentationLevel?: "low" | "medium" | "high"; + } | null; + learnedProfile?: { + confidence?: number; + inferredFavoriteColors?: string[]; + inferredFits?: string[]; + } | null; + session?: { + currentOccasion?: string; + vibeForThisSession?: string; + } | null; + } | null; + }, + closetRecords?: (Record & { id: string })[] | null, ) { - if (response.look) { - response.reply = shortenLookReply(response.reply); - response.reason = ""; - if (response.look.stylingNote) { - response.look.stylingNote = shortenLookReply(response.look.stylingNote); + const multiRequested = wantsMultipleLooks(userMessage ?? ""); + const closetItems = stylingItemsFromRecords(closetRecords); + if (!response.look && !response.lookOptions?.length && closetRecords?.length) { + const manualLook = buildManualOutfitLookFromPrompt( + userMessage ?? "", + closetRecords, + response, + ); + if (manualLook) { + response.presentation = "card"; + response.title = response.title?.trim() && response.title !== "AURA" + ? response.title + : "Outfit edit"; + response.look = manualLook; + response.outfitItems = manualLook.fromCloset; + response.ownedPieces = manualLook.fromCloset; + response.recommendedAdditions = manualLook.addToComplete; + } + } + if (!response.look && !response.lookOptions?.length && closetRecords?.length) { + const concreteLook = buildConcreteOutfitLook({ + userMessage, + response, + closetRecords, + }); + if (concreteLook) { + response.presentation = "card"; + response.title = response.title?.trim() && response.title !== "AURA" + ? response.title + : "Outfit option"; + response.reply = concreteLook.reply; + response.look = concreteLook.look; + response.outfitItems = concreteLook.look.fromCloset; + response.ownedPieces = concreteLook.look.fromCloset; + response.recommendedAdditions = concreteLook.look.addToComplete; } + } + if (response.lookOptions?.length) { + response.lookOptions = response.lookOptions.slice(0, 3); + } + + if (response.look || response.lookOptions?.length) { + response.reply = /:\n/.test(response.reply) ? response.reply.trim() : shortenLookReply(response.reply); + response.reason = ""; + } - const ownedPieces = response.look.pieces - .filter((piece) => piece.source === "closet") - .map((piece) => piece.itemName) - .filter(Boolean); - const suggestedPieces = response.look.pieces - .filter((piece) => piece.source === "suggested") - .map((piece) => piece.itemName) - .filter(Boolean); - - response.ownedPieces = Array.from(new Set(ownedPieces)); - response.recommendedAdditions = Array.from( - new Set(suggestedPieces.filter((piece) => !response.ownedPieces.includes(piece))) + if (response.look) { + const normalized = normalizeLookSurface( + response.look, + auraContext, + closetItems, + userMessage, ); - response.look.fromCloset = response.ownedPieces; - response.look.addToComplete = response.recommendedAdditions; + response.ownedPieces = normalized.ownedPieces; + response.recommendedAdditions = normalized.recommendedAdditions; const eligibleFootwear = auraContext?.wardrobe?.footwear ?? []; const selectedOwnedFootwear = response.look.pieces.filter( @@ -111,25 +654,303 @@ function normalizeAuraResponse( }); } } + + if (!response.look && response.lookOptions?.length) { + response.look = response.lookOptions[0]; + } + if (response.lookOptions?.length) { + response.lookOptions = response.lookOptions.map((look) => { + normalizeLookSurface(look, auraContext, closetItems, userMessage); + return look; + }); + } + if (multiRequested && !response.lookOptions?.length && response.look) { + logger.warn("[AURA_MULTI] multi-look request returned only one structured look", { + requestedCount: parseRequestedLookCount(userMessage ?? ""), + lookTitle: response.look.lookTitle, + }); + response.lookOptions = [response.look]; + } + if (multiRequested && !response.look && !response.lookOptions?.length) { + logger.warn("[AURA_MULTI] multi-look request returned no structured looks", { + requestedCount: parseRequestedLookCount(userMessage ?? ""), + title: response.title, + reply: response.reply, + ownedPiecesCount: response.ownedPieces?.length ?? 0, + recommendedAdditionsCount: response.recommendedAdditions?.length ?? 0, + outfitItemsCount: response.outfitItems?.length ?? 0, + }); + } + + response.stylingIntelligence = + response.look?.stylingIntelligence ?? + response.lookOptions?.[0]?.stylingIntelligence ?? + null; + + const gapContext = auraContext?.wardrobeGaps; + const derivedMissing = uniqueTrimmed([ + ...(response.missingPieces ?? []), + ...(auraContext?.isSparseWardrobe + ? [ + ...(gapContext?.missingCore?.map((gap) => gap.label) ?? []), + ...(gapContext?.weakAreas?.slice(0, 1).map((gap) => gap.label) ?? []), + ] + : gapContext?.missingCore?.map((gap) => gap.label) ?? []), + ]); + const derivedSuggestions = uniqueTrimmed([ + ...(response.upgradeSuggestions ?? []), + ...((gapContext?.suggestions ?? []).map((suggestion) => suggestion.label) ?? []), + ]); + + const maxSuggestionCount = auraContext?.isSparseWardrobe ? 3 : 2; + response.missingPieces = derivedMissing.slice(0, maxSuggestionCount); + response.upgradeSuggestions = derivedSuggestions + .filter((suggestion) => !response.missingPieces?.includes(suggestion)) + .slice(0, maxSuggestionCount); + response.upgradeSuggestionItems = normalizeSuggestionItems( + response.upgradeSuggestionItems, + response.upgradeSuggestions, + gapContext?.suggestions ?? [] + ).filter((item) => !response.missingPieces?.includes(item.label)); + + if (!auraContext?.isSparseWardrobe && !(gapContext?.missingCore?.length ?? 0)) { + response.missingPieces = response.missingPieces?.slice(0, 1) ?? []; + response.upgradeSuggestions = response.upgradeSuggestions?.slice(0, 1) ?? []; + response.upgradeSuggestionItems = response.upgradeSuggestionItems?.slice(0, 1) ?? []; + } + + if (DEBUG_AURA_SPARSE) { + logger.info("[AURA_SPARSE_NORMALIZED]", { + isSparseWardrobe: auraContext?.isSparseWardrobe ?? false, + categoryCounts: auraContext?.categoryCounts ?? null, + detectedGaps: { + missingCore: gapContext?.missingCore?.map((gap) => gap.label) ?? [], + weakAreas: gapContext?.weakAreas?.map((gap) => gap.label) ?? [], + }, + missingPieces: response.missingPieces ?? [], + upgradeSuggestions: response.upgradeSuggestions ?? [], + upgradeSuggestionItems: response.upgradeSuggestionItems ?? [], + }); + } + return response; } +async function parseAuraAttachments(uid: string, input: unknown): Promise { + if (!Array.isArray(input)) return []; + const out: AuraAttachment[] = []; + for (const entry of input.slice(0, 8)) { + if (!entry || typeof entry !== "object") continue; + const candidate = entry as Record; + if (candidate.type !== "image") continue; + const uri = String(candidate.uri ?? "").trim(); + if (!uri) continue; + const storagePath = String(candidate.storagePath ?? "").trim() || null; + if (storagePath && !storagePath.startsWith(`users/${uid}/auraAttachments/`)) { + logger.warn("[AURA_ATTACHMENTS] rejected foreign image attachment", { + uidHash: redactUid(uid), + hasStoragePath: !!storagePath, + }); + continue; + } + try { + await validateSafeUrlForFetch(uri); + } catch (error) { + logger.warn("[AURA_ATTACHMENTS] rejected unsafe image attachment URL", { + uidHash: redactUid(uid), + uri: redactUrlForLogs(uri), + reason: error instanceof Error ? error.message : String(error), + }); + continue; + } + logger.info("[AURA_ATTACHMENTS] accepted image attachment", { + uidHash: redactUid(uid), + kind: "image", + mimeType: typeof candidate.mimeType === "string" ? candidate.mimeType : null, + hasStoragePath: !!storagePath, + hasDownloadURL: /^https?:\/\//i.test(uri), + uriHost: safeUrlHost(uri), + validatedMediaSource: storagePath ? "owned_storage_download_url" : "validated_remote_url", + width: typeof candidate.width === "number" ? candidate.width : null, + height: typeof candidate.height === "number" ? candidate.height : null, + }); + out.push({ + type: "image", + uri, + role: typeof candidate.role === "string" ? candidate.role : null, + groupId: typeof candidate.groupId === "string" ? candidate.groupId : null, + mimeType: typeof candidate.mimeType === "string" ? candidate.mimeType : null, + storagePath, + width: typeof candidate.width === "number" ? candidate.width : null, + height: typeof candidate.height === "number" ? candidate.height : null, + }); + } + return out; +} + +function attachmentContextText(attachments: AuraAttachment[]) { + if (!attachments.length) return "No attachments."; + return JSON.stringify( + attachments.map((attachment, index) => ({ + index, + type: attachment.type, + role: attachment.role ?? null, + groupId: attachment.groupId ?? null, + mimeType: attachment.mimeType ?? null, + storagePath: attachment.storagePath ?? null, + width: attachment.width ?? null, + height: attachment.height ?? null, + uri: redactUrlForLogs(attachment.uri), + })), + null, + 2 + ); +} + +function safeUrlHost(uri: string | undefined) { + if (!uri) return null; + try { + return new URL(uri).host; + } catch { + return null; + } +} + +function auraLinkErrorMessage(error: unknown) { + if (error instanceof ProductLinkError) { + if (error.code === "invalid_url") return "That product link does not look valid."; + if (error.code === "unsafe_url") return "I cannot fetch that kind of link."; + if (error.code === "blocked_store") { + return "This store blocked automatic reading. Try another link or upload a screenshot/photo of the item."; + } + if (error.code === "fetch_failed") { + return "I couldn't read that product link. Try another link or upload a photo."; + } + if (error.code === "no_metadata") { + return "I couldn't find product details on that page. Try another link or upload a photo."; + } + if (error.code === "no_images") { + return "I couldn't find usable product images on that page. Try another link or upload a photo."; + } + if (error.code === "draft_failed") { + return "I found the product, but couldn't create a wardrobe draft."; + } + } + return "I couldn't read that product link. Try another link or upload a photo."; +} + +function productContextText( + products: Awaited>[], +) { + return JSON.stringify( + products.map((product) => ({ + metadata: product.metadata, + imageUrls: product.imageUrls.slice(0, 4), + })), + null, + 2, + ); +} + +type ClientAuraIntent = + | "add_item" + | "add_items_batch" + | "add_from_link" + | "add_from_links_batch" + | string + | null; + +function imageGroupsForAddIntent( + text: string, + attachments: AuraAttachment[], + clientIntent?: ClientAuraIntent, +) { + const images = attachments.filter( + (attachment): attachment is AuraAttachment & { uri: string } => + attachment.type === "image" && !!attachment.uri, + ); + const forceAdd = + clientIntent === "add_item" || clientIntent === "add_items_batch"; + if (!images.length || (!forceAdd && !ADD_IMAGE_RE.test(text))) return []; + const shouldSplit = + images.length > 1 && + (clientIntent === "add_items_batch" || + images[0]?.role === "separate_items" || + BATCH_IMAGE_RE.test(text)); + return shouldSplit ? images.map((image) => [image.uri]) : [images.map((image) => image.uri)]; +} + +function logCallableCandidatePayload( + uid: string, + source: "image" | "link", + data: ReturnType, +) { + const payload = { ok: true, data }; + const serialized = JSON.stringify(payload); + logger.info("[AURA_CANDIDATE_BACKEND] callable final candidate payload", { + uidHash: redactUid(uid), + source, + dataKeys: Object.keys(data), + payloadKeys: Object.keys(payload), + candidateItemsCount: data.candidateItems.length, + candidatesCount: data.candidates.length, + presentation: data.presentation, + serializedLength: serialized.length, + }); +} + export const askAura = onCall( - { secrets: ["OPENAI_API_KEY"] }, + { secrets: ["OPENAI_API_KEY"], timeoutSeconds: 120 }, async (request) => { + logger.info("[AURA_BACKEND_VERSION] askAura callable entry", { + version: AURA_BACKEND_VERSION, + requestKeys: Object.keys(request.data ?? {}), + promptLength: typeof request.data?.message === "string" ? request.data.message.length : 0, + attachmentCount: Array.isArray(request.data?.attachments) ? request.data.attachments.length : 0, + clientIntent: typeof request.data?.clientIntent === "string" ? request.data.clientIntent : null, + }); const uid = request.auth?.uid; if (!uid) { throw new HttpsError("unauthenticated", "User must be signed in."); } + await assertFunctionRateLimit(uid, "auraChat", RATE_LIMITS.auraChat); - const userMessage = String(request.data?.message || "").trim(); + const userMessage = sanitizeUserInput(String(request.data?.message || "")); + const styleCoreNote = styleCoreNoteFromClientContext(request.data?.clientContext); + const requiredItemIds = requiredItemIdsFromClientContext(request.data?.clientContext); + await assertRequiredItemsBelongToUser(uid, requiredItemIds); + const attachments = await parseAuraAttachments(uid, request.data?.attachments); + const clientIntent = typeof request.data?.clientIntent === "string" ? request.data.clientIntent : null; + const linkIntent = classifyAuraLinkIntent(userMessage); + const detectedUrls = extractUrlsFromText(userMessage); + const uidHash = redactUid(uid); + logger.info("[AURA_SEND] callable request received", { + uidHash, + hasMessage: !!userMessage, + linkIntent, + clientIntent, + detectedUrlCount: detectedUrls.length, + detectedDomains: detectedUrls.map((entry) => safeUrlHost(entry.normalized)), + attachmentCount: attachments.length, + attachments: attachments.map((attachment) => ({ + type: attachment.type, + role: attachment.role ?? null, + groupId: attachment.groupId ?? null, + mimeType: attachment.mimeType ?? null, + hasStoragePath: !!attachment.storagePath, + width: attachment.width ?? null, + height: attachment.height ?? null, + hasUri: !!attachment.uri, + uriHost: safeUrlHost(attachment.uri), + })), + }); const history = Array.isArray(request.data?.history) ? request.data.history .map((entry: unknown) => { if (!entry || typeof entry !== "object") return null; const candidate = entry as Record; const role = candidate.role === "assistant" ? "assistant" : candidate.role === "user" ? "user" : null; - const text = String(candidate.text ?? "").trim(); + const text = sanitizeUserInput(String(candidate.text ?? "")); if (!role || !text) return null; return { role, text }; }) @@ -142,8 +963,153 @@ export const askAura = onCall( const selectedDate = request.data?.selectedDate || null; const occasion = request.data?.occasion || null; - if (!userMessage) { - throw new HttpsError("invalid-argument", "Message is required."); + if (!userMessage && attachments.length === 0) { + throw new HttpsError("invalid-argument", "Message or attachment is required."); + } + + const client = new OpenAI({ + apiKey: requireOpenAiApiKey(), + }); + const imageCandidateGroups = imageGroupsForAddIntent(userMessage, attachments, clientIntent); + const shouldAnalyzeOutfitPhoto = isOutfitPhotoRequest({ clientIntent, userMessage, attachments }); + logger.info("[AURA_INTENT] callable selected intent", { + uidHash, + selectedIntent: shouldAnalyzeOutfitPhoto + ? "outfit_photo_analysis" + : imageCandidateGroups.length > 0 + ? "image_candidate_preview" + : detectedUrls.length > 0 + ? "product_link" + : "general_chat", + clientIntent, + attachmentCount: attachments.length, + imageAttachmentCount: attachments.filter((attachment) => attachment.type === "image").length, + hasVisionPayload: attachments.some((attachment) => attachment.type === "image" && !!attachment.uri), + }); + + if (shouldAnalyzeOutfitPhoto) { + logger.info("[AURA_OUTFIT_PHOTO] callable outfit analysis intent classified", { + uidHash, + clientIntent, + attachmentCount: attachments.length, + attachments: attachments.map((attachment) => ({ + type: attachment.type, + mimeType: attachment.mimeType ?? null, + hasStoragePath: !!attachment.storagePath, + uriHost: safeUrlHost(attachment.uri), + width: attachment.width ?? null, + height: attachment.height ?? null, + })), + }); + try { + const data = await analyzeOutfitPhoto({ client, attachments, userMessage }); + return { ok: true, data }; + } catch (error) { + logger.error("[AURA_OUTFIT_PHOTO] callable vision analysis failed", { + uidHash, + clientIntent, + attachmentCount: attachments.length, + error: safeAuraVisionError(error), + }); + return { + ok: true, + data: fallbackAuraResponse("I couldn't read that image as a supported photo. Try sending it again as a JPEG or PNG."), + }; + } + } + + if (imageCandidateGroups.length > 0) { + logger.info("[AURA_CANDIDATE] image add intent classified", { + uidHash, + candidateCount: imageCandidateGroups.length, + }); + try { + let candidates = await extractImageCandidates({ + client, + imageGroups: imageCandidateGroups, + }); + if (!candidates.length) { + logger.warn("[AURA_CANDIDATE_BACKEND] callable image extraction returned no candidates; using fallback", { + uidHash, + groupCount: imageCandidateGroups.length, + }); + candidates = fallbackImageCandidates(imageCandidateGroups); + } + logger.info("[AURA_CANDIDATE] callable image preview response", { + uidHash, + candidateCount: candidates.length, + candidateIdsCount: candidates.length, + }); + const data = candidatePreviewResponse(candidates); + logger.info("[AURA_CANDIDATE_BACKEND] callable raw image candidate object", { + uidHash, + keys: Object.keys(data), + candidateItemsCount: data.candidateItems.length, + }); + logCallableCandidatePayload(uid, "image", data); + return { + ok: true, + data, + }; + } catch (error) { + logger.error("[AURA_LINK_ERROR] image candidate extraction failed", { + uidHash, + error: error instanceof Error ? error.message : String(error), + }); + return { + ok: true, + data: fallbackAuraResponse("I couldn't read that item photo. Try another photo or add it manually."), + }; + } + } + + if ( + linkIntent === "add_from_link" || + linkIntent === "add_from_links_batch" || + clientIntent === "add_from_link" || + clientIntent === "add_from_links_batch" + ) { + if (!detectedUrls.length) { + throw new HttpsError("invalid-argument", "A product link is required."); + } + logger.info("[AURA_LINK] add intent classified", { + uidHash, + linkIntent, + urlCount: detectedUrls.length, + }); + try { + const extractions = []; + for (const detectedUrl of detectedUrls.slice(0, 5)) { + const extraction = await extractProductFromUrl(detectedUrl.normalized); + extractions.push(await rankProductExtractionImages({ client, extraction })); + } + logger.info("[AURA_CANDIDATE] callable link preview response", { + uidHash, + candidateCount: extractions.length, + domains: extractions.map((extraction) => extraction.metadata.domain), + }); + const data = candidatePreviewResponse(candidatesFromProductExtractions(extractions)); + logger.info("[AURA_CANDIDATE_BACKEND] callable raw link candidate object", { + uidHash, + keys: Object.keys(data), + candidateItemsCount: data.candidateItems.length, + }); + logCallableCandidatePayload(uid, "link", data); + return { + ok: true, + data, + }; + } catch (error) { + logger.error("[AURA_LINK_ERROR] add from link failed", { + uidHash, + linkIntent, + error: error instanceof Error ? error.message : String(error), + }); + return { + ok: true, + data: fallbackAuraResponse(auraLinkErrorMessage(error)), + }; + } } const itemsSnap = await db.collection("users").doc(uid).collection("items").get(); @@ -152,23 +1118,57 @@ export const askAura = onCall( ...doc.data(), })) as (Record & { id: string })[]; + const laundryResponse = await handleLaundryIntent({ db, uid, message: userMessage, items }); + if (laundryResponse) { + return { ok: true, data: laundryResponse }; + } + + const [auraMemory, userProfile] = await Promise.all([ + loadCompactAuraMemoryContext( + db, + uid, + typeof request.data?.chatId === "string" ? request.data.chatId : null + ), + loadAuraUserProfile(uid), + ]); const auraContext = buildAuraContext({ items, weather: request.data?.weather || null, occasion, selectedDate, + memory: auraMemory, + userProfile, }); logger.info("AURA closet context", { - counts: auraContext.counts, - footwearAvailable: auraContext.wardrobeDebug?.footwearAvailable ?? [], - excludedFootwear: auraContext.wardrobeDebug?.excludedFootwear ?? [], + itemCount: items.length, + isSparseWardrobe: auraContext.isSparseWardrobe, + categoryKeyCount: Object.keys(auraContext.categoryCounts ?? {}).length, + gapKeyCount: Object.keys(auraContext.wardrobeGaps ?? {}).length, + footwearAvailableCount: auraContext.wardrobeDebug?.footwearAvailable?.length ?? 0, + excludedFootwearCount: auraContext.wardrobeDebug?.excludedFootwear?.length ?? 0, }); - const userProfile = await loadAuraUserProfile(uid); + let linkProductContext = "No product links."; + if (linkIntent === "analyze_link" && detectedUrls.length > 0) { + logger.info("[AURA_LINK] analyze intent classified", { + uidHash, + urlCount: detectedUrls.length, + }); + try { + const products = []; + for (const detectedUrl of detectedUrls.slice(0, 3)) { + products.push(await extractProductFromUrl(detectedUrl.normalized)); + } + linkProductContext = productContextText(products); + } catch (error) { + logger.error("[AURA_LINK_ERROR] analyze link extraction failed", { + uidHash, + error: error instanceof Error ? error.message : String(error), + }); + linkProductContext = auraLinkErrorMessage(error); + } + } try { - const client = new OpenAI({ - apiKey: process.env.OPENAI_API_KEY, - }); const response = await client.responses.create({ model: "gpt-5.4", input: [ @@ -184,9 +1184,23 @@ export const askAura = onCall( text: `User profile:\n${JSON.stringify(userProfile, null, 2)}\n\n` + `Aura context:\n${JSON.stringify(auraContext, null, 2)}\n\n` + + `Stylist brief:\n${auraContext.stylistBrief || "Personalize lightly."}\n\n` + + `Style core note:\n${styleCoreNote || "None."}\n\n` + + `Required item guard:\n${requiredItemsPromptNote(requiredItemIds)}\n\n` + `Recent conversation:\n${JSON.stringify(history, null, 2)}\n\n` + - `User request:\n${userMessage}`, + `Product link context:\n${linkProductContext}\n\n` + + `Attachments:\n${attachmentContextText(attachments)}\n\n` + + `User request:\n${userMessage}` + + buildMultiLookRequestNote(userMessage), }, + ...attachments + .filter((attachment) => attachment.type === "image") + .slice(0, 2) + .map((attachment) => ({ + type: "input_image" as const, + image_url: attachment.uri ?? "", + detail: "low" as const, + })), ], }, ], @@ -234,6 +1248,29 @@ export const askAura = onCall( type: "string", description: "One short direct swap suggestion. Leave empty if not needed.", }, + missingPieces: { + type: "array", + description: "Short labels for missing or thin wardrobe areas.", + items: { type: "string" }, + }, + upgradeSuggestions: { + type: "array", + description: "Short, human-readable upgrade ideas for improving future outfits.", + items: { type: "string" }, + }, + upgradeSuggestionItems: { + type: "array", + description: "Optional structured suggestion metadata for future shopping hooks.", + items: { + type: "object", + additionalProperties: false, + properties: { + label: { type: "string" }, + searchQuery: { type: "string" }, + }, + required: ["label"], + }, + }, chips: { type: "array", description: "Useful next prompts for AURA, short and action-oriented.", @@ -250,6 +1287,8 @@ export const askAura = onCall( vibe: { type: "string" }, shortExplanation: { type: "string" }, stylingNote: { type: "string" }, + personalizationLabel: { type: "string" }, + personalizationNote: { type: "string" }, pieces: { type: "array", items: { @@ -272,7 +1311,7 @@ export const askAura = onCall( type: "array", items: { type: "string", - enum: ["saveLook", "planForToday", "showMoreLikeThis", "shopMissingPieces", "useOnlyMyCloset", "makeItDressier"], + enum: ["saveLook", "planForToday", "likeLook", "notMyVibe", "showMoreLikeThis", "lessLikeThis", "shopMissingPieces", "useOnlyMyCloset", "makeItDressier"], }, }, }, @@ -280,6 +1319,47 @@ export const askAura = onCall( }, ], }, + lookOptions: { + type: "array", + items: { + type: "object", + additionalProperties: false, + properties: { + lookTitle: { type: "string" }, + vibe: { type: "string" }, + shortExplanation: { type: "string" }, + stylingNote: { type: "string" }, + personalizationLabel: { type: "string" }, + personalizationNote: { type: "string" }, + pieces: { + type: "array", + items: { + type: "object", + additionalProperties: false, + properties: { + role: { type: "string", enum: ["top", "bottom", "shoes", "outerwear", "accessory"] }, + itemName: { type: "string" }, + source: { type: "string", enum: ["closet", "suggested"] }, + itemId: { type: ["string", "null"] }, + imageUrl: { type: ["string", "null"] }, + }, + required: ["role", "itemName", "source", "itemId", "imageUrl"], + }, + }, + fromCloset: { type: "array", items: { type: "string" } }, + addToComplete: { type: "array", items: { type: "string" } }, + alternates: { type: "array", items: { type: "string" } }, + actions: { + type: "array", + items: { + type: "string", + enum: ["saveLook", "planForToday", "likeLook", "notMyVibe", "showMoreLikeThis", "lessLikeThis", "shopMissingPieces", "useOnlyMyCloset", "makeItDressier"], + }, + }, + }, + required: ["lookTitle", "vibe", "shortExplanation", "stylingNote", "pieces", "fromCloset", "addToComplete", "alternates", "actions"], + }, + }, }, required: [ "presentation", @@ -290,8 +1370,12 @@ export const askAura = onCall( "ownedPieces", "recommendedAdditions", "swapSuggestion", + "missingPieces", + "upgradeSuggestions", + "upgradeSuggestionItems", "chips", "look", + "lookOptions", ], }, }, @@ -306,13 +1390,48 @@ export const askAura = onCall( } catch { parsed = fallbackAuraResponse(raw); } + logger.info("[AURA_MULTI] structured response parsed", { + uidHash, + multiRequested: wantsMultipleLooks(userMessage), + requestedCount: parseRequestedLookCount(userMessage), + rawPresentation: parsed.presentation, + rawHasLook: !!parsed.look, + rawLookOptionsCount: parsed.lookOptions?.length ?? 0, + rawOwnedPiecesCount: parsed.ownedPieces?.length ?? 0, + rawOutfitItemsCount: parsed.outfitItems?.length ?? 0, + }); + const normalized = normalizeAuraResponse(parsed, userMessage, auraContext, items); + logger.info("[AURA_MULTI] structured response normalized", { + uidHash, + multiRequested: wantsMultipleLooks(userMessage), + normalizedPresentation: normalized.presentation, + normalizedHasLook: !!normalized.look, + normalizedLookOptionsCount: normalized.lookOptions?.length ?? 0, + normalizedOwnedPiecesCount: normalized.ownedPieces?.length ?? 0, + missingPiecesCount: normalized.missingPieces?.length ?? 0, + upgradeSuggestionsCount: normalized.upgradeSuggestions?.length ?? 0, + }); + logger.info("[AURA_RESPONSE_FINAL] callable final response", { + uidHash, + presentation: normalized.presentation, + hasLook: !!normalized.look, + lookOptionsCount: normalized.lookOptions?.length ?? 0, + ownedPiecesCount: normalized.ownedPieces?.length ?? 0, + recommendedAdditionsCount: normalized.recommendedAdditions?.length ?? 0, + missingPiecesCount: normalized.missingPieces?.length ?? 0, + upgradeSuggestionsCount: normalized.upgradeSuggestions?.length ?? 0, + outfitItemsCount: normalized.outfitItems?.length ?? 0, + titleLength: String(normalized.title ?? "").length, + replyLength: String(normalized.reply ?? "").length, + }); - return { ok: true, data: normalizeAuraResponse(parsed, auraContext) }; + return { ok: true, data: normalized }; } catch (error) { - logger.error("askAura failed", { - uid, - message: userMessage, - error, + logger.error("[AURA_ERROR] askAura failed", { + uidHash, + messageLength: userMessage.length, + attachmentCount: attachments.length, + error: error instanceof Error ? error.message : String(error), }); throw new HttpsError("internal", "Aura could not respond right now."); } diff --git a/functions/src/askAuraStream.ts b/functions/src/askAuraStream.ts index eeba31c..1e01577 100644 --- a/functions/src/askAuraStream.ts +++ b/functions/src/askAuraStream.ts @@ -1,17 +1,208 @@ import { getAuth } from "firebase-admin/auth"; import { getFirestore } from "firebase-admin/firestore"; import { logger } from "firebase-functions/v2"; -import { onRequest } from "firebase-functions/v2/https"; +import { HttpsError, onRequest } from "firebase-functions/v2/https"; import OpenAI from "openai"; import { AURA_INSTRUCTIONS, AURA_STREAM_INSTRUCTIONS } from "./shared/auraPrompt"; +import { + candidatePreviewResponse, + candidatesFromProductExtractions, + extractImageCandidates, + fallbackImageCandidates, + rankProductExtractionImages, + type AuraCandidateItem, +} from "./shared/auraCandidatePreview"; +import { classifyAuraLinkIntent, extractUrlsFromText } from "./shared/auraLinkIntent"; import { buildAuraContext } from "./shared/buildAuraContext"; +import { loadCompactAuraMemoryContext } from "./shared/auraMemory"; +import { + analyzeOutfitPhoto, + isOutfitPhotoRequest, + safeAuraVisionError, +} from "./shared/auraOutfitPhotoAnalysis"; +import { dedupeAuraLookAccessories } from "./shared/auraAccessorySelection"; +import { type WardrobeGapSuggestion } from "./shared/detectWardrobeGaps"; import { loadAuraUserProfile } from "./shared/loadAuraUserProfile"; +import { handleLaundryIntent } from "./shared/laundryIntent"; +import { extractProductUrlMetadata } from "./shared/productUrlMetadata"; +import { + ProductLinkError, + extractProductFromUrl, +} from "./shared/productLinkExtractor"; +import { requireOpenAiApiKey } from "./shared/env"; +import { + attachmentContextText, + buildAuraVisionImageInputs, + imageGroupsForAddIntent, + parseAuraAttachments, + safeUrlHost, +} from "./shared/auraStreamAttachments"; +import { + buildCompactAuraContextForSimpleChat, + buildMultiLookRequestNote, + parseRequestedLookCount, + shouldUseSimpleChatPath, + wantsMultipleLooks, +} from "./shared/auraStreamRouting"; +import { + setupAuraStreamResponse, + writeError, + writeFinal, + writeStatus, + writeTextDelta, + type AuraStreamWriter, +} from "./shared/auraStreamSse"; +import { + buildUrlCandidatePreview, + isHmProductUrl, + type AuraLinkPreview, +} from "./shared/auraUrlCandidatePreview"; +import { + RATE_LIMITS, + assertFunctionRateLimit, + isRateLimitError, + redactUid, +} from "./shared/rateLimit"; +import { buildManualOutfitLookFromPrompt } from "./shared/manualOutfitLook"; +import { buildConcreteOutfitLook } from "./shared/auraConcreteLook"; +import { scoreLookStylingFromPieces } from "./shared/styling/stylingScore"; +import type { + StylingItem, + StylingScoreResult, +} from "./shared/styling/types"; const db = getFirestore(); +const AURA_BACKEND_VERSION = "candidate-preview-url-v9-zara-product-api"; +const DEBUG_AURA_SPARSE = + process.env.FUNCTIONS_EMULATOR === "true" || process.env.NODE_ENV !== "production"; +const DEBUG_AURA_DIVERSITY = + process.env.AURA_DEBUG === "1" || + process.env.EXPO_PUBLIC_AURA_DEBUG === "1" || + process.env.FUNCTIONS_EMULATOR === "true"; + +function sanitizeUserInput(input: string): string { + return input + .trim() + .replace(/\0/g, "") + .slice(0, 2000) + .replace(/ignore previous instructions/gi, "") + .replace(/forget everything/gi, "") + .replace(/you are now/gi, "") + .replace(/system:/gi, "") + .replace(/assistant:/gi, ""); +} + +function styleCoreNoteFromClientContext(value: unknown) { + const context = (value && typeof value === "object" + ? (value as Record).minimumCloset + : null) as Record | null; + if (!context || typeof context !== "object") return ""; + + const progress = String(context.styleCoreProgress ?? "").trim(); + const nextBestAdd = String(context.nextBestAdd ?? "").trim(); + const outfitRange = Number(context.outfitRange ?? 0); + const nudge = String(context.nudge ?? "").trim(); + + return [ + progress ? `Style core: ${progress}.` : "", + nextBestAdd ? `Next best add: ${nextBestAdd}.` : "", + Number.isFinite(outfitRange) && outfitRange > 0 ? `Outfit range signal: ${outfitRange}+ paths.` : "", + nudge, + "Use this naturally; do not say minimum closet target, estimated combinations, or missing categories.", + ].filter(Boolean).join(" "); +} + +type ClientOutfitDiversityContext = { + shouldAvoidRepeats: boolean; + reason: "followup" | "multi_look" | "none"; + recentItemIds: string[]; + previousLookItemIds: string[]; + excludedItemIds: string[]; + previousLookSignatures: string[]; + maxOverlap: number; +}; + +function cleanStringList(value: unknown, maxLength: number) { + if (!Array.isArray(value)) return []; + const seen = new Set(); + const out: string[] = []; + for (const entry of value) { + const text = String(entry ?? "").trim(); + if (!text || seen.has(text)) continue; + seen.add(text); + out.push(text); + if (out.length >= maxLength) break; + } + return out; +} + +function requiredItemIdsFromClientContext(value: unknown) { + const context = value && typeof value === "object" + ? (value as Record) + : {}; + return cleanStringList(context.requiredItemIds, 8); +} + +function requiredItemsPromptNote(requiredItemIds: string[]) { + if (!requiredItemIds.length) return "No required closet anchor items."; + return [ + "Required closet anchor:", + `- Every outfit/look option must include these exact closet item IDs: ${requiredItemIds.join(", ")}.`, + "- Do not substitute another closet item for these anchors.", + "- Never mention these raw IDs in the user-facing reply.", + "- If you cannot use the required item, return chat copy explaining that instead of inventing a random outfit.", + ].join("\n"); +} + +async function assertRequiredItemsBelongToUser(uid: string, requiredItemIds: string[]) { + if (!requiredItemIds.length) return; + const snaps = await Promise.all( + requiredItemIds.map((itemId) => db.doc(`users/${uid}/items/${itemId}`).get()), + ); + if (snaps.some((snap) => !snap.exists)) { + throw new HttpsError("failed-precondition", "Selected closet item is no longer available."); + } +} + +function outfitDiversityFromClientContext(value: unknown): ClientOutfitDiversityContext | null { + const context = value && typeof value === "object" + ? (value as Record).outfitDiversity + : null; + if (!context || typeof context !== "object") return null; + const raw = context as Record; + const reason = + raw.reason === "followup" || raw.reason === "multi_look" || raw.reason === "none" + ? raw.reason + : "none"; + return { + shouldAvoidRepeats: raw.shouldAvoidRepeats === true, + reason, + recentItemIds: cleanStringList(raw.recentItemIds, 36), + previousLookItemIds: cleanStringList(raw.previousLookItemIds, 12), + excludedItemIds: cleanStringList(raw.excludedItemIds, 24), + previousLookSignatures: cleanStringList(raw.previousLookSignatures, 12), + maxOverlap: Math.max(0, Math.min(3, Math.round(Number(raw.maxOverlap ?? 2)))), + }; +} + +function outfitDiversityPromptNote(context: ClientOutfitDiversityContext | null) { + if (!context?.shouldAvoidRepeats) return "No recent outfit exclusions."; + return [ + "Outfit diversity guard:", + `- User is asking for ${context.reason === "followup" ? "another/different look" : "distinct multi-look options"}.`, + `- Previous look item IDs: ${context.previousLookItemIds.length ? context.previousLookItemIds.join(", ") : "none"}.`, + `- Recent session item IDs: ${context.recentItemIds.length ? context.recentItemIds.join(", ") : "none"}.`, + `- Excluded item IDs where possible: ${context.excludedItemIds.length ? context.excludedItemIds.join(", ") : "none"}.`, + `- Previous look signatures: ${context.previousLookSignatures.length ? context.previousLookSignatures.join(" / ") : "none"}.`, + `- Do not return an exact same piece combination. Avoid more than ${context.maxOverlap} overlapping closet item IDs with the previous look unless the closet is too limited.`, + "- Prefer changing the top/bottom/shoes anchors first. For safe/balanced/bold, each option needs a distinct anchor combination.", + "- If the closet is too limited, briefly say that meaningful variety is limited, but still change at least one anchor piece.", + ].join("\n"); +} type AuraResponse = { - presentation: "chat" | "card"; + presentation: "chat" | "card" | "candidate_preview" | "outfit_analysis" | "laundry_confirmation"; title: string; reply: string; reason: string; @@ -19,12 +210,21 @@ type AuraResponse = { ownedPieces: string[]; recommendedAdditions: string[]; swapSuggestion: string; + missingPieces?: string[]; + upgradeSuggestions?: string[]; + upgradeSuggestionItems?: { + label: string; + searchQuery?: string; + }[]; chips: string[]; look: { lookTitle: string; vibe: string; shortExplanation: string; stylingNote: string; + personalizationLabel?: string; + personalizationNote?: string; + stylingIntelligence?: StylingScoreResult | null; pieces: { role: "top" | "bottom" | "shoes" | "outerwear" | "accessory"; itemName: string; @@ -36,9 +236,38 @@ type AuraResponse = { addToComplete: string[]; alternates: string[]; actions: ( - "saveLook" | "planForToday" | "showMoreLikeThis" | "shopMissingPieces" | "useOnlyMyCloset" | "makeItDressier" + "saveLook" | "planForToday" | "likeLook" | "notMyVibe" | "showMoreLikeThis" | "lessLikeThis" | "shopMissingPieces" | "useOnlyMyCloset" | "makeItDressier" )[]; } | null; + lookOptions?: { + lookTitle: string; + vibe: string; + shortExplanation: string; + stylingNote: string; + personalizationLabel?: string; + personalizationNote?: string; + stylingIntelligence?: StylingScoreResult | null; + pieces: { + role: "top" | "bottom" | "shoes" | "outerwear" | "accessory"; + itemName: string; + source: "closet" | "suggested"; + itemId: string | null; + imageUrl: string | null; + }[]; + fromCloset: string[]; + addToComplete: string[]; + alternates: string[]; + actions: ( + "saveLook" | "planForToday" | "likeLook" | "notMyVibe" | "showMoreLikeThis" | "lessLikeThis" | "shopMissingPieces" | "useOnlyMyCloset" | "makeItDressier" + )[]; + }[]; + candidates?: AuraCandidateItem[]; + candidateItems?: AuraCandidateItem[]; + stylingIntelligence?: StylingScoreResult | null; + laundryAction?: { + targetStatus: "clean" | "needs_wash" | "in_laundry"; + matches: { itemId: string; label: string; subtitle?: string }[]; + }; }; function shortenLookReply(text: string) { @@ -50,36 +279,451 @@ function shortenLookReply(text: string) { return `${trimmed.slice(0, 117).trimEnd()}...`; } +function uniqueActions(actions: string[]) { + return Array.from(new Set(actions.filter(Boolean))); +} + +const PERSONALIZATION_BLOCKLIST = [ + /stored preferences?/i, + /profile data/i, + /learned memory/i, + /memory suggests/i, + /your profile says/i, + /\bdata suggests\b/i, +]; + +function cleanPersonalizationText(text: string, maxLength: number) { + const trimmed = String(text ?? "") + .replace(/\s+/g, " ") + .replace(/^["'`\-–—\s]+|["'`\-–—\s]+$/g, "") + .trim(); + if (!trimmed) return ""; + if (PERSONALIZATION_BLOCKLIST.some((pattern) => pattern.test(trimmed))) { + return ""; + } + if (trimmed.length <= maxLength) return trimmed; + return `${trimmed.slice(0, maxLength - 3).trimEnd()}...`; +} + +function fallbackPersonalization( + auraContext?: { + preferenceContext?: { + explicitProfile?: { + styleVibes?: string[]; + preferredFits?: string[]; + favoriteColors?: string[]; + experimentationLevel?: "low" | "medium" | "high"; + } | null; + learnedProfile?: { + confidence?: number; + inferredFavoriteColors?: string[]; + inferredFits?: string[]; + } | null; + session?: { + currentOccasion?: string; + vibeForThisSession?: string; + } | null; + } | null; + } +) { + const explicit = auraContext?.preferenceContext?.explicitProfile; + const learned = auraContext?.preferenceContext?.learnedProfile; + const session = auraContext?.preferenceContext?.session; + const learnedConfidence = learned?.confidence ?? 0; + + if (session?.currentOccasion && session?.vibeForThisSession) { + return { + label: `Right for ${session.currentOccasion}`, + note: `It keeps the ${session.vibeForThisSession} tone, but still feels polished enough for ${session.currentOccasion}.`, + }; + } + + if (session?.vibeForThisSession) { + return { + label: `Set to ${session.vibeForThisSession}`, + note: `This keeps the mood pointed toward ${session.vibeForThisSession} without losing polish.`, + }; + } + if (session?.currentOccasion) { + return { + label: `Built for ${session.currentOccasion}`, + note: `It is shaped around ${session.currentOccasion} first, so it feels right for the moment instead of generic.`, + }; + } + if (explicit?.styleVibes?.length && explicit?.favoriteColors?.length) { + const vibe = explicit.styleVibes[0]; + const color = explicit.favoriteColors[0]; + if (explicit.experimentationLevel === "high") { + return { + label: "A touch bolder", + note: `It still sits inside your ${vibe} lane, just with a slightly bolder ${color}-leaning twist.`, + }; + } + return { + label: `Feels like your ${vibe} side`, + note: `It stays close to your ${vibe} direction and keeps the palette near shades that already feel easy on you.`, + }; + } + if (explicit?.preferredFits?.length) { + const fit = explicit.preferredFits[0]; + return { + label: `Leans ${fit} in fit`, + note: `The proportions stay closer to the ${fit} fit you naturally wear well.`, + }; + } + if (explicit?.styleVibes?.length) { + const vibe = explicit.styleVibes[0]; + if (explicit.experimentationLevel === "high") { + return { + label: "A little bolder than usual", + note: `It still sits inside your ${vibe} lane, just with a slightly more adventurous edge.`, + }; + } + if (explicit.experimentationLevel === "low") { + return { + label: "Closer to your usual lane", + note: `This keeps things grounded in the ${vibe} direction you already gravitate toward.`, + }; + } + return { + label: `Works for your ${vibe} style`, + note: `It stays close to the ${vibe} direction that already feels natural on you.`, + }; + } + if (learnedConfidence >= 0.62 && learned?.inferredFavoriteColors?.length) { + return { + label: "Closer to your usual palette", + note: "The palette stays near the colors you seem to keep coming back to, so it still feels familiar in a good way.", + }; + } + if (learnedConfidence >= 0.62 && learned?.inferredFits?.length) { + return { + label: `Feels more ${learned.inferredFits[0]}`, + note: "The proportions stay near the shapes you seem to respond to best.", + }; + } + if (learnedConfidence >= 0.42 && learned?.inferredFavoriteColors?.length) { + return { + label: "Still feels like you", + note: "This keeps the palette in a range that already seems easy for you to wear.", + }; + } + return {label: "", note: ""}; +} + +function stylingItemsFromRecords( + items?: (Record & { id: string })[] | null, +): StylingItem[] { + return (items ?? []).map((item) => ({ + ...(item as StylingItem), + id: item.id, + source: "closet", + })); +} + +function normalizeLookSurface( + look: NonNullable, + auraContext?: { + occasion?: string | null; + wardrobe?: { + footwear?: Record[]; + }; + preferenceContext?: { + explicitProfile?: { + styleVibes?: string[]; + preferredFits?: string[]; + experimentationLevel?: "low" | "medium" | "high"; + favoriteColors?: string[]; + } | null; + learnedProfile?: { + confidence?: number; + inferredFavoriteColors?: string[]; + inferredFits?: string[]; + } | null; + session?: { + currentOccasion?: string; + vibeForThisSession?: string; + } | null; + } | null; + }, + closetItems?: StylingItem[], + userMessage?: string, +) { + if (look.stylingNote) { + look.stylingNote = shortenLookReply(look.stylingNote); + } + + const accessorySelection = dedupeAuraLookAccessories( + look, + auraContext?.preferenceContext ?? null + ); + look.pieces = accessorySelection.pieces; + if (DEBUG_AURA_SPARSE && accessorySelection.discarded.length > 0) { + logger.info("[AURA_ACCESSORY_SLOT]", { + discarded: accessorySelection.discarded.map((entry) => entry.reason), + }); + } + + const ownedPieces = look.pieces + .filter((piece) => piece.source === "closet") + .map((piece) => piece.itemName) + .filter(Boolean); + const suggestedPieces = look.pieces + .filter((piece) => piece.source === "suggested") + .map((piece) => piece.itemName) + .filter(Boolean); + + look.fromCloset = Array.from(new Set(ownedPieces)); + look.addToComplete = Array.from( + new Set(suggestedPieces.filter((piece) => !look.fromCloset.includes(piece))) + ); + look.actions = uniqueActions([ + ...(look.actions ?? []), + "likeLook", + "notMyVibe", + "showMoreLikeThis", + "lessLikeThis", + "useOnlyMyCloset", + "makeItDressier", + "shopMissingPieces", + ]) as NonNullable["actions"]; + + const personalization = fallbackPersonalization(auraContext); + look.personalizationLabel = + cleanPersonalizationText(String(look.personalizationLabel ?? ""), 38) || + personalization.label; + look.personalizationNote = + cleanPersonalizationText(String(look.personalizationNote ?? ""), 150) || + personalization.note; + + const stylingIntelligence = scoreLookStylingFromPieces( + look.pieces, + closetItems ?? [], + { + requestText: userMessage, + occasion: + auraContext?.occasion ?? + auraContext?.preferenceContext?.session?.currentOccasion ?? + null, + vibe: look.vibe, + }, + ); + if (stylingIntelligence) { + look.stylingIntelligence = stylingIntelligence; + } + + return { + ownedPieces: look.fromCloset, + recommendedAdditions: look.addToComplete, + }; +} + +type AuraLookSurface = NonNullable; + +function itemIdsForAuraLook(look?: AuraLookSurface | null) { + return cleanStringList( + (look?.pieces ?? []) + .filter((piece) => piece.source === "closet") + .map((piece) => piece.itemId), + 12, + ); +} + +function signatureForAuraLook(look?: AuraLookSurface | null) { + return itemIdsForAuraLook(look).sort().join("|"); +} + +function overlapWithPreviousLook( + look: AuraLookSurface, + diversity: ClientOutfitDiversityContext +) { + const previous = new Set(diversity.previousLookItemIds); + return itemIdsForAuraLook(look).filter((itemId) => previous.has(itemId)).length; +} + +function isTooSimilarToPrevious( + look: AuraLookSurface, + diversity: ClientOutfitDiversityContext +) { + if (!diversity.shouldAvoidRepeats) return false; + const signature = signatureForAuraLook(look); + if (signature && diversity.previousLookSignatures.includes(signature)) return true; + return overlapWithPreviousLook(look, diversity) > diversity.maxOverlap; +} + +function enforceOutfitDiversity( + response: AuraResponse, + diversity: ClientOutfitDiversityContext | null +): AuraResponse { + if (!diversity?.shouldAvoidRepeats) return response; + const originalLooks = [ + ...(response.lookOptions ?? []), + ...(response.look && !(response.lookOptions ?? []).includes(response.look) ? [response.look] : []), + ].filter(Boolean) as AuraLookSurface[]; + if (!originalLooks.length) return response; + + const selected: AuraLookSurface[] = []; + const selectedSignatures = new Set(); + const rejected = originalLooks.map((look) => { + const itemIds = itemIdsForAuraLook(look); + const signature = itemIds.slice().sort().join("|"); + const previousOverlap = overlapWithPreviousLook(look, diversity); + const duplicateWithinResponse = !!signature && selectedSignatures.has(signature); + const tooSimilar = isTooSimilarToPrevious(look, diversity); + const selectedOverlapTooHigh = + selected.some((existing) => { + const existingIds = new Set(itemIdsForAuraLook(existing)); + const overlap = itemIds.filter((itemId) => existingIds.has(itemId)).length; + return overlap > Math.max(1, diversity.maxOverlap); + }); + const reject = duplicateWithinResponse || tooSimilar || selectedOverlapTooHigh; + if (!reject) { + selected.push(look); + if (signature) selectedSignatures.add(signature); + } + return { + title: look.lookTitle, + itemIds, + signature, + previousOverlap, + duplicateWithinResponse, + tooSimilar, + selectedOverlapTooHigh, + rejected: reject, + }; + }); + + if (DEBUG_AURA_DIVERSITY) { + logger.info("[AURA_DIVERSITY] final look guard", { + previousItemIds: diversity.previousLookItemIds, + excludedItemIds: diversity.excludedItemIds, + recentItemIds: diversity.recentItemIds, + previousLookSignatures: diversity.previousLookSignatures, + maxOverlap: diversity.maxOverlap, + candidates: rejected, + selected: selected.map((look) => ({ + title: look.lookTitle, + itemIds: itemIdsForAuraLook(look), + signature: signatureForAuraLook(look), + overlap: overlapWithPreviousLook(look, diversity), + })), + }); + } + + if (!selected.length) { + return { + ...fallbackAuraResponse("I need more usable pieces to make this meaningfully different."), + title: "More pieces needed", + presentation: "chat" as const, + }; + } + + if (response.lookOptions?.length) { + response.lookOptions = selected.slice(0, response.lookOptions.length); + response.look = response.lookOptions[0] ?? null; + } else if (response.look && selected[0]) { + response.look = selected[0]; + } + + return response; +} + function normalizeAuraResponse( response: AuraResponse, + userMessage?: string, auraContext?: { + occasion?: string | null; wardrobe?: { footwear?: Record[]; }; - } + categoryCounts?: { + tops: number; + bottoms: number; + footwear: number; + outerwear: number; + accessories: number; + }; + isSparseWardrobe?: boolean; + wardrobeGaps?: { + missingCore?: { label: string }[]; + weakAreas?: { label: string }[]; + suggestions?: WardrobeGapSuggestion[]; + }; + preferenceContext?: { + explicitProfile?: { + styleVibes?: string[]; + preferredFits?: string[]; + experimentationLevel?: "low" | "medium" | "high"; + } | null; + learnedProfile?: { + confidence?: number; + inferredFavoriteColors?: string[]; + inferredFits?: string[]; + } | null; + session?: { + currentOccasion?: string; + vibeForThisSession?: string; + } | null; + } | null; + }, + closetRecords?: (Record & { id: string })[] | null, + diversity?: ClientOutfitDiversityContext | null ) { - if (response.look) { - response.reply = shortenLookReply(response.reply); - response.reason = ""; - if (response.look.stylingNote) { - response.look.stylingNote = shortenLookReply(response.look.stylingNote); + const multiRequested = wantsMultipleLooks(userMessage ?? ""); + const closetItems = stylingItemsFromRecords(closetRecords); + if (!response.look && !response.lookOptions?.length && closetRecords?.length) { + const manualLook = buildManualOutfitLookFromPrompt( + userMessage ?? "", + closetRecords, + response, + ); + if (manualLook) { + response.presentation = "card"; + response.title = response.title?.trim() && response.title !== "AURA" + ? response.title + : "Outfit edit"; + response.look = manualLook; + response.outfitItems = manualLook.fromCloset; + response.ownedPieces = manualLook.fromCloset; + response.recommendedAdditions = manualLook.addToComplete; } + } + if (!response.look && !response.lookOptions?.length && closetRecords?.length) { + const concreteLook = buildConcreteOutfitLook({ + userMessage, + response, + closetRecords, + }); + if (concreteLook) { + response.presentation = "card"; + response.title = response.title?.trim() && response.title !== "AURA" + ? response.title + : "Outfit option"; + response.reply = concreteLook.reply; + response.look = concreteLook.look; + response.outfitItems = concreteLook.look.fromCloset; + response.ownedPieces = concreteLook.look.fromCloset; + response.recommendedAdditions = concreteLook.look.addToComplete; + } + } + if (response.lookOptions?.length) { + response.lookOptions = response.lookOptions.slice(0, 3); + } - const ownedPieces = response.look.pieces - .filter((piece) => piece.source === "closet") - .map((piece) => piece.itemName) - .filter(Boolean); - const suggestedPieces = response.look.pieces - .filter((piece) => piece.source === "suggested") - .map((piece) => piece.itemName) - .filter(Boolean); - - response.ownedPieces = Array.from(new Set(ownedPieces)); - response.recommendedAdditions = Array.from( - new Set(suggestedPieces.filter((piece) => !response.ownedPieces.includes(piece))) + if (response.look || response.lookOptions?.length) { + response.reply = /:\n/.test(response.reply) ? response.reply.trim() : shortenLookReply(response.reply); + response.reason = ""; + } + + if (response.look) { + const normalized = normalizeLookSurface( + response.look, + auraContext, + closetItems, + userMessage, ); - response.look.fromCloset = response.ownedPieces; - response.look.addToComplete = response.recommendedAdditions; + response.ownedPieces = normalized.ownedPieces; + response.recommendedAdditions = normalized.recommendedAdditions; const eligibleFootwear = auraContext?.wardrobe?.footwear ?? []; const selectedOwnedFootwear = response.look.pieces.filter( @@ -97,18 +741,85 @@ function normalizeAuraResponse( }); } } - return response; -} + if (!response.look && response.lookOptions?.length) { + response.look = response.lookOptions[0]; + } + if (response.lookOptions?.length) { + response.lookOptions = response.lookOptions.map((look) => { + normalizeLookSurface(look, auraContext, closetItems, userMessage); + return look; + }); + } + if (multiRequested && !response.lookOptions?.length && response.look) { + logger.warn("[AURA_MULTI] stream multi-look request returned only one structured look", { + requestedCount: parseRequestedLookCount(userMessage ?? ""), + lookTitle: response.look.lookTitle, + }); + response.lookOptions = [response.look]; + } + if (multiRequested && !response.look && !response.lookOptions?.length) { + logger.warn("[AURA_MULTI] stream multi-look request returned no structured looks", { + requestedCount: parseRequestedLookCount(userMessage ?? ""), + title: response.title, + reply: response.reply, + ownedPiecesCount: response.ownedPieces?.length ?? 0, + recommendedAdditionsCount: response.recommendedAdditions?.length ?? 0, + outfitItemsCount: response.outfitItems?.length ?? 0, + }); + } -function writeEvent( - res: { - write: (chunk: string) => void; - flush?: () => void; - }, - payload: Record -) { - res.write(`${JSON.stringify(payload)}\n`); - res.flush?.(); + response.stylingIntelligence = + response.look?.stylingIntelligence ?? + response.lookOptions?.[0]?.stylingIntelligence ?? + null; + + const gapContext = auraContext?.wardrobeGaps; + const derivedMissing = uniqueTrimmed([ + ...(response.missingPieces ?? []), + ...(auraContext?.isSparseWardrobe + ? [ + ...(gapContext?.missingCore?.map((gap) => gap.label) ?? []), + ...(gapContext?.weakAreas?.slice(0, 1).map((gap) => gap.label) ?? []), + ] + : gapContext?.missingCore?.map((gap) => gap.label) ?? []), + ]); + const derivedSuggestions = uniqueTrimmed([ + ...(response.upgradeSuggestions ?? []), + ...((gapContext?.suggestions ?? []).map((suggestion) => suggestion.label) ?? []), + ]); + + const maxSuggestionCount = auraContext?.isSparseWardrobe ? 3 : 2; + response.missingPieces = derivedMissing.slice(0, maxSuggestionCount); + response.upgradeSuggestions = derivedSuggestions + .filter((suggestion) => !response.missingPieces?.includes(suggestion)) + .slice(0, maxSuggestionCount); + response.upgradeSuggestionItems = normalizeSuggestionItems( + response.upgradeSuggestionItems, + response.upgradeSuggestions, + gapContext?.suggestions ?? [] + ).filter((item) => !response.missingPieces?.includes(item.label)); + + if (!auraContext?.isSparseWardrobe && !(gapContext?.missingCore?.length ?? 0)) { + response.missingPieces = response.missingPieces?.slice(0, 1) ?? []; + response.upgradeSuggestions = response.upgradeSuggestions?.slice(0, 1) ?? []; + response.upgradeSuggestionItems = response.upgradeSuggestionItems?.slice(0, 1) ?? []; + } + + if (DEBUG_AURA_SPARSE) { + logger.info("[AURA_SPARSE_NORMALIZED]", { + isSparseWardrobe: auraContext?.isSparseWardrobe ?? false, + categoryCounts: auraContext?.categoryCounts ?? null, + detectedGaps: { + missingCore: gapContext?.missingCore?.map((gap) => gap.label) ?? [], + weakAreas: gapContext?.weakAreas?.map((gap) => gap.label) ?? [], + }, + missingPieces: response.missingPieces ?? [], + upgradeSuggestions: response.upgradeSuggestions ?? [], + upgradeSuggestionItems: response.upgradeSuggestionItems ?? [], + }); + } + + return enforceOutfitDiversity(response, diversity ?? null); } function fallbackAuraResponse(reply: string): AuraResponse { @@ -121,20 +832,282 @@ function fallbackAuraResponse(reply: string): AuraResponse { ownedPieces: [], recommendedAdditions: [], swapSuggestion: "", + missingPieces: [], + upgradeSuggestions: [], + upgradeSuggestionItems: [], chips: [], look: null, + lookOptions: [], + }; +} + +function uniqueTrimmed(values: string[] | undefined, max = 3) { + return Array.from( + new Set( + (values ?? []) + .map((value) => String(value ?? "").trim()) + .filter(Boolean) + ) + ).slice(0, max); +} + +function normalizeSuggestionItems( + items: AuraResponse["upgradeSuggestionItems"] | undefined, + fallbackSuggestions: string[], + contextSuggestions: WardrobeGapSuggestion[] +) { + const entries: [string, { label: string; searchQuery?: string }][] = []; + for (const item of items ?? []) { + const label = String(item?.label ?? "").trim(); + if (!label) continue; + const searchQuery = String(item?.searchQuery ?? "").trim(); + entries.push([label.toLowerCase(), { label, searchQuery: searchQuery || undefined }]); + } + const normalizedItems = Array.from(new Map(entries).values()); + + if (normalizedItems.length) return normalizedItems.slice(0, 3); + + return fallbackSuggestions + .map((label) => { + const match = contextSuggestions.find( + (suggestion) => suggestion.label.toLowerCase() === label.toLowerCase() + ); + return { + label, + searchQuery: match?.searchQuery, + }; + }) + .filter((item) => item.label) + .slice(0, 3); +} + +function auraLinkErrorMessage(error: unknown) { + if (error instanceof ProductLinkError) { + if (error.code === "invalid_url") return "That product link does not look valid."; + if (error.code === "unsafe_url") return "I cannot fetch that kind of link."; + if (error.code === "blocked_store") { + return "This store blocked automatic reading. Try another link or upload a screenshot/photo of the item."; + } + if (error.code === "fetch_failed") { + return "I couldn't read that product link. Try another link or upload a photo."; + } + if (error.code === "no_metadata") { + return "I couldn't find product details on that page. Try another link or upload a photo."; + } + if (error.code === "no_images") { + return "I couldn't find usable product images on that page. Try another link or upload a photo."; + } + if (error.code === "draft_failed") { + return "I found the product, but couldn't create a wardrobe draft."; + } + } + return "I couldn't read that product link. Try another link or upload a photo."; +} + +function productContextText( + products: Awaited>[], +) { + return JSON.stringify( + products.map((product) => ({ + metadata: product.metadata, + imageUrls: product.imageUrls.slice(0, 4), + })), + null, + 2, + ); +} + +function logStreamCandidateEmit( + uid: string, + source: "image" | "link", + data: ReturnType, +) { + const payload = { type: "final", data }; + const serialized = JSON.stringify(payload); + logger.info("[AURA_STREAM_EMIT] candidate final payload", { + uidHash: redactUid(uid), + source, + dataKeys: Object.keys(data), + payloadKeys: Object.keys(payload), + candidateItemsCount: data.candidateItems.length, + candidatesCount: data.candidates.length, + presentation: data.presentation, + serializedLength: serialized.length, + }); +} + +function brandFromSourceUrl(sourceUrl?: string | null) { + try { + const host = new URL(String(sourceUrl ?? "")).hostname.replace(/^www\d*\./, "").toLowerCase(); + if (host.endsWith("hm.com")) return "H&M"; + if (host.endsWith("zara.com")) return "Zara"; + if (host.endsWith("nike.com")) return "Nike"; + const domain = host.split(".")[0] ?? ""; + return domain ? domain.charAt(0).toUpperCase() + domain.slice(1) : null; + } catch { + return null; + } +} + +function isLikelyProductLinkForReview(sourceUrl?: string | null) { + try { + const url = new URL(String(sourceUrl ?? "")); + const host = url.hostname.toLowerCase(); + if ( + host.includes("amazon.") || + host.endsWith("hm.com") || + host.endsWith("zara.com") || + host.endsWith("nike.com") + ) { + return true; + } + return /\b(product|productpage|pdp|item|dp|gp\/product)\b/i.test(url.pathname); + } catch { + return false; + } +} + +function fallbackLinkPreviewFromUrl(sourceUrl: string): AuraLinkPreview | null { + if (!isLikelyProductLinkForReview(sourceUrl)) return null; + try { + const url = new URL(sourceUrl); + const brand = brandFromSourceUrl(sourceUrl); + const articleId = isHmProductUrl(sourceUrl) + ? url.pathname.match(/\/productpage\.(\d+)\.html$/i)?.[1] ?? null + : null; + const hostLabel = url.hostname.replace(/^www\d*\./i, ""); + return { + sourceUrl: url.toString(), + title: brand ? `${brand} product link` : `${hostLabel} product link`, + imageUrl: null, + imageUrls: [], + description: articleId ? `Product ${articleId}` : null, + brand, + status: "needs_review", + }; + } catch { + return null; + } +} + +function cleanPreviewText(value: unknown, maxLength = 500) { + const text = String(value ?? "").replace(/\s+/g, " ").trim(); + return text ? text.slice(0, maxLength) : null; +} + +function normalizePreviewImage(sourceUrl: URL, value: unknown) { + const raw = String(value ?? "").trim(); + if (!raw || raw.startsWith("data:")) return null; + try { + const imageUrl = new URL(raw, sourceUrl); + if (imageUrl.protocol !== "http:" && imageUrl.protocol !== "https:") return null; + imageUrl.hash = ""; + return imageUrl.toString(); + } catch { + return null; + } +} + +function clientLinkPreviewFromRequest(value: unknown, detectedUrl: string | null): AuraLinkPreview | null { + if (!value || typeof value !== "object" || !detectedUrl) return null; + const candidate = value as Record; + if (typeof candidate.sourceUrl !== "string") return null; + try { + const detected = new URL(detectedUrl); + const source = new URL(candidate.sourceUrl); + detected.hash = ""; + source.hash = ""; + if (detected.toString() !== source.toString()) return null; + const imageUrl = normalizePreviewImage(source, candidate.imageUrl); + const imageUrls = Array.isArray(candidate.imageUrls) + ? candidate.imageUrls + .map((url) => normalizePreviewImage(source, url)) + .filter((url): url is string => !!url) + : []; + const preview = { + sourceUrl: detected.toString(), + title: cleanPreviewText(candidate.title, 220), + imageUrl, + imageUrls: imageUrls.length ? imageUrls : imageUrl ? [imageUrl] : [], + description: cleanPreviewText(candidate.description, 500), + }; + return preview.imageUrls.length || preview.title || preview.description ? preview : null; + } catch { + return null; + } +} + +function hmSingleImageClientPreview(preview: AuraLinkPreview | null): AuraLinkPreview | null { + if (!preview || !isHmProductUrl(preview.sourceUrl) || !preview.imageUrl) return null; + return { + ...preview, + imageUrls: [preview.imageUrl], }; } +function hmSanitizedClientPreview(preview: AuraLinkPreview | null): AuraLinkPreview | null { + if (!preview || !isHmProductUrl(preview.sourceUrl)) return null; + const urls = [ + ...(Array.isArray(preview.imageUrls) ? preview.imageUrls : []), + preview.imageUrl, + ] + .map((url) => String(url ?? "").trim()) + .filter((url) => { + try { + const parsed = new URL(url); + return parsed.hostname.toLowerCase() === "image.hm.com"; + } catch { + return false; + } + }); + const seen = new Set(); + const imageUrls = urls + .filter((url) => { + const key = url.toLowerCase().replace(/([?&])(imwidth|width|height|w|h)=\d+/g, "$1"); + if (seen.has(key)) return false; + seen.add(key); + return true; + }) + .slice(0, 24); + const imageUrl = imageUrls[0] ?? preview.imageUrl; + return imageUrl + ? { + ...preview, + imageUrl, + imageUrls, + } + : null; +} + +async function emitUrlCandidatePreview(params: { + res: AuraStreamWriter & { end: () => void }; + client: OpenAI; + uid: string; + metadata: Awaited> | AuraLinkPreview; +}) { + const { data } = await buildUrlCandidatePreview({ + client: params.client, + uid: params.uid, + metadata: params.metadata, + }); + logger.info("[AURA_STREAM_PRE_EMIT] URL candidate final object", { + uidHash: redactUid(params.uid), + branchChosen: "url_candidate_preview", + finalPayloadKeys: Object.keys(data), + presentation: data.presentation, + candidateItemsLength: data.candidateItems.length, + candidatesLength: data.candidates.length, + }); + logStreamCandidateEmit(params.uid, "link", data); + writeTextDelta(params.res, data.reply); + writeFinal(params.res, data); + params.res.end(); +} + export const askAuraStream = onRequest( - { cors: true, secrets: ["OPENAI_API_KEY"] }, + { cors: true, secrets: ["OPENAI_API_KEY"], timeoutSeconds: 120 }, async (req, res) => { - res.setHeader("Content-Type", "application/x-ndjson; charset=utf-8"); - res.setHeader("Cache-Control", "no-cache, no-transform"); - res.setHeader("Connection", "keep-alive"); - res.setHeader("X-Accel-Buffering", "no"); - res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization"); - res.flushHeaders?.(); + setupAuraStreamResponse(res); if (req.method === "OPTIONS") { res.status(204).end(); @@ -155,9 +1128,83 @@ export const askAuraStream = onRequest( } try { + logger.info("[AURA_BACKEND_VERSION] askAuraStream entry", { + version: AURA_BACKEND_VERSION, + method: req.method, + requestKeys: Object.keys(req.body ?? {}), + promptLength: typeof req.body?.message === "string" ? req.body.message.length : 0, + attachmentCount: Array.isArray(req.body?.attachments) ? req.body.attachments.length : 0, + clientIntent: typeof req.body?.clientIntent === "string" ? req.body.clientIntent : null, + }); const decodedToken = await getAuth().verifyIdToken(idToken); const uid = decodedToken.uid; - const userMessage = String(req.body?.message ?? "").trim(); + await assertFunctionRateLimit(uid, "auraChat", RATE_LIMITS.auraChat); + const uidHash = redactUid(uid); + const userMessage = sanitizeUserInput(String(req.body?.message ?? "")); + const styleCoreNote = styleCoreNoteFromClientContext(req.body?.clientContext); + const requiredItemIds = requiredItemIdsFromClientContext(req.body?.clientContext); + await assertRequiredItemsBelongToUser(uid, requiredItemIds); + const outfitDiversity = outfitDiversityFromClientContext(req.body?.clientContext); + const attachments = await parseAuraAttachments(uid, req.body?.attachments); + const clientIntent = typeof req.body?.clientIntent === "string" ? req.body.clientIntent : null; + const linkIntent = classifyAuraLinkIntent(userMessage); + const detectedUrls = extractUrlsFromText(userMessage); + const firstDetectedUrl = detectedUrls[0]?.normalized ?? null; + const rawClientLinkPreview = clientLinkPreviewFromRequest(req.body?.linkPreview, firstDetectedUrl); + const hmClientFallback = hmSanitizedClientPreview(rawClientLinkPreview) ?? hmSingleImageClientPreview(rawClientLinkPreview); + const clientLinkPreview = isHmProductUrl(firstDetectedUrl) ? null : rawClientLinkPreview; + const effectiveClientIntent = firstDetectedUrl ? "add_item_from_url" : clientIntent; + if (firstDetectedUrl) { + if (rawClientLinkPreview && !clientLinkPreview) { + logger.info("[LINK_PRODUCT_SCOPE] ignored client H&M link preview; server scope required", { + uidHash, + urlHost: safeUrlHost(firstDetectedUrl), + clientImageCount: rawClientLinkPreview.imageUrls?.length ?? 0, + hasClientImageUrl: !!rawClientLinkPreview.imageUrl, + sanitizedFallbackImageCount: hmClientFallback?.imageUrls?.length ?? 0, + }); + } + logger.info("[AURA_URL_DETECTED] URL detected in AURA message", { + uidHash, + urlHost: safeUrlHost(firstDetectedUrl), + clientIntent, + effectiveClientIntent, + hasClientLinkPreview: !!clientLinkPreview, + clientLinkPreviewHasImage: !!clientLinkPreview?.imageUrl, + }); + } + logger.info("[AURA_STREAM] request received", { + uidHash, + hasAuth: !!uid, + hasMessage: !!userMessage, + linkIntent, + clientIntent: effectiveClientIntent, + detectedUrlCount: detectedUrls.length, + detectedDomains: detectedUrls.map((entry) => safeUrlHost(entry.normalized)), + attachmentCount: attachments.length, + outfitDiversity: outfitDiversity + ? { + shouldAvoidRepeats: outfitDiversity.shouldAvoidRepeats, + reason: outfitDiversity.reason, + previousItemCount: outfitDiversity.previousLookItemIds.length, + excludedItemCount: outfitDiversity.excludedItemIds.length, + recentItemCount: outfitDiversity.recentItemIds.length, + previousLookSignatureCount: outfitDiversity.previousLookSignatures.length, + maxOverlap: outfitDiversity.maxOverlap, + } + : null, + attachments: attachments.map((attachment) => ({ + type: attachment.type, + role: attachment.role ?? null, + groupId: attachment.groupId ?? null, + mimeType: attachment.mimeType ?? null, + hasStoragePath: !!attachment.storagePath, + width: attachment.width ?? null, + height: attachment.height ?? null, + hasUri: !!attachment.uri, + uriHost: safeUrlHost(attachment.uri), + })), + }); const history = Array.isArray(req.body?.history) ? req.body.history .map((entry: unknown) => { @@ -169,7 +1216,7 @@ export const askAuraStream = onRequest( : candidate.role === "user" ? "user" : null; - const text = String(candidate.text ?? "").trim(); + const text = sanitizeUserInput(String(candidate.text ?? "")); if (!role || !text) return null; return { role, text }; }) @@ -180,8 +1227,363 @@ export const askAuraStream = onRequest( .slice(-8) : []; - if (!userMessage) { - res.status(400).json({ error: "Message is required." }); + if (!userMessage && attachments.length === 0) { + res.status(400).json({ error: "Message or attachment is required." }); + return; + } + + const client = new OpenAI({ + apiKey: requireOpenAiApiKey(), + }); + const imageCandidateGroups = imageGroupsForAddIntent(userMessage, attachments, effectiveClientIntent); + const shouldAnalyzeOutfitPhoto = isOutfitPhotoRequest({ + clientIntent: effectiveClientIntent, + userMessage, + attachments, + }); + logger.info("[AURA_STREAM_INTENT] selected intent", { + uidHash, + selectedIntent: shouldAnalyzeOutfitPhoto + ? "outfit_photo_analysis" + : imageCandidateGroups.length > 0 + ? "image_candidate_preview" + : firstDetectedUrl + ? "product_link" + : "general_chat", + clientIntent, + effectiveClientIntent, + attachmentCount: attachments.length, + imageAttachmentCount: attachments.filter((attachment) => attachment.type === "image").length, + hasVisionPayload: attachments.some((attachment) => attachment.type === "image" && !!attachment.uri), + }); + + if (shouldAnalyzeOutfitPhoto) { + writeStatus(res, "analyzing_outfit"); + logger.info("[AURA_OUTFIT_PHOTO] stream outfit analysis intent classified", { + uidHash, + clientIntent, + effectiveClientIntent, + attachmentCount: attachments.length, + attachments: attachments.map((attachment) => ({ + type: attachment.type, + mimeType: attachment.mimeType ?? null, + hasStoragePath: !!attachment.storagePath, + uriHost: safeUrlHost(attachment.uri), + width: attachment.width ?? null, + height: attachment.height ?? null, + })), + }); + let data: ReturnType | Awaited>; + try { + data = await analyzeOutfitPhoto({ client, attachments, userMessage }); + } catch (error) { + logger.error("[AURA_OUTFIT_PHOTO] stream vision analysis failed", { + uidHash, + clientIntent, + effectiveClientIntent, + attachmentCount: attachments.length, + error: safeAuraVisionError(error), + }); + data = fallbackAuraResponse("I couldn't read that image as a supported photo. Try sending it again as a JPEG or PNG."); + } + writeFinal(res, data); + res.end(); + return; + } + + if (firstDetectedUrl) { + try { + const metadata = await extractProductUrlMetadata(firstDetectedUrl); + if (!metadata.imageUrl) { + const fallbackPreview = + clientLinkPreview ?? + hmClientFallback ?? + (metadata.title || metadata.description + ? { ...metadata, status: "needs_review" as const } + : fallbackLinkPreviewFromUrl(firstDetectedUrl)); + if (fallbackPreview) { + logger.info("[AURA_URL_METADATA] using client link preview after missing server image", { + uidHash, + urlHost: safeUrlHost(firstDetectedUrl), + hasTitle: !!fallbackPreview.title, + hasImageUrl: !!fallbackPreview.imageUrl, + imageCount: fallbackPreview.imageUrls?.length ?? 0, + sanitizedHmFallback: fallbackPreview === hmClientFallback, + }); + await emitUrlCandidatePreview({ + res, + client, + uid, + metadata: fallbackPreview, + }); + return; + } + logger.warn("[AURA_URL_METADATA] no product image found for URL", { + uidHash, + urlHost: safeUrlHost(firstDetectedUrl), + }); + const reply = "Couldn't read that link. Try a screenshot."; + writeTextDelta(res, reply); + writeFinal(res, fallbackAuraResponse(reply)); + res.end(); + return; + } + + await emitUrlCandidatePreview({ + res, + client, + uid, + metadata, + }); + return; + } catch (error) { + const fallbackPreview = + clientLinkPreview ?? + hmClientFallback ?? + fallbackLinkPreviewFromUrl(firstDetectedUrl); + if (fallbackPreview) { + logger.info("[AURA_URL_METADATA] using client link preview after server fetch failure", { + uidHash, + urlHost: safeUrlHost(firstDetectedUrl), + hasTitle: !!fallbackPreview.title, + hasImageUrl: !!fallbackPreview.imageUrl, + imageCount: fallbackPreview.imageUrls?.length ?? 0, + sanitizedHmFallback: fallbackPreview === hmClientFallback, + }); + await emitUrlCandidatePreview({ + res, + client, + uid, + metadata: fallbackPreview, + }); + return; + } + logger.error("[AURA_URL_FETCH] failed to read product URL", { + uidHash, + urlHost: safeUrlHost(firstDetectedUrl), + error: error instanceof Error ? error.message : String(error), + }); + const reply = "Couldn't read that link. Try a screenshot."; + writeTextDelta(res, reply); + writeFinal(res, fallbackAuraResponse(reply)); + res.end(); + return; + } + } + + if (imageCandidateGroups.length > 0) { + writeStatus(res, "extracting_preview"); + logger.info("[AURA_CANDIDATE] image add intent classified", { + uidHash, + candidateCount: imageCandidateGroups.length, + }); + try { + let candidates = await extractImageCandidates({ + client, + imageGroups: imageCandidateGroups, + }); + if (!candidates.length) { + logger.warn("[AURA_CANDIDATE_BACKEND] image extraction returned no candidates; using fallback", { + uidHash, + groupCount: imageCandidateGroups.length, + }); + candidates = fallbackImageCandidates(imageCandidateGroups); + } + const data = candidatePreviewResponse(candidates); + logger.info("[AURA_CANDIDATE_BACKEND] raw image candidate object before stream", { + uidHash, + keys: Object.keys(data), + candidateItemsCount: data.candidateItems.length, + }); + logger.info("[AURA_STREAM_FINAL] emitting image candidate final", { + uidHash, + clientIntent, + candidateCount: data.candidateItems.length, + presentation: data.presentation, + hasCandidateItems: !!data.candidateItems.length, + }); + logger.info("[AURA_STREAM_PRE_EMIT] image candidate final object", { + uidHash, + branchChosen: "image_candidate_preview", + finalPayloadKeys: Object.keys(data), + presentation: data.presentation, + candidateItemsLength: data.candidateItems.length, + candidatesLength: data.candidates.length, + }); + logStreamCandidateEmit(uid, "image", data); + writeTextDelta(res, data.reply); + writeFinal(res, data); + res.end(); + return; + } catch (error) { + logger.error("[AURA_LINK_ERROR] image candidate extraction failed", { + uidHash, + error: error instanceof Error ? error.message : String(error), + }); + const reply = "I couldn't read that item photo. Try another photo or add it manually."; + writeTextDelta(res, reply); + writeFinal(res, fallbackAuraResponse(reply)); + res.end(); + return; + } + } + + if ( + linkIntent === "add_from_link" || + linkIntent === "add_from_links_batch" || + clientIntent === "add_from_link" || + clientIntent === "add_from_links_batch" + ) { + if (!detectedUrls.length) { + writeError(res, "A product link is required."); + res.end(); + return; + } + + writeStatus(res, "extracting_preview"); + logger.info("[AURA_LINK] add intent classified", { + uidHash, + linkIntent, + urlCount: detectedUrls.length, + }); + + try { + const extractions = []; + for (const detectedUrl of detectedUrls.slice(0, 5)) { + const extraction = await extractProductFromUrl(detectedUrl.normalized); + extractions.push(await rankProductExtractionImages({ client, extraction })); + } + const data = candidatePreviewResponse(candidatesFromProductExtractions(extractions)); + logger.info("[AURA_CANDIDATE_BACKEND] raw link candidate object before stream", { + uidHash, + keys: Object.keys(data), + candidateItemsCount: data.candidateItems.length, + }); + logger.info("[AURA_STREAM_FINAL] emitting link candidate final", { + uidHash, + candidateCount: data.candidateItems.length, + presentation: data.presentation, + hasCandidateItems: !!data.candidateItems.length, + }); + logger.info("[AURA_STREAM_PRE_EMIT] link candidate final object", { + uidHash, + branchChosen: "link_candidate_preview", + finalPayloadKeys: Object.keys(data), + presentation: data.presentation, + candidateItemsLength: data.candidateItems.length, + candidatesLength: data.candidates.length, + }); + logStreamCandidateEmit(uid, "link", data); + writeTextDelta(res, data.reply); + writeFinal(res, data); + res.end(); + return; + } catch (error) { + logger.error("[AURA_LINK_ERROR] add from link failed", { + uidHash, + linkIntent, + error: error instanceof Error ? error.message : String(error), + }); + const reply = auraLinkErrorMessage(error); + writeTextDelta(res, reply); + writeFinal(res, fallbackAuraResponse(reply)); + res.end(); + return; + } + } + + const useSimpleChatPath = shouldUseSimpleChatPath({ + userMessage, + attachments, + detectedUrls, + clientIntent, + effectiveClientIntent, + linkIntent, + }); + + logger.info("[AURA_ROUTE] selected model path", { + uidHash, + path: useSimpleChatPath ? "simple_chat" : "structured", + contextMode: useSimpleChatPath ? "compact" : "full", + attachmentCount: attachments.length, + detectedUrlCount: detectedUrls.length, + clientIntent, + effectiveClientIntent, + }); + + if (useSimpleChatPath) { + const [auraMemory, userProfile] = await Promise.all([ + loadCompactAuraMemoryContext( + db, + uid, + typeof req.body?.chatId === "string" ? req.body.chatId : null + ), + loadAuraUserProfile(uid), + ]); + const compactAuraContext = buildCompactAuraContextForSimpleChat({ + memory: auraMemory, + userProfile, + clientContext: req.body?.clientContext, + styleCoreNote, + }); + const requestText = + `User profile:\n${JSON.stringify(compactAuraContext.userProfile, null, 2)}\n\n` + + `Aura context:\n${JSON.stringify(compactAuraContext, null, 2)}\n\n` + + "Stylist brief:\nPersonalize lightly.\n\n" + + `Style core note:\n${styleCoreNote || "None."}\n\n` + + `Required item guard:\n${requiredItemsPromptNote(requiredItemIds)}\n\n` + + `Outfit diversity:\n${outfitDiversityPromptNote(outfitDiversity)}\n\n` + + `Recent conversation:\n${JSON.stringify(history, null, 2)}\n\n` + + "Product link context:\nNo product links.\n\n" + + "Attachments:\nNone.\n\n" + + `User request:\n${userMessage}`; + + writeStatus(res, "responding"); + logger.info("[AURA_ROUTE] simple chat stream starting", { + uidHash, + path: "simple_chat", + contextMode: "compact", + secondModelCall: "skipped", + }); + + let streamedText = ""; + const stream = client.responses.stream({ + model: "gpt-5.4", + input: [ + { + role: "developer", + content: AURA_STREAM_INSTRUCTIONS, + }, + { + role: "user", + content: [ + { + type: "input_text", + text: requestText, + }, + ], + }, + ], + }); + + for await (const event of stream) { + if (event.type === "response.output_text.delta" && event.delta) { + streamedText += event.delta; + writeTextDelta(res, event.delta); + } + } + + const finalReply = streamedText.trim(); + const data = fallbackAuraResponse(finalReply || "I'm here - what are we styling?"); + logger.info("[AURA_ROUTE] simple chat stream finished", { + uidHash, + path: "simple_chat", + contextMode: "compact", + secondModelCall: "skipped", + textLength: data.reply.length, + }); + writeFinal(res, data); + res.end(); return; } @@ -191,30 +1593,77 @@ export const askAuraStream = onRequest( ...doc.data(), })) as (Record & { id: string })[]; + const laundryResponse = await handleLaundryIntent({ db, uid, message: userMessage, items }); + if (laundryResponse) { + writeTextDelta(res, laundryResponse.reply); + writeFinal(res, laundryResponse); + res.end(); + return; + } + + const [auraMemory, userProfile] = await Promise.all([ + loadCompactAuraMemoryContext( + db, + uid, + typeof req.body?.chatId === "string" ? req.body.chatId : null + ), + loadAuraUserProfile(uid), + ]); const auraContext = buildAuraContext({ items, weather: req.body?.weather || null, occasion: req.body?.occasion || null, selectedDate: req.body?.selectedDate || null, + memory: auraMemory, + userProfile, }); logger.info("AURA stream closet context", { - counts: auraContext.counts, - footwearAvailable: auraContext.wardrobeDebug?.footwearAvailable ?? [], - excludedFootwear: auraContext.wardrobeDebug?.excludedFootwear ?? [], - }); - const userProfile = await loadAuraUserProfile(uid); - - const client = new OpenAI({ - apiKey: process.env.OPENAI_API_KEY, + itemCount: items.length, + isSparseWardrobe: auraContext.isSparseWardrobe, + categoryKeyCount: Object.keys(auraContext.categoryCounts ?? {}).length, + gapKeyCount: Object.keys(auraContext.wardrobeGaps ?? {}).length, + footwearAvailableCount: auraContext.wardrobeDebug?.footwearAvailable?.length ?? 0, + excludedFootwearCount: auraContext.wardrobeDebug?.excludedFootwear?.length ?? 0, }); + let linkProductContext = "No product links."; + if (linkIntent === "analyze_link" && detectedUrls.length > 0) { + logger.info("[AURA_LINK] analyze intent classified", { + uidHash, + urlCount: detectedUrls.length, + }); + try { + const products = []; + for (const detectedUrl of detectedUrls.slice(0, 3)) { + products.push(await extractProductFromUrl(detectedUrl.normalized)); + } + linkProductContext = productContextText(products); + } catch (error) { + logger.error("[AURA_LINK_ERROR] analyze link extraction failed", { + uidHash, + error: error instanceof Error ? error.message : String(error), + }); + linkProductContext = auraLinkErrorMessage(error); + } + } const requestText = `User profile:\n${JSON.stringify(userProfile, null, 2)}\n\n` + `Aura context:\n${JSON.stringify(auraContext, null, 2)}\n\n` + + `Stylist brief:\n${auraContext.stylistBrief || "Personalize lightly."}\n\n` + + `Style core note:\n${styleCoreNote || "None."}\n\n` + + `Required item guard:\n${requiredItemsPromptNote(requiredItemIds)}\n\n` + + `Outfit diversity:\n${outfitDiversityPromptNote(outfitDiversity)}\n\n` + `Recent conversation:\n${JSON.stringify(history, null, 2)}\n\n` + - `User request:\n${userMessage}`; + `Product link context:\n${linkProductContext}\n\n` + + `Attachments:\n${attachmentContextText(attachments)}\n\n` + + `User request:\n${userMessage}` + + buildMultiLookRequestNote(userMessage); - writeEvent(res, { type: "status", status: "responding" }); + writeStatus(res, "responding"); + logger.info("[AURA_STREAM] starting OpenAI stream", { + uidHash, + attachmentCount: attachments.length, + }); let streamedText = ""; const stream = client.responses.stream({ @@ -231,6 +1680,7 @@ export const askAuraStream = onRequest( type: "input_text", text: requestText, }, + ...buildAuraVisionImageInputs(attachments), ], }, ], @@ -239,12 +1689,23 @@ export const askAuraStream = onRequest( for await (const event of stream) { if (event.type === "response.output_text.delta" && event.delta) { streamedText += event.delta; - writeEvent(res, { type: "delta", delta: event.delta }); + writeTextDelta(res, event.delta); } } const finalReply = streamedText.trim(); + logger.info("[AURA_STREAM] OpenAI stream finished", { + uidHash, + textLength: finalReply.length, + }); + logger.info("[AURA_ROUTE] structured finalizer starting", { + uidHash, + path: "structured", + contextMode: "full", + secondModelCall: "used", + textLength: finalReply.length, + }); const structured = await client.responses.create({ model: "gpt-5.4", input: [ @@ -262,6 +1723,7 @@ export const askAuraStream = onRequest( `Draft assistant reply already shown to the user:\n${finalReply}\n\n` + "Use that draft reply as the final reply unless a tiny cleanup is needed. Do not materially rewrite the response.", }, + ...buildAuraVisionImageInputs(attachments), ], }, ], @@ -281,6 +1743,20 @@ export const askAuraStream = onRequest( ownedPieces: { type: "array", items: { type: "string" } }, recommendedAdditions: { type: "array", items: { type: "string" } }, swapSuggestion: { type: "string" }, + missingPieces: { type: "array", items: { type: "string" } }, + upgradeSuggestions: { type: "array", items: { type: "string" } }, + upgradeSuggestionItems: { + type: "array", + items: { + type: "object", + additionalProperties: false, + properties: { + label: { type: "string" }, + searchQuery: { type: "string" }, + }, + required: ["label"], + }, + }, chips: { type: "array", items: { type: "string" } }, look: { anyOf: [ @@ -293,6 +1769,8 @@ export const askAuraStream = onRequest( vibe: { type: "string" }, shortExplanation: { type: "string" }, stylingNote: { type: "string" }, + personalizationLabel: { type: "string" }, + personalizationNote: { type: "string" }, pieces: { type: "array", items: { @@ -315,7 +1793,7 @@ export const askAuraStream = onRequest( type: "array", items: { type: "string", - enum: ["saveLook", "planForToday", "showMoreLikeThis", "shopMissingPieces", "useOnlyMyCloset", "makeItDressier"], + enum: ["saveLook", "planForToday", "likeLook", "notMyVibe", "showMoreLikeThis", "lessLikeThis", "shopMissingPieces", "useOnlyMyCloset", "makeItDressier"], }, }, }, @@ -323,6 +1801,47 @@ export const askAuraStream = onRequest( }, ], }, + lookOptions: { + type: "array", + items: { + type: "object", + additionalProperties: false, + properties: { + lookTitle: { type: "string" }, + vibe: { type: "string" }, + shortExplanation: { type: "string" }, + stylingNote: { type: "string" }, + personalizationLabel: { type: "string" }, + personalizationNote: { type: "string" }, + pieces: { + type: "array", + items: { + type: "object", + additionalProperties: false, + properties: { + role: { type: "string", enum: ["top", "bottom", "shoes", "outerwear", "accessory"] }, + itemName: { type: "string" }, + source: { type: "string", enum: ["closet", "suggested"] }, + itemId: { type: ["string", "null"] }, + imageUrl: { type: ["string", "null"] }, + }, + required: ["role", "itemName", "source", "itemId", "imageUrl"], + }, + }, + fromCloset: { type: "array", items: { type: "string" } }, + addToComplete: { type: "array", items: { type: "string" } }, + alternates: { type: "array", items: { type: "string" } }, + actions: { + type: "array", + items: { + type: "string", + enum: ["saveLook", "planForToday", "likeLook", "notMyVibe", "showMoreLikeThis", "lessLikeThis", "shopMissingPieces", "useOnlyMyCloset", "makeItDressier"], + }, + }, + }, + required: ["lookTitle", "vibe", "shortExplanation", "stylingNote", "pieces", "fromCloset", "addToComplete", "alternates", "actions"], + }, + }, }, required: [ "presentation", @@ -333,8 +1852,12 @@ export const askAuraStream = onRequest( "ownedPieces", "recommendedAdditions", "swapSuggestion", + "missingPieces", + "upgradeSuggestions", + "upgradeSuggestionItems", "chips", "look", + "lookOptions", ], }, }, @@ -350,16 +1873,64 @@ export const askAuraStream = onRequest( parsed.reply = parsed.reply?.trim() ? parsed.reply.trim() : finalReply; if (!parsed.reply) parsed.reply = finalReply; - parsed = normalizeAuraResponse(parsed, auraContext); + logger.info("[AURA_MULTI] stream structured response parsed", { + uidHash, + multiRequested: wantsMultipleLooks(userMessage), + requestedCount: parseRequestedLookCount(userMessage), + rawPresentation: parsed.presentation, + rawHasLook: !!parsed.look, + rawLookOptionsCount: parsed.lookOptions?.length ?? 0, + rawOwnedPiecesCount: parsed.ownedPieces?.length ?? 0, + rawOutfitItemsCount: parsed.outfitItems?.length ?? 0, + }); + parsed = normalizeAuraResponse(parsed, userMessage, auraContext, items, outfitDiversity); + logger.info("[AURA_MULTI] stream structured response normalized", { + uidHash, + multiRequested: wantsMultipleLooks(userMessage), + normalizedPresentation: parsed.presentation, + normalizedHasLook: !!parsed.look, + normalizedLookOptionsCount: parsed.lookOptions?.length ?? 0, + normalizedOwnedPiecesCount: parsed.ownedPieces?.length ?? 0, + missingPiecesCount: parsed.missingPieces?.length ?? 0, + upgradeSuggestionsCount: parsed.upgradeSuggestions?.length ?? 0, + }); + logger.info("[AURA_RESPONSE_FINAL] stream final response", { + uidHash, + presentation: parsed.presentation, + hasLook: !!parsed.look, + lookOptionsCount: parsed.lookOptions?.length ?? 0, + ownedPiecesCount: parsed.ownedPieces?.length ?? 0, + recommendedAdditionsCount: parsed.recommendedAdditions?.length ?? 0, + missingPiecesCount: parsed.missingPieces?.length ?? 0, + upgradeSuggestionsCount: parsed.upgradeSuggestions?.length ?? 0, + outfitItemsCount: parsed.outfitItems?.length ?? 0, + titleLength: String(parsed.title ?? "").length, + replyLength: String(parsed.reply ?? "").length, + }); - writeEvent(res, { type: "final", data: parsed }); + writeFinal(res, parsed); + logger.info("[AURA_STREAM] final response emitted", { + uidHash, + presentation: parsed.presentation, + hasLook: !!parsed.look, + lookOptionsCount: parsed.lookOptions?.length ?? 0, + }); res.end(); } catch (error) { - logger.error("askAuraStream failed", error); - writeEvent(res, { - type: "error", - error: "AURA could not respond right now.", + if (isRateLimitError(error)) { + writeError(res, error.message); + res.end(); + return; + } + if (error instanceof HttpsError) { + writeError(res, error.message); + res.end(); + return; + } + logger.error("[AURA_ERROR] askAuraStream failed", { + error: error instanceof Error ? error.message : String(error), }); + writeError(res, "AURA could not respond right now."); res.end(); } } diff --git a/functions/src/deleteAccountData.ts b/functions/src/deleteAccountData.ts new file mode 100644 index 0000000..3f72572 --- /dev/null +++ b/functions/src/deleteAccountData.ts @@ -0,0 +1,183 @@ +import { getAuth } from "firebase-admin/auth"; +import { + CollectionReference, + DocumentReference, + getFirestore, + WriteBatch, +} from "firebase-admin/firestore"; +import { getStorage } from "firebase-admin/storage"; +import { logger } from "firebase-functions/v2"; +import { HttpsError, onCall } from "firebase-functions/v2/https"; + +import { + RATE_LIMITS, + assertFunctionRateLimit, + redactUid, +} from "./shared/rateLimit"; + +const FIRESTORE_BATCH_LIMIT = 400; +const STORAGE_DELETE_BATCH_LIMIT = 100; + +type DeleteCounts = { + firestoreDocumentsDeleted: number; + storageFilesDeleted: number; + rateLimitDocumentsDeleted: number; + authUserDeleted: boolean; +}; + +async function commitBatch(batch: WriteBatch, pendingWrites: number) { + if (pendingWrites > 0) await batch.commit(); +} + +async function deleteDocumentTree(docRef: DocumentReference): Promise { + let deleted = 0; + const subcollections = await docRef.listCollections(); + for (const subcollection of subcollections) { + deleted += await deleteCollectionTree(subcollection); + } + await docRef.delete(); + return deleted + 1; +} + +async function deleteCollectionTree(collectionRef: CollectionReference): Promise { + let deleted = 0; + let hasMoreDocuments = true; + while (hasMoreDocuments) { + const snapshot = await collectionRef.limit(FIRESTORE_BATCH_LIMIT).get(); + if (snapshot.empty) { + hasMoreDocuments = false; + continue; + } + + let batch = getFirestore().batch(); + let pendingWrites = 0; + for (const doc of snapshot.docs) { + const subcollections = await doc.ref.listCollections(); + for (const subcollection of subcollections) { + deleted += await deleteCollectionTree(subcollection); + } + batch.delete(doc.ref); + pendingWrites += 1; + deleted += 1; + if (pendingWrites >= FIRESTORE_BATCH_LIMIT) { + await commitBatch(batch, pendingWrites); + batch = getFirestore().batch(); + pendingWrites = 0; + } + } + await commitBatch(batch, pendingWrites); + } + return deleted; +} + +async function deleteUserStorageFiles(uid: string) { + const bucket = getStorage().bucket(); + const [files] = await bucket.getFiles({ prefix: `users/${uid}/` }); + for (let index = 0; index < files.length; index += STORAGE_DELETE_BATCH_LIMIT) { + const chunk = files.slice(index, index + STORAGE_DELETE_BATCH_LIMIT); + await Promise.all( + chunk.map((file) => + file.delete().catch((error) => { + if ((error as { code?: unknown })?.code === 404) return; + throw error; + }), + ), + ); + } + return files.length; +} + +async function deleteUserRateLimitDocs(uidHash: string, uid: string) { + let deleted = 0; + const db = getFirestore(); + for (const endpoint of Object.keys(RATE_LIMITS)) { + const userRef = db + .collection("functionRateLimits") + .doc(endpoint) + .collection("users") + .doc(uidHash); + const windows = await userRef.collection("windows").get(); + let batch = db.batch(); + let pendingWrites = 0; + for (const docSnap of windows.docs) { + batch.delete(docSnap.ref); + pendingWrites += 1; + deleted += 1; + if (pendingWrites >= FIRESTORE_BATCH_LIMIT) { + await commitBatch(batch, pendingWrites); + batch = db.batch(); + pendingWrites = 0; + } + } + batch.delete(userRef); + pendingWrites += 1; + deleted += 1; + await commitBatch(batch, pendingWrites); + } + const legacyParseIntentRef = db + .collection("functionRateLimits") + .doc("parseOutfitIntent") + .collection("users") + .doc(uid); + const legacyParseIntentSnap = await legacyParseIntentRef.get(); + if (legacyParseIntentSnap.exists) { + deleted += await deleteDocumentTree(legacyParseIntentRef); + } + return deleted; +} + +export const deleteAccountData = onCall( + { timeoutSeconds: 540, memory: "512MiB" }, + async (request): Promise<{ ok: true } & DeleteCounts> => { + const uid = request.auth?.uid; + if (!uid) { + throw new HttpsError("unauthenticated", "User must be signed in."); + } + + const requestedUid = String(request.data?.uid ?? "").trim(); + if (requestedUid && requestedUid !== uid) { + throw new HttpsError("permission-denied", "You can only delete your own account data."); + } + await assertFunctionRateLimit(uid, "accountDelete", RATE_LIMITS.accountDelete); + const uidHash = redactUid(uid); + + const userRef = getFirestore().collection("users").doc(uid); + logger.info("[ACCOUNT_DELETE] starting account data deletion", { uidHash }); + + const firestoreDocumentsDeleted = await deleteDocumentTree(userRef); + const storageFilesDeleted = await deleteUserStorageFiles(uid); + const rateLimitDocumentsDeleted = await deleteUserRateLimitDocs(uidHash, uid); + let authUserDeleted = false; + try { + await getAuth().deleteUser(uid); + authUserDeleted = true; + } catch (error) { + if ((error as { code?: unknown })?.code !== "auth/user-not-found") { + logger.error("[ACCOUNT_DELETE] auth deletion failed", { + uidHash, + firestoreDocumentsDeleted, + storageFilesDeleted, + rateLimitDocumentsDeleted, + error: error instanceof Error ? error.message : String(error), + }); + throw new HttpsError("internal", "Account data was deleted, but Auth deletion failed."); + } + } + + logger.info("[ACCOUNT_DELETE] account data deletion complete", { + uidHash, + firestoreDocumentsDeleted, + storageFilesDeleted, + rateLimitDocumentsDeleted, + authUserDeleted, + }); + + return { + ok: true, + firestoreDocumentsDeleted, + storageFilesDeleted, + rateLimitDocumentsDeleted, + authUserDeleted, + }; + }, +); diff --git a/functions/src/generateAuraSwipeBatch.ts b/functions/src/generateAuraSwipeBatch.ts new file mode 100644 index 0000000..56d0ade --- /dev/null +++ b/functions/src/generateAuraSwipeBatch.ts @@ -0,0 +1,438 @@ +import { getApps, initializeApp } from "firebase-admin/app"; +import { getFirestore } from "firebase-admin/firestore"; +import { HttpsError, onCall } from "firebase-functions/v2/https"; +import { logger } from "firebase-functions/v2"; + +import { + MODEL, + clampNumOutfits, + fetchWardrobeItems, + generateOutfitCandidates, + inferRequestedOutfitCount, + normalizeParsedIntent, + safeJsonExtract, + type OutfitIntentV1, + type Slot, + type WardrobeItem, +} from "./shared/outfitEngine"; +import { loadCompactAuraMemoryContext } from "./shared/auraMemory"; +import { + roleForStylingItem, + type StylingItem, + type StylingScoreResult, +} from "./shared/styling/types"; +import { + RATE_LIMITS, + assertFunctionRateLimit, + redactUid, +} from "./shared/rateLimit"; + +if (!getApps().length) { + initializeApp(); +} + +type SwipeLookPayload = { + id: string; + position: number; + score: number; + reason: string; + stylingScore?: number; + stylingIntelligence?: StylingScoreResult; + directionLabel: "safe" | "balanced" | "bold" | null; + itemIds: string[]; + look: { + id?: string | null; + lookTitle: string; + vibe: string; + shortExplanation: string; + stylingNote: string; + personalizationLabel?: string; + personalizationNote?: string; + stylingIntelligence?: StylingScoreResult | null; + pieces: Array<{ + role: "top" | "bottom" | "shoes" | "outerwear" | "accessory"; + itemName: string; + source: "closet"; + itemId: string; + imageUrl?: string | null; + }>; + fromCloset: string[]; + addToComplete: string[]; + alternates: string[]; + actions: []; + }; +}; + +type GeneratedSwipeCandidate = ReturnType["outfits"][number]; + +async function parseSwipeIntent( + intentText: string, +): Promise<{ intent: OutfitIntentV1; numOutfits: number | null }> { + const apiKey = process.env.OPENAI_API_KEY; + if (!apiKey) { + return { + intent: normalizeParsedIntent(null, intentText), + numOutfits: inferRequestedOutfitCount(intentText), + }; + } + + try { + const response = await fetch("https://api.openai.com/v1/chat/completions", { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${apiKey}`, + }, + body: JSON.stringify({ + model: MODEL, + temperature: 0, + response_format: { type: "json_object" }, + messages: [ + { + role: "system", + content: [ + "Extract outfit intent from user text.", + "Return JSON only with keys: occasion, formalityTarget, warmthTarget, needs, niceToHave, colorsWanted, colorsAvoid, avoidLogos, excludeLaundry, numOutfits.", + "No markdown. No prose. No additional keys.", + "occasion enum: casual, smart_casual, formal, gym, date, work, party, travel, unknown.", + "needs defaults to [top,bottom,footwear]. niceToHave may include outerwear and accessory.", + "excludeLaundry defaults true.", + "If the user asks for a count of outfits, set numOutfits to that number. Otherwise set numOutfits to null.", + ].join(" "), + }, + { role: "user", content: intentText }, + ], + }), + }); + + if (!response.ok) { + return { + intent: normalizeParsedIntent(null, intentText), + numOutfits: inferRequestedOutfitCount(intentText), + }; + } + + const data = (await response.json()) as { + choices?: Array<{ message?: { content?: string } }>; + }; + const content = data.choices?.[0]?.message?.content ?? ""; + const parsed = safeJsonExtract & { numOutfits?: number | null }>(content); + return { + intent: normalizeParsedIntent(parsed, intentText), + numOutfits: + parsed?.numOutfits == null ? inferRequestedOutfitCount(intentText) : Number(parsed.numOutfits), + }; + } catch { + return { + intent: normalizeParsedIntent(null, intentText), + numOutfits: inferRequestedOutfitCount(intentText), + }; + } +} + +function cleanString(value: unknown) { + return String(value ?? "").trim(); +} + +function itemLabel(item?: WardrobeItem | null) { + return ( + cleanString(item?.name) || + cleanString(item?.subCategory) || + cleanString(item?.category) || + "Wardrobe piece" + ); +} + +function itemImageUrl(item?: WardrobeItem | null) { + return ( + cleanString(item?.photos?.cleanedPhotoUrl) || + cleanString(item?.photos?.cleanedUrl) || + cleanString(item?.photos?.primaryUrl) || + cleanString(item?.photoUrl) || + cleanString(item?.photos?.thumbUrl) || + null + ); +} + +function buildLookTitle(outfit: GeneratedSwipeCandidate, itemsById: Map, index: number) { + const top = outfit.picks.find((pick) => pick.slot === "top"); + const bottom = outfit.picks.find((pick) => pick.slot === "bottom"); + const topName = itemLabel(top ? itemsById.get(top.itemId) : null); + const bottomName = itemLabel(bottom ? itemsById.get(bottom.itemId) : null); + if (topName && bottomName) { + return `${topName} + ${bottomName}`; + } + return `Aura edit ${index + 1}`; +} + +function buildStylingNote(outfit: GeneratedSwipeCandidate, itemsById: Map) { + const categories = outfit.picks + .map((pick) => itemLabel(itemsById.get(pick.itemId))) + .filter(Boolean) + .slice(0, 3); + return categories.length + ? `Lean into ${categories.join(", ").toLowerCase()} for a clean, wearable finish.` + : "Keep the proportions clean and let the outfit breathe."; +} + +function buildDirectionLabel(index: number, total: number): "safe" | "balanced" | "bold" | null { + if (total < 3) return null; + if (index === 0) return "safe"; + if (index === total - 1) return "bold"; + return "balanced"; +} + +function slotForAnchorItem(item: WardrobeItem): Slot | null { + const role = roleForStylingItem(item as StylingItem); + if (role === "footwear") return "footwear"; + if (role === "top" || role === "bottom" || role === "outerwear" || role === "accessory") { + return role; + } + return null; +} + +function lockedItemsForAnchors( + anchorItemIds: string[], + itemsById: Map, +): Partial> | undefined { + const locked: Partial> = {}; + for (const itemId of anchorItemIds) { + const item = itemsById.get(itemId); + if (!item) continue; + const slot = slotForAnchorItem(item); + if (!slot || locked[slot]) continue; + locked[slot] = item; + } + return Object.keys(locked).length ? locked : undefined; +} + +function cleanItemIdList(value: unknown, max = 8) { + if (!Array.isArray(value)) return []; + const seen = new Set(); + const ids: string[] = []; + for (const entry of value) { + const itemId = String(entry ?? "").trim(); + if (!itemId || seen.has(itemId)) continue; + seen.add(itemId); + ids.push(itemId); + if (ids.length >= max) break; + } + return ids; +} + +function requiredItemIdsFromRequest(data: unknown) { + const raw = data && typeof data === "object" ? data as Record : {}; + return cleanItemIdList([ + ...cleanItemIdList(raw.requiredItemIds, 8), + ...cleanItemIdList(raw.mustIncludeItemIds, 8), + ], 8); +} + +function filterOutfitsForRequiredItems( + outfits: T[], + requiredItemIds: string[], +) { + if (!requiredItemIds.length) return outfits; + return outfits.filter((outfit) => + requiredItemIds.every((itemId) => outfit.itemIds.includes(itemId)), + ); +} + +function toSwipeLook( + outfit: GeneratedSwipeCandidate, + itemsById: Map, + index: number, + total: number, + learnedSummary: string | null, +): SwipeLookPayload { + const pieces = outfit.picks.flatMap((pick) => { + const item = itemsById.get(pick.itemId); + if (!item) return []; + return [{ + role: (pick.slot === "footwear" ? "shoes" : pick.slot) as "top" | "bottom" | "shoes" | "outerwear" | "accessory", + itemName: itemLabel(item), + source: "closet" as const, + itemId: pick.itemId, + imageUrl: itemImageUrl(item), + }]; + }); + + const fromCloset = pieces.map((piece) => piece.itemName).filter(Boolean); + const directionLabel = buildDirectionLabel(index, total); + + return { + id: `swipe_${index + 1}`, + position: index, + score: Math.round(outfit.score * 100) / 100, + reason: cleanString(outfit.reason), + stylingScore: outfit.stylingScore, + stylingIntelligence: outfit.stylingIntelligence, + directionLabel, + itemIds: outfit.picks.map((pick) => pick.itemId), + look: { + id: `swipe_${index + 1}_${outfit.picks.map((pick) => pick.itemId).join("_").slice(0, 80)}`, + lookTitle: buildLookTitle(outfit, itemsById, index), + vibe: directionLabel ? `${directionLabel} direction` : "wardrobe direction", + shortExplanation: cleanString(outfit.reason) || "AURA built this from your wardrobe.", + stylingNote: outfit.stylingIntelligence?.stylingNotes[0] ?? buildStylingNote(outfit, itemsById), + personalizationLabel: directionLabel ? directionLabel.toUpperCase() : undefined, + personalizationNote: learnedSummary || undefined, + stylingIntelligence: outfit.stylingIntelligence ?? null, + pieces, + fromCloset, + addToComplete: [], + alternates: [], + actions: [], + }, + }; +} + +export const generateAuraSwipeBatch = onCall( + { secrets: ["OPENAI_API_KEY"] }, + async (request) => { + const uid = request.auth?.uid; + if (!uid) { + throw new HttpsError("unauthenticated", "Authentication required"); + } + await assertFunctionRateLimit(uid, "outfitGeneration", RATE_LIMITS.outfitGeneration); + const uidHash = redactUid(uid); + + const intentText = cleanString(request.data?.intentText) || + "Build a varied batch of outfit directions from my wardrobe. Keep them polished, wearable, and distinct."; + const parsed = await parseSwipeIntent(intentText); + const requestedNumOutfits = + request.data?.numOutfits ?? parsed.numOutfits ?? inferRequestedOutfitCount(intentText) ?? 8; + const numOutfits = clampNumOutfits(requestedNumOutfits, 8); + const excludeItemIds = Array.isArray(request.data?.excludeItemIds) + ? request.data.excludeItemIds.map((value: unknown) => String(value).trim()).filter(Boolean).slice(0, 24) + : []; + const recentItemIds = Array.isArray(request.data?.recentItemIds) + ? request.data.recentItemIds.map((value: unknown) => String(value).trim()).filter(Boolean).slice(0, 36) + : []; + const requiredItemIds = requiredItemIdsFromRequest(request.data); + const anchorItemIds = cleanItemIdList([ + ...requiredItemIds, + ...(Array.isArray(request.data?.anchorItemIds) ? request.data.anchorItemIds : []), + ], requiredItemIds.length ? 8 : 3); + const previousLookItemIds = Array.isArray(request.data?.previousLookItemIds) + ? request.data.previousLookItemIds.map((value: unknown) => String(value).trim()).filter(Boolean).slice(0, 12) + : []; + const previousLookSignatures = Array.isArray(request.data?.previousLookSignatures) + ? request.data.previousLookSignatures.map((value: unknown) => String(value).trim()).filter(Boolean).slice(0, 12) + : []; + const maxOverlap = Number.isFinite(Number(request.data?.maxOverlap)) + ? Math.max(0, Math.min(3, Math.round(Number(request.data.maxOverlap)))) + : 2; + + const db = getFirestore(); + const allItems = await fetchWardrobeItems(db, uid); + const memory = await loadCompactAuraMemoryContext(db, uid, null); + const itemsById = new Map(allItems.map((item) => [item.id, item])); + const missingRequiredItemIds = requiredItemIds.filter((itemId) => !itemsById.has(itemId)); + if (missingRequiredItemIds.length) { + throw new HttpsError("failed-precondition", "Selected closet item is no longer available."); + } + const lockedItemsBySlot = lockedItemsForAnchors(anchorItemIds, itemsById); + if (requiredItemIds.length && Object.keys(lockedItemsBySlot ?? {}).length < requiredItemIds.length) { + throw new HttpsError("failed-precondition", "Selected closet item cannot be used as an outfit anchor."); + } + let generated = generateOutfitCandidates(allItems, parsed.intent, { + numOutfits, + memory, + excludeItemIds, + lockedItemsBySlot, + diversity: { + recentItemIds, + previousLookItemIds, + previousLookSignatures, + maxOverlap, + }, + }); + generated = { + ...generated, + outfits: filterOutfitsForRequiredItems(generated.outfits, requiredItemIds), + }; + if (!generated.outfits.length && requiredItemIds.length) { + throw new HttpsError("failed-precondition", "Could not build an outfit with the selected closet item."); + } + if (!generated.outfits.length && lockedItemsBySlot) { + generated = generateOutfitCandidates(allItems, parsed.intent, { + numOutfits, + memory, + excludeItemIds, + diversity: { + recentItemIds, + previousLookItemIds, + previousLookSignatures, + maxOverlap, + }, + }); + } + generated = { + ...generated, + outfits: filterOutfitsForRequiredItems(generated.outfits, requiredItemIds), + }; + if (!generated.outfits.length && requiredItemIds.length) { + throw new HttpsError("failed-precondition", "Could not build an outfit with the selected closet item."); + } + if (!generated.outfits.length && excludeItemIds.length) { + generated = generateOutfitCandidates(allItems, parsed.intent, { + numOutfits, + memory, + excludeItemIds: [], + diversity: { + recentItemIds, + previousLookItemIds, + previousLookSignatures, + maxOverlap, + }, + }); + } + generated = { + ...generated, + outfits: filterOutfitsForRequiredItems(generated.outfits, requiredItemIds), + }; + if (!generated.outfits.length && requiredItemIds.length) { + throw new HttpsError("failed-precondition", "Could not build an outfit with the selected closet item."); + } + + logger.info("generateAuraSwipeBatch built batch", { + uidHash, + requestedNumOutfits, + numOutfits, + eligibleCount: generated.eligibleCount, + slotCounts: generated.slotCounts, + returned: generated.outfits.length, + fallbackMode: generated.fallbackMode ?? "strict", + diversity: { + excludeItemCount: excludeItemIds.length, + previousLookItemCount: previousLookItemIds.length, + recentItemCount: recentItemIds.length, + anchorItemCount: anchorItemIds.length, + requiredItemCount: requiredItemIds.length, + lockedAnchorSlots: Object.keys(lockedItemsBySlot ?? {}), + previousLookSignatureCount: previousLookSignatures.length, + maxOverlap, + }, + }); + + const lookOptions = generated.outfits.map((outfit, index) => + toSwipeLook(outfit, itemsById, index, generated.outfits.length, memory.learnedProfile?.summaryShort ?? null), + ); + + logger.info("generateAuraSwipeBatch response payload", { + uidHash, + lookCount: lookOptions.length, + lookTitleCount: lookOptions.filter((entry) => !!entry.look.lookTitle).length, + itemCounts: lookOptions.map((entry) => entry.look.pieces.length), + }); + + return { + batchId: `swipe_${Date.now()}`, + intentText, + lookOptions, + primaryLook: lookOptions[0] ?? null, + looks: lookOptions, + }; + }, +); diff --git a/functions/src/generateCleanedProductImages.ts b/functions/src/generateCleanedProductImages.ts index a3d6750..8324dc4 100644 --- a/functions/src/generateCleanedProductImages.ts +++ b/functions/src/generateCleanedProductImages.ts @@ -1,5 +1,4 @@ import { getApps, initializeApp } from "firebase-admin/app"; -import { logger } from "firebase-functions/v2"; import { onDocumentWritten } from "firebase-functions/v2/firestore"; if (!getApps().length) { @@ -14,9 +13,7 @@ export const generateCleanedProductImages = onDocumentWritten( timeoutSeconds: 60, }, async (event) => { - const uid = String(event.params.uid ?? ""); - const itemId = String(event.params.itemId ?? ""); - logger.info("Skipping cleaned image generation: ONNX disabled", { uid, itemId }); + void event; return; } ); diff --git a/functions/src/generateOutfitsV1.ts b/functions/src/generateOutfitsV1.ts index e00dd68..805093c 100644 --- a/functions/src/generateOutfitsV1.ts +++ b/functions/src/generateOutfitsV1.ts @@ -12,12 +12,197 @@ import { normalizeParsedIntent, persistGeneratedOutfits, safeJsonExtract, + type Slot, + type WardrobeItem, } from "./shared/outfitEngine"; +import { loadCompactAuraMemoryContext } from "./shared/auraMemory"; +import { + RATE_LIMITS, + assertFunctionRateLimit, + redactUid, +} from "./shared/rateLimit"; +import { scoreOutfitStyling } from "./shared/styling/stylingScore"; +import { + roleForStylingItem, + type StylingItem, +} from "./shared/styling/types"; if (!getApps().length) { initializeApp(); } +function normalizedText(value: unknown): string { + return String(value ?? "") + .toLowerCase() + .replace(/[^a-z0-9\s]/g, " ") + .replace(/\s+/g, " ") + .trim(); +} + +function isOuterwearItem(item: { category?: string | null; subCategory?: string | null; type?: string | null; name?: string | null }) { + const category = String(item.category ?? "").trim().toLowerCase(); + const tokens = normalizedText( + [item.category, item.subCategory, item.type, item.name].filter(Boolean).join(" "), + ); + return ( + category === "outerwear" || + /\b(jacket|coat|outerwear|overshirt|blazer|hoodie|cardigan|shacket|trench|parka|bomber|denim jacket)\b/.test( + tokens, + ) + ); +} + +function slotForAnchorItem(item: WardrobeItem): Slot | null { + const role = roleForStylingItem(item as StylingItem); + if (role === "footwear") return "footwear"; + if (role === "top" || role === "bottom" || role === "outerwear" || role === "accessory") { + return role; + } + return null; +} + +function lockedItemsForAnchors( + anchorItemIds: string[], + itemsById: Map, +): Partial> | undefined { + const locked: Partial> = {}; + for (const itemId of anchorItemIds) { + const item = itemsById.get(itemId); + if (!item) continue; + const slot = slotForAnchorItem(item); + if (!slot || locked[slot]) continue; + locked[slot] = item; + } + return Object.keys(locked).length ? locked : undefined; +} + +function cleanItemIdList(value: unknown, max = 8) { + if (!Array.isArray(value)) return []; + const seen = new Set(); + const ids: string[] = []; + for (const entry of value) { + const itemId = String(entry ?? "").trim(); + if (!itemId || seen.has(itemId)) continue; + seen.add(itemId); + ids.push(itemId); + if (ids.length >= max) break; + } + return ids; +} + +function requiredItemIdsFromRequest(data: unknown) { + const raw = data && typeof data === "object" ? data as Record : {}; + return cleanItemIdList([ + ...cleanItemIdList(raw.requiredItemIds, 8), + ...cleanItemIdList(raw.mustIncludeItemIds, 8), + ], 8); +} + +function filterOutfitsForRequiredItems( + outfits: T[], + requiredItemIds: string[], +) { + if (!requiredItemIds.length) return outfits; + return outfits.filter((outfit) => + requiredItemIds.every((itemId) => outfit.itemIds.includes(itemId)), + ); +} + +type EnforceableOutfit = { + picks: Array<{ slot: "top" | "bottom" | "footwear" | "outerwear" | "accessory"; itemId: string }>; + score: number; + reason: string; + itemIds: string[]; + stylingScore?: number; + stylingIntelligence?: ReturnType; +}; + +function applyStylingToOutfit( + outfit: EnforceableOutfit, + allItems: WardrobeItem[], + intent: OutfitIntentV1, +): EnforceableOutfit { + const itemsById = new Map(allItems.map((item) => [item.id, item])); + const stylingItems = outfit.picks.flatMap((pick) => { + const item = itemsById.get(pick.itemId); + if (!item) return []; + return [{ + ...item, + role: pick.slot, + source: "closet" as const, + }]; + }); + const stylingIntelligence = scoreOutfitStyling(stylingItems, { + occasion: intent.occasion, + formalityTarget: intent.formalityTarget, + requestText: intent.occasion, + }); + return { + ...outfit, + stylingScore: stylingIntelligence.overallScore, + stylingIntelligence, + }; +} + +function enforceOuterwearOnOutfits(params: { + outfits: EnforceableOutfit[]; + allItems: WardrobeItem[]; + requireOuterwear: boolean; + intent: OutfitIntentV1; +}) { + const { outfits, allItems, requireOuterwear, intent } = params; + const outerwearPool = allItems.filter(isOuterwearItem); + if (!requireOuterwear || outerwearPool.length === 0) { + return { + outfits, + availableOuterwearCount: outerwearPool.length, + repairedCount: 0, + }; + } + + const usedOuterwearIds = new Set(); + let repairedCount = 0; + const repaired = outfits.map((outfit) => { + if (outfit.picks.some((pick) => pick.slot === "outerwear")) { + const currentOuterwearId = outfit.picks.find((pick) => pick.slot === "outerwear")?.itemId; + if (currentOuterwearId) usedOuterwearIds.add(currentOuterwearId); + return { + ...outfit, + itemIds: outfit.itemIds, + }; + } + + const existingIds = new Set(outfit.picks.map((pick) => pick.itemId)); + const candidate = + outerwearPool.find((item) => !existingIds.has(item.id) && !usedOuterwearIds.has(item.id)) ?? + outerwearPool.find((item) => !existingIds.has(item.id)) ?? + outerwearPool[0]; + + if (!candidate) { + return outfit; + } + + usedOuterwearIds.add(candidate.id); + repairedCount += 1; + return { + ...outfit, + picks: [{ slot: "outerwear" as const, itemId: candidate.id }, ...outfit.picks], + itemIds: [candidate.id, ...outfit.itemIds], + reason: outfit.reason.includes("outerwear") + ? outfit.reason + : `${outfit.reason.replace(/\.\s*$/, "")}, layered with ${candidate.name ?? "outerwear"}.`, + }; + }); + + return { + outfits: repaired + .filter((outfit) => outfit.picks.some((pick) => pick.slot === "outerwear")) + .map((outfit) => applyStylingToOutfit(outfit, allItems, intent)), + availableOuterwearCount: outerwearPool.length, + repairedCount, + }; +} + async function parseOutfitIntent( intentText: string ): Promise<{intent: OutfitIntentV1; numOutfits: number | null}> { @@ -90,6 +275,8 @@ export const generateOutfitsV1 = onCall( if (!uid) { throw new HttpsError("unauthenticated", "Authentication required"); } + await assertFunctionRateLimit(uid, "outfitGeneration", RATE_LIMITS.outfitGeneration); + const uidHash = redactUid(uid); const intentText = String(request.data?.intentText ?? "").trim(); if (!intentText) { @@ -100,23 +287,96 @@ export const generateOutfitsV1 = onCall( const requestedNumOutfits = request.data?.numOutfits ?? parsed.numOutfits ?? inferRequestedOutfitCount(intentText) ?? 3; const numOutfits = clampNumOutfits(requestedNumOutfits, 3); + const requiredItemIds = requiredItemIdsFromRequest(request.data); + const anchorItemIds = cleanItemIdList([ + ...requiredItemIds, + ...(Array.isArray(request.data?.anchorItemIds) ? request.data.anchorItemIds : []), + ], requiredItemIds.length ? 8 : 3); const parsedIntent = parsed.intent; logger.info("generateOutfitsV1 parsed intent", { - uid, - parsedIntent, + uidHash, + parsedIntentKeys: Object.keys(parsedIntent ?? {}), requestedNumOutfits, numOutfits, + anchorItemCount: anchorItemIds.length, + requiredItemCount: requiredItemIds.length, }); const db = getFirestore(); const allItems = await fetchWardrobeItems(db, uid); - const generated = generateOutfitCandidates(allItems, parsedIntent, {numOutfits}); + const memory = await loadCompactAuraMemoryContext(db, uid, null); + const itemsById = new Map(allItems.map((item) => [item.id, item])); + const missingRequiredItemIds = requiredItemIds.filter((itemId) => !itemsById.has(itemId)); + if (missingRequiredItemIds.length) { + throw new HttpsError("failed-precondition", "Selected closet item is no longer available."); + } + const lockedItemsBySlot = lockedItemsForAnchors(anchorItemIds, itemsById); + if (requiredItemIds.length && Object.keys(lockedItemsBySlot ?? {}).length < requiredItemIds.length) { + throw new HttpsError("failed-precondition", "Selected closet item cannot be used as an outfit anchor."); + } + let generated = generateOutfitCandidates(allItems, parsedIntent, { + numOutfits, + memory, + lockedItemsBySlot, + }); + generated = { + ...generated, + outfits: filterOutfitsForRequiredItems(generated.outfits, requiredItemIds), + }; + if (!generated.outfits.length && requiredItemIds.length) { + throw new HttpsError("failed-precondition", "Could not build an outfit with the selected closet item."); + } + if (!generated.outfits.length && lockedItemsBySlot) { + generated = generateOutfitCandidates(allItems, parsedIntent, { + numOutfits, + memory, + }); + } + generated = { + ...generated, + outfits: filterOutfitsForRequiredItems(generated.outfits, requiredItemIds), + }; + if (!generated.outfits.length && requiredItemIds.length) { + throw new HttpsError("failed-precondition", "Could not build an outfit with the selected closet item."); + } + const outerwearAvailable = allItems.filter((item) => + isOuterwearItem({ + category: item.category, + subCategory: item.subCategory, + type: (item as { type?: string | null }).type ?? null, + name: item.name ?? null, + }), + ).length; logger.info("generateOutfitsV1 slot counts", { - uid, + uidHash, total: allItems.length, eligible: generated.eligibleCount, ...generated.slotCounts, + fallbackMode: generated.fallbackMode ?? "strict", + requiresOuterwear: parsedIntent.requireOuterwear === true, + availableOuterwearCount: outerwearAvailable, + lockedAnchorSlots: Object.keys(lockedItemsBySlot ?? {}), + }); + + const enforced = enforceOuterwearOnOutfits({ + outfits: generated.outfits, + allItems, + requireOuterwear: parsedIntent.requireOuterwear === true, + intent: generated.intent, + }); + const enforcedOutfits = enforced.outfits; + + logger.info("generateOutfitsV1 outerwear enforcement", { + uidHash, + requiresOuterwear: parsedIntent.requireOuterwear === true, + availableOuterwearCount: enforced.availableOuterwearCount, + generatedCount: generated.outfits.length, + enforcedCount: enforcedOutfits.length, + repairedCount: enforced.repairedCount, + outerwearCountPerLook: enforcedOutfits.map( + (outfit) => outfit.picks.filter((pick) => pick.slot === "outerwear").length, + ), }); const outfits = await persistGeneratedOutfits({ @@ -124,17 +384,19 @@ export const generateOutfitsV1 = onCall( uid, intentText, intent: generated.intent, - outfits: generated.outfits, + outfits: enforcedOutfits, }); logger.info("generateOutfitsV1 selected outfits", { - uid, + uidHash, requestedNumOutfits, numOutfits, + fallbackMode: generated.fallbackMode ?? "strict", outfits: outfits.map((outfit) => ({ id: outfit.id, score: outfit.score, - itemIds: outfit.picks.map((pick) => pick.itemId), + itemCount: outfit.picks.length, + outerwearCount: outfit.picks.filter((pick) => pick.slot === "outerwear").length, })), }); diff --git a/functions/src/importProductLink.ts b/functions/src/importProductLink.ts new file mode 100644 index 0000000..1d8b845 --- /dev/null +++ b/functions/src/importProductLink.ts @@ -0,0 +1,123 @@ +import { HttpsError, onCall } from "firebase-functions/v2/https"; +import { logger } from "firebase-functions/v2"; +import { getFirestore, FieldValue } from "firebase-admin/firestore"; +import OpenAI from "openai"; + +import { + ProductLinkError, + createDraftItemFromProductLink, + extractProductFromUrl, +} from "./shared/productLinkExtractor"; +import { requireOpenAiApiKey } from "./shared/env"; +import { rankProductExtractionImages } from "./shared/auraCandidatePreview"; +import { + RATE_LIMITS, + assertFunctionRateLimit, + redactUid, +} from "./shared/rateLimit"; +import { redactUrlForLogs } from "./shared/safeFetch"; + +function messageForError(error: unknown) { + if (error instanceof ProductLinkError) return error.message; + if (error instanceof Error) return error.message; + return "Could not import that product link."; +} + +function codeForError( + error: unknown, +): "invalid-argument" | "failed-precondition" | "internal" { + if (!(error instanceof ProductLinkError)) return "internal"; + switch (error.code) { + case "invalid_url": + case "unsafe_url": + case "fetch_failed": + return "invalid-argument"; + case "blocked_store": + case "no_metadata": + case "no_images": + return "failed-precondition"; + case "draft_failed": + default: + return "internal"; + } +} + +async function markImportFailed(uid: string, itemId: string | null, message: string) { + if (!itemId) return; + await getFirestore() + .collection("users") + .doc(uid) + .collection("items") + .doc(itemId) + .set( + { + draftState: "failed", + itemLifecycleStatus: "failed", + ingestionStatus: "failed", + ingestion: { + status: "failed", + lastRunAt: FieldValue.serverTimestamp(), + error: { message }, + }, + updatedAt: Date.now(), + }, + { merge: true } + ); +} + +export const importProductLink = onCall( + { secrets: ["OPENAI_API_KEY"] }, + async (request) => { + const uid = request.auth?.uid; + if (!uid) { + throw new HttpsError("unauthenticated", "Please sign in first."); + } + + const url = String(request.data?.url ?? "").trim(); + if (!url) { + throw new HttpsError("invalid-argument", "A product link is required."); + } + await assertFunctionRateLimit(uid, "productLink", RATE_LIMITS.productLink); + const itemId = String(request.data?.itemId ?? "").trim() || null; + + try { + logger.info("[SNAP_DONE_LINK] importing product link", { + uidHash: redactUid(uid), + url: redactUrlForLogs(url), + itemId, + }); + const client = new OpenAI({ + apiKey: requireOpenAiApiKey(), + }); + const extraction = await rankProductExtractionImages({ + client, + extraction: await extractProductFromUrl(url), + }); + const created = await createDraftItemFromProductLink({ + uid, + itemId: itemId ?? undefined, + prompt: "Quick add product link", + extraction, + }); + logger.info("[SNAP_DONE_LINK] product draft created", { + uidHash: redactUid(uid), + itemId: created.itemId, + imageCount: created.imageCount, + domain: created.metadata.domain, + }); + return { ok: true, itemId: created.itemId, imageCount: created.imageCount }; + } catch (error) { + if (error instanceof HttpsError) throw error; + const message = messageForError(error); + logger.error("[SNAP_DONE_LINK] import failed", { + uidHash: redactUid(uid), + url: redactUrlForLogs(url), + itemId, + code: error instanceof ProductLinkError ? error.code : null, + error: message, + }); + await markImportFailed(uid, itemId, message); + throw new HttpsError(codeForError(error), message); + } + } +); diff --git a/functions/src/index.ts b/functions/src/index.ts index 04c3d5d..e0e93c4 100644 --- a/functions/src/index.ts +++ b/functions/src/index.ts @@ -1,26 +1,84 @@ import { getApps, initializeApp } from "firebase-admin/app"; +import { getAuth } from "firebase-admin/auth"; import { getFirestore } from "firebase-admin/firestore"; import { onRequest } from "firebase-functions/v2/https"; import { parseOutfitIntentFromPrompt } from "./parseOutfitIntent"; export { generateOutfitsV1 } from "./generateOutfitsV1"; +export { generateAuraSwipeBatch } from "./generateAuraSwipeBatch"; export { outfitChatV1 } from "./outfitChatV1"; export { askAura } from "./askAura"; export { askAuraStream } from "./askAuraStream"; +export { transcribeAuraAudio } from "./transcribeAuraAudio"; +export { importProductLink } from "./importProductLink"; +export { previewProductLink } from "./previewProductLink"; export { ingestItemFromPhotos } from "./ingestItemFromPhotos"; -export { generateCleanedProductImages } from "./generateCleanedProductImages"; +export { deleteAccountData } from "./deleteAccountData"; +export { wrapAffiliateLinks } from "./affiliate/affiliateLinks"; +export { searchLiveProducts } from "./products/searchLiveProducts"; +// export { generateCleanedProductImages } from "./generateCleanedProductImages"; if (!getApps().length) { initializeApp(); } getFirestore().settings({ignoreUndefinedProperties: true}); +async function assertParseOutfitIntentRateLimit(uid: string) { + const db = getFirestore(); + const ref = db + .collection("functionRateLimits") + .doc("parseOutfitIntent") + .collection("users") + .doc(uid); + const now = Date.now(); + const windowMs = 60_000; + const maxRequests = 20; + await db.runTransaction(async (transaction) => { + const snapshot = await transaction.get(ref); + const data = snapshot.data() as { windowStart?: number; count?: number } | undefined; + const windowStart = + typeof data?.windowStart === "number" && now - data.windowStart < windowMs + ? data.windowStart + : now; + const count = windowStart === data?.windowStart ? Number(data?.count ?? 0) + 1 : 1; + if (count > maxRequests) { + throw new Error("rate_limited"); + } + transaction.set(ref, { windowStart, count, updatedAt: now }, { merge: true }); + }); +} + export const parseOutfitIntent = onRequest( { cors: true, secrets: ["OPENAI_API_KEY"] }, async (req, res) => { + if (req.method === "OPTIONS") { + res.status(204).send(""); + return; + } + const authHeader = req.headers.authorization; + if (!authHeader?.startsWith("Bearer ")) { + res.status(401).json({ error: "Unauthorized" }); + return; + } + const token = authHeader.split("Bearer ")[1]; + let uid: string; + try { + const decoded = await getAuth().verifyIdToken(token); + uid = decoded.uid; + } catch { + res.status(401).json({ error: "Invalid token" }); + return; + } + if (req.method !== "POST") { res.status(405).json({ error: "Method not allowed" }); return; } + try { + await assertParseOutfitIntentRateLimit(uid); + } catch { + res.status(429).json({ error: "Too many requests" }); + return; + } const prompt = String(req.body?.prompt ?? "").trim(); if (!prompt) { diff --git a/functions/src/ingestItemFromPhotos.ts b/functions/src/ingestItemFromPhotos.ts index 286309c..9d95616 100644 --- a/functions/src/ingestItemFromPhotos.ts +++ b/functions/src/ingestItemFromPhotos.ts @@ -2,6 +2,7 @@ import { createHash, randomUUID } from "node:crypto"; import { getApps, initializeApp } from "firebase-admin/app"; import { FieldValue, getFirestore, Timestamp } from "firebase-admin/firestore"; import { getStorage } from "firebase-admin/storage"; +import { removeBackground } from "@imgly/background-removal-node"; import { onDocumentWritten } from "firebase-functions/v2/firestore"; import { logger } from "firebase-functions/v2"; import sharp from "sharp"; @@ -18,14 +19,33 @@ import { isValidCategorySubCategory, wearSlot, } from "./shared/wardrobeTaxonomy"; +import { + RATE_LIMIT_MESSAGE, + RATE_LIMITS, + assertFunctionRateLimit, + isRateLimitError, + redactUid, +} from "./shared/rateLimit"; +import { redactUrlForLogs, safeFetch, validateSafeUrlForFetch } from "./shared/safeFetch"; if (!getApps().length) { initializeApp(); } -type IngestionStatus = "pending" | "processing" | "done" | "failed"; +type IngestionStatus = + | "awaiting_confirmation" + | "pending" + | "processing" + | "done" + | "failed"; type ItemDoc = { + images?: { + originalUrl?: string | null; + aiUrl?: string | null; + cleanedUrl?: string | null; + isPrimary?: boolean; + }[] | null; brand?: string | null; name?: string | null; brandConfidence?: number; @@ -36,6 +56,8 @@ type ItemDoc = { category?: string; subCategory?: string; type?: string | null; + pattern?: string | null; + material?: string | null; fit?: string; style?: string; sleeveLength?: string; @@ -55,15 +77,25 @@ type ItemDoc = { visualWeight?: string | null; versatilityScore?: number | null; photos?: { + originalUrl?: string | null; primaryUrl?: string | null; urls?: string[]; + images?: { + originalUrl?: string | null; + aiUrl?: string | null; + cleanedUrl?: string | null; + isPrimary?: boolean; + }[]; cleanedUrl?: string | null; cleanedPhotoUrl?: string | null; normalizedUrl?: string | null; + aiUrl?: string | null; croppedUrl?: string; thumbUrl?: string; }; photoUrl?: string | null; + originalImageUrl?: string | null; + cleanedImageUrl?: string | null; photoUri?: string | null; colors?: string[]; colorLabel?: string; @@ -72,6 +104,7 @@ type ItemDoc = { displayColors?: string[] | null; colorSource?: "ai" | "user"; colorUpdatedAt?: number; + userEditedFields?: string[]; aiColorLabel?: string; aiColors?: string[]; pixelColors?: string[]; @@ -93,10 +126,18 @@ type ItemDoc = { ingestion?: { status?: IngestionStatus; lastRunAt?: Timestamp | { toMillis?: () => number } | number | null; - error?: { message: string; code?: string }; + startedAt?: Timestamp | { toMillis?: () => number } | number | null; + completedAt?: Timestamp | { toMillis?: () => number } | number | null; + failedAt?: Timestamp | { toMillis?: () => number } | number | null; + error?: { message: string; code?: string } | null; + errorCode?: string | null; + errorMessage?: string | null; lastProcessedPhotoHash?: string; lastProcessedSourceHash?: string; runId?: string; + attempt?: number; + workerVersion?: string; + canRetry?: boolean; }; ingestionStatus?: string | null; isDraft?: boolean | null; @@ -105,7 +146,10 @@ type ItemDoc = { sourceHash?: string; sourceType?: string; }; + itemLifecycleStatus?: string | null; + removedAt?: number | null; cleanedFromHash?: string; + backgroundRemovalMethod?: "client" | "server" | "none"; }; type RawExtraction = { @@ -117,12 +161,17 @@ type RawExtraction = { subCategory?: string; type?: string | null; name?: string | null; + color?: string | null; colors?: string[]; primaryColor?: string | null; + secondaryColors?: string[]; + dominantColors?: string[]; + palette?: string[]; displayColor?: string | null; displayColors?: string[] | null; pattern?: string; material?: string; + materialConfidence?: number; fit?: | "slim" | "regular" @@ -184,6 +233,12 @@ type RawExtraction = { subCategory?: number; colors?: number; brand?: number; + material?: number; + }; + detailTags?: string[]; + confidenceSummary?: { + overall?: number; + notes?: string; }; formalityScore?: number; warmthScore?: number; @@ -198,6 +253,11 @@ type LastRunAtValue = const MODEL = "gpt-5.4-mini"; const HOUR_MS = 60 * 60 * 1000; +const STALE_PROCESSING_MS = 10 * 60 * 1000; +const INGEST_WORKER_VERSION = "add-item-ingestion-2026-05-08"; +const MAX_PROCESSING_IMAGE_DIMENSION = 1600; +const MAX_CROP_IMAGE_DIMENSION = 1024; +const SERVER_BG_REMOVAL_MAX_BYTES = 3 * 1024 * 1024; const ALLOWED_PATTERNS = new Set([ "solid", "striped", @@ -330,14 +390,92 @@ function toMillis(value: LastRunAtValue): number | null { return null; } +function getProcessingStartedAtMs(item: ItemDoc | undefined): number | null { + return ( + toMillis(item?.ingestion?.startedAt) ?? + toMillis(item?.ingestion?.lastRunAt) + ); +} + +function isStaleProcessingStatus( + item: ItemDoc | undefined, + status: IngestionStatus | "", + nowMs = Date.now(), +): boolean { + if (status !== "processing") return false; + const startedAtMs = getProcessingStartedAtMs(item); + return !startedAtMs || nowMs - startedAtMs >= STALE_PROCESSING_MS; +} + +function getIngestionAttempt(item: ItemDoc | undefined): number { + const attempt = Number(item?.ingestion?.attempt ?? 0); + if (!Number.isFinite(attempt)) return 0; + return Math.max(0, Math.floor(attempt)); +} + +function safeIngestionFailureMessage(message: string): string { + const lower = message.toLowerCase(); + if (lower.includes("rate limit")) return RATE_LIMIT_MESSAGE; + if (lower.includes("openai_api_key")) { + return "AI item detection is temporarily unavailable."; + } + if (lower.includes("image is too large")) { + return "The photo is too large to process. Please try a smaller image."; + } + if (lower.includes("failed to download") || lower.includes("image")) { + return "The photo could not be processed. Please try another image."; + } + return "AI item detection failed. Please try again."; +} + +function normalizeComparableValue(value: unknown): unknown { + if (value == null) return null; + if (Array.isArray(value)) return value.map(normalizeComparableValue); + if (typeof value === "object") { + const record = value as Record; + const normalized: Record = {}; + for (const key of Object.keys(record).sort()) { + normalized[key] = normalizeComparableValue(record[key]); + } + return normalized; + } + return value; +} + +function valuesDiffer(left: unknown, right: unknown): boolean { + return ( + JSON.stringify(normalizeComparableValue(left)) !== + JSON.stringify(normalizeComparableValue(right)) + ); +} + +function stringSet(value: unknown): Set { + if (!Array.isArray(value)) return new Set(); + return new Set( + value + .map((entry) => String(entry ?? "").trim()) + .filter(Boolean), + ); +} + function extractPhotoUrls(item: ItemDoc): string[] { const values = [ + ...(Array.isArray(item.images) + ? item.images.flatMap((image) => [image?.aiUrl ?? "", image?.cleanedUrl ?? "", image?.originalUrl ?? ""]) + : []), + ...(Array.isArray(item.photos?.images) + ? item.photos.images.flatMap((image) => [image?.aiUrl ?? "", image?.cleanedUrl ?? "", image?.originalUrl ?? ""]) + : []), + item.photos?.aiUrl ?? "", item.photos?.cleanedUrl ?? "", item.photos?.cleanedPhotoUrl ?? "", + item.photos?.originalUrl ?? "", item.photos?.primaryUrl ?? "", + item.cleanedImageUrl ?? "", + item.originalImageUrl ?? "", item.photoUrl ?? "", item.photoUri ?? "", - ...(Array.isArray(item.photos?.urls) ? item.photos!.urls : []), + ...(Array.isArray(item.photos?.urls) ? item.photos.urls : []), ]; const deduped = Array.from( @@ -355,13 +493,23 @@ function getIngestionStatus(item: ItemDoc | undefined): IngestionStatus | "" { function extractIngestionSourceUrls(item: ItemDoc): string[] { const values = [ + ...(Array.isArray(item.images) + ? item.images.flatMap((image) => [image?.aiUrl ?? "", image?.cleanedUrl ?? "", image?.originalUrl ?? ""]) + : []), + ...(Array.isArray(item.photos?.images) + ? item.photos.images.flatMap((image) => [image?.aiUrl ?? "", image?.cleanedUrl ?? "", image?.originalUrl ?? ""]) + : []), + item.photos?.aiUrl ?? "", item.photos?.cleanedUrl ?? "", item.photos?.cleanedPhotoUrl ?? "", item.photos?.normalizedUrl ?? "", + item.photos?.originalUrl ?? "", item.photos?.primaryUrl ?? "", + item.cleanedImageUrl ?? "", + item.originalImageUrl ?? "", item.photoUrl ?? "", item.photoUri ?? "", - ...(Array.isArray(item.photos?.urls) ? item.photos!.urls : []), + ...(Array.isArray(item.photos?.urls) ? item.photos.urls : []), ]; const deduped = Array.from( new Set(values.map((v) => String(v).trim()).filter(Boolean)), @@ -373,6 +521,53 @@ function hashPhotoUrls(urls: string[]): string { return createHash("sha1").update(urls.join("|")).digest("hex"); } +function extractStoragePathFromUrl(url: string): string | null { + try { + const parsed = new URL(url); + if (parsed.hostname === "firebasestorage.googleapis.com") { + const marker = "/o/"; + const markerIndex = parsed.pathname.indexOf(marker); + if (markerIndex === -1) return null; + const encodedPath = parsed.pathname.slice(markerIndex + marker.length); + return decodeURIComponent(encodedPath); + } + if (parsed.hostname.endsWith(".appspot.com") || parsed.hostname.endsWith(".firebasestorage.app")) { + const path = parsed.pathname.replace(/^\/+/, ""); + return path ? decodeURIComponent(path) : null; + } + return null; + } catch { + return null; + } +} + +function isUserOwnedStoragePath(uid: string, storagePath: string | null) { + return !!storagePath && storagePath.startsWith(`users/${uid}/`); +} + +function extractDownloadTokenFromUrl(url: string): string | null { + try { + return new URL(url).searchParams.get("token"); + } catch { + return null; + } +} + +function hasClientCleanedImage(item: ItemDoc): boolean { + const values = [ + ...(Array.isArray(item.images) + ? item.images.map((image) => image?.cleanedUrl ?? "") + : []), + ...(Array.isArray(item.photos?.images) + ? item.photos.images.map((image) => image?.cleanedUrl ?? "") + : []), + item.photos?.cleanedUrl ?? "", + item.photos?.cleanedPhotoUrl ?? "", + item.cleanedImageUrl ?? "", + ]; + return values.some((value) => /^https?:\/\//i.test(String(value ?? "").trim())); +} + function clampScore(value: unknown): number { const n = Number(value); if (!Number.isFinite(n)) return 0.5; @@ -586,24 +781,27 @@ function normalizeColorToken(raw: string): AllowedColor | null { if (text.includes("navy")) return "navy"; if (text.includes("blue")) return "blue"; if (text.includes("black")) return "black"; - if (text.includes("white")) return "white"; if ( text.includes("cream") || text.includes("ivory") || - text.includes("off white") + text.includes("off white") || + text.includes("offwhite") ) { return "cream"; } + if (text.includes("white")) return "white"; if (text.includes("gold")) return "gold"; if (text.includes("silver")) return "silver"; if ( - text.includes("beige") || - text.includes("tan") || - text.includes("khaki") - ) { - return "beige"; - } - if (text.includes("brown")) return "brown"; + text.includes("chocolate") || + text.includes("mocha") || + text.includes("espresso") || + text.includes("taupe") || + text.includes("brown") + ) return "brown"; + if (text.includes("camel") || text.includes("tan")) return "tan"; + if (text.includes("khaki")) return "khaki"; + if (text.includes("beige")) return "beige"; if (text.includes("red")) return "red"; if (text.includes("green")) return "green"; if (text.includes("yellow")) return "yellow"; @@ -663,6 +861,20 @@ function normalizeDisplayColors(values: unknown): string[] { return out; } +function normalizeDetailTags(values: unknown): string[] { + if (!Array.isArray(values)) return []; + const out: string[] = []; + for (const value of values) { + const normalized = normalizeFreeTextLabel(value, 24) + ?.toLowerCase() + .replace(/\s+/g, "_"); + if (!normalized || out.includes(normalized)) continue; + out.push(normalized); + if (out.length >= 6) break; + } + return out; +} + function toHex(r: number, g: number, b: number): string { const parts = [r, g, b].map((n) => Math.max(0, Math.min(255, Math.round(n)))); return `#${parts @@ -712,6 +924,15 @@ function mapRgbToAllowedColor(r: number, g: number, b: number): AllowedColor { return "grey"; } + // Brown garments live in the red/orange/yellow hue range, but with + // lower value than true orange or yellow. Catch that before hue mapping. + if (h >= 10 && h < 55 && s >= 0.18 && v >= 0.16 && v < 0.72) { + return "brown"; + } + if (h >= 20 && h < 65 && s >= 0.12 && v >= 0.45 && v < 0.86) { + return "tan"; + } + const mapByHue = (): AllowedColor | null => { if (h >= 345 || h < 15) return "red"; if (h >= 15 && h < 45) return "orange"; @@ -735,15 +956,210 @@ function mapRgbToAllowedColor(r: number, g: number, b: number): AllowedColor { return "grey"; } -async function downloadImageBytes(url: string): Promise { - const response = await fetch(url); +async function validateImageInputUrls(uid: string, itemId: string, urls: string[], label: string) { + const safeUrls: string[] = []; + for (const url of urls) { + const storagePath = extractStoragePathFromUrl(url); + if (storagePath) { + if (isUserOwnedStoragePath(uid, storagePath)) { + safeUrls.push(url); + } else { + logger.warn("[INGEST_SECURITY] rejected foreign Storage image URL", { + uidHash: redactUid(uid), + itemId, + label, + hasStoragePath: !!storagePath, + }); + } + continue; + } + try { + safeUrls.push((await validateSafeUrlForFetch(url)).toString()); + } catch (error) { + logger.warn("[INGEST_SECURITY] rejected unsafe external image URL", { + uidHash: redactUid(uid), + itemId, + label, + url: redactUrlForLogs(url), + reason: error instanceof Error ? error.message : String(error), + }); + } + } + return Array.from(new Set(safeUrls)); +} + +async function downloadImageBytes(uid: string, url: string): Promise { + const storagePath = extractStoragePathFromUrl(url); + if (storagePath) { + if (!isUserOwnedStoragePath(uid, storagePath)) { + throw new Error("Image Storage path is not owned by this user."); + } + const file = getStorage().bucket().file(storagePath); + const [metadata] = await file.getMetadata(); + const size = Number(metadata.size ?? 0); + const contentType = String(metadata.contentType ?? "").toLowerCase(); + if (Number.isFinite(size) && size > 10 * 1024 * 1024) { + throw new Error("Image is too large."); + } + if (!contentType.startsWith("image/")) { + throw new Error("Storage file is not an image."); + } + const [bytes] = await file.download(); + return bytes; + } + + const response = await safeFetch(url, { + expectedKind: "image", + maxBytes: 10 * 1024 * 1024, + }); if (!response.ok) { throw new Error(`Failed to download image: ${response.status}`); } - const arrayBuffer = await response.arrayBuffer(); + return response.bytes; +} + +async function normalizeImageBytesForProcessing( + uid: string, + itemId: string, + bytes: Buffer, +): Promise { + const metadata = await sharp(bytes).metadata(); + const width = metadata.width ?? 0; + const height = metadata.height ?? 0; + const maxDimension = Math.max(width, height); + const hasAlpha = Boolean(metadata.hasAlpha); + let pipeline = sharp(bytes).rotate(); + if (maxDimension > MAX_PROCESSING_IMAGE_DIMENSION) { + pipeline = pipeline.resize({ + width: MAX_PROCESSING_IMAGE_DIMENSION, + height: MAX_PROCESSING_IMAGE_DIMENSION, + fit: "inside", + withoutEnlargement: true, + }); + } + + const normalized = hasAlpha + ? await pipeline.png({ compressionLevel: 9 }).toBuffer() + : await pipeline.jpeg({ quality: 86, mozjpeg: true }).toBuffer(); + + logger.info("[INGEST_IMAGE] normalized source image for processing", { + uidHash: redactUid(uid), + itemId, + originalBytes: bytes.byteLength, + normalizedBytes: normalized.byteLength, + originalWidth: width || null, + originalHeight: height || null, + maxDimension, + resized: maxDimension > MAX_PROCESSING_IMAGE_DIMENSION, + preservedAlpha: hasAlpha, + }); + + return normalized; +} + +function shouldAttemptServerBackgroundRemoval(params: { + sourceType: string; + imageBytes: Buffer; +}): boolean { + const sourceType = params.sourceType.trim().toLowerCase(); + if ( + sourceType === "aura_chat" || + sourceType === "aura_product_link" || + sourceType === "original" + ) { + return false; + } + return params.imageBytes.byteLength <= SERVER_BG_REMOVAL_MAX_BYTES; +} + +async function hasTransparentPngBackground(bytes: Buffer): Promise { + const metadata = await sharp(bytes).metadata(); + if (metadata.format !== "png" || !metadata.hasAlpha) return false; + + const raw = await sharp(bytes) + .ensureAlpha() + .raw() + .toBuffer({ resolveWithObject: true }); + const channels = raw.info.channels; + const data = raw.data; + for (let i = channels - 1; i < data.length; i += channels) { + if (data[i] < 255) return true; + } + return false; +} + +async function blobToBuffer(blob: Blob): Promise { + const arrayBuffer = await blob.arrayBuffer(); return Buffer.from(arrayBuffer); } +async function applyServerBackgroundRemoval(params: { + uid: string; + itemId: string; + photoUrl: string; + imageBytes: Buffer; +}): Promise<{ + bytes: Buffer; + method: "server" | "none"; +}> { + const { uid, itemId, photoUrl, imageBytes } = params; + try { + if (await hasTransparentPngBackground(imageBytes)) { + logger.info("[BgRemoval] Server-side removal skipped; PNG already has transparency", { + uidHash: redactUid(uid), + itemId, + }); + return { bytes: imageBytes, method: "none" }; + } + + const storagePath = extractStoragePathFromUrl(photoUrl); + if (!storagePath) { + logger.warn("[BgRemoval] Server-side removal skipped; unable to parse Storage path", { + uidHash: redactUid(uid), + itemId, + photoUrl: redactUrlForLogs(photoUrl), + }); + return { bytes: imageBytes, method: "none" }; + } + if (!isUserOwnedStoragePath(uid, storagePath)) { + logger.warn("[BgRemoval] Server-side removal skipped; Storage path is not user-owned", { + uidHash: redactUid(uid), + itemId, + hasStoragePath: !!storagePath, + }); + return { bytes: imageBytes, method: "none" }; + } + + const result = await removeBackground(imageBytes, { + output: { format: "image/png", quality: 0.92 }, + }); + const pngBytes = await blobToBuffer(result); + const token = extractDownloadTokenFromUrl(photoUrl) ?? randomUUID(); + await getStorage().bucket().file(storagePath).save(pngBytes, { + metadata: { + contentType: "image/png", + metadata: { + firebaseStorageDownloadTokens: token, + }, + }, + resumable: false, + }); + logger.info("[BgRemoval] Server-side removal applied", { + uidHash: redactUid(uid), + itemId, + hasStoragePath: !!storagePath, + }); + return { bytes: pngBytes, method: "server" }; + } catch (error) { + logger.error("[BgRemoval] Server-side removal failed; continuing original image", { + uidHash: redactUid(uid), + itemId, + error: error instanceof Error ? error.message : String(error), + }); + return { bytes: imageBytes, method: "none" }; + } +} + function clampBbox( bbox: RawExtraction["bbox"], width: number, @@ -1056,11 +1472,17 @@ function safeJsonExtract(text: string): RawExtraction | null { } } -async function extractWithOpenAI(photoUrl: string): Promise { +async function extractWithOpenAI(photoUrls: string[]): Promise { const apiKey = process.env.OPENAI_API_KEY; if (!apiKey) { throw new Error("OPENAI_API_KEY is not configured"); } + const visionPhotoUrls = photoUrls.slice(0, 3); + logger.info("[INGEST_VISION_PAYLOAD] prepared image payload", { + inputImageCount: photoUrls.length, + visionImageCount: visionPhotoUrls.length, + detail: "high", + }); const response = await fetch("https://api.openai.com/v1/chat/completions", { method: "POST", @@ -1076,9 +1498,9 @@ async function extractWithOpenAI(photoUrl: string): Promise { { role: "system", content: [ - "You are a fashion catalog parser and wardrobe stylist. Analyze one clothing item from the image and output ONLY one JSON object matching the schema. No markdown. No prose. No extra keys.", + "You are a fashion catalog parser and wardrobe stylist. You are given multiple images of the same clothing item. Combine information across all images to determine the most accurate attributes and output ONLY one JSON object matching the schema. No markdown. No prose. No extra keys.", "Be visually grounded. Use only what is visible in the garment image. Prefer null over guessing when uncertain.", - "Schema keys only: category, subCategory, type, name, brand, brandConfidence, colors, primaryColor, displayColor, displayColors, pattern, material, fit, style, sleeveLength, neckline, closure, length, rise, legShape, hasLogo, logoPlacement, formality, warmth, layerRole, aestheticTags, occasionTags, seasonTags, visualWeight, versatilityScore, confidence.", + "Schema keys only: category, subCategory, type, name, brand, brandConfidence, colors, primaryColor, displayColor, displayColors, pattern, material, materialConfidence, fit, style, sleeveLength, neckline, closure, length, rise, legShape, hasLogo, logoPlacement, formality, warmth, layerRole, aestheticTags, occasionTags, seasonTags, visualWeight, versatilityScore, detailTags, confidenceSummary, confidence.", "HARD category disambiguation priority:", "1) If two leg openings, inseam, crotch seam, fly, waistband, belt loops, or drawstring at the waist are visible, category MUST be bottom.", "2) If collar or neckline plus sleeves are visible, category is top unless it is clearly open-front outerwear.", @@ -1125,7 +1547,9 @@ async function extractWithOpenAI(photoUrl: string): Promise { { type: "text", text: [ - "Analyze this garment photo.", + "Analyze these garment photos together as one item.", + "Use all images to improve brand, material, pattern, color, and visible-detail accuracy.", + "If the images conflict, choose the most consistent signal and lower confidence.", "Prefer visible garment type and visible logo/text/tag only.", "Use waistband/fly/two-leg cues to avoid misclassifying pants as tops.", "If uncertain, return null instead of guessing.", @@ -1134,10 +1558,10 @@ async function extractWithOpenAI(photoUrl: string): Promise { "If a piece has multiple visible garment colors, include them and choose the main one as primaryColor.", ].join(" "), }, - { - type: "image_url", - image_url: { url: photoUrl }, - }, + ...visionPhotoUrls.map((photoUrl) => ({ + type: "image_url" as const, + image_url: { url: photoUrl, detail: "high" }, + })), ], }, ], @@ -1152,19 +1576,21 @@ async function extractWithOpenAI(photoUrl: string): Promise { } const data = (await response.json()) as { - choices?: Array<{ message?: { content?: string } }>; + choices?: { message?: { content?: string } }[]; }; const content = data.choices?.[0]?.message?.content ?? ""; logger.info("OpenAI raw extraction response", { - rawText: content.slice(0, 2000), + responseLength: content.length, truncated: content.length > 2000, }); const parsed = safeJsonExtract(content); if (!parsed) { throw new Error("OpenAI returned invalid JSON payload"); } - logger.info("OpenAI parsed extraction payload", { parsed }); + logger.info("OpenAI parsed extraction payload", { + parsedKeys: Object.keys(parsed as Record), + }); return parsed; } @@ -1173,43 +1599,153 @@ export const ingestItemFromPhotos = onDocumentWritten( { document: "users/{uid}/items/{itemId}", secrets: ["OPENAI_API_KEY"], + memory: "1GiB", + timeoutSeconds: 240, }, async (event) => { const uid = String(event.params.uid ?? ""); const itemId = String(event.params.itemId ?? ""); + const uidHash = redactUid(uid); const before = event.data?.before.data() as ItemDoc | undefined; const after = event.data?.after.data() as ItemDoc | undefined; if (!after) return; - const photoUrls = extractPhotoUrls(after); - const hasPhoto = - photoUrls.length > 0 || !!String(after.photoUrl ?? "").trim(); + const rawPhotoUrls = extractPhotoUrls(after); + const rawSourceUrls = extractIngestionSourceUrls(after); + const photoUrls = await validateImageInputUrls(uid, itemId, rawPhotoUrls, "photo"); + const sourceUrls = await validateImageInputUrls(uid, itemId, rawSourceUrls, "source"); + const sourceType = String(after.ingestionSource?.sourceType ?? "").trim(); + const isSnapDoneDraft = + after.isDraft === true && + (sourceType === "aura_chat" || sourceType === "aura_product_link"); + const hasPhoto = photoUrls.length > 0; const status = getIngestionStatus(after); - logger.info("INGEST CHECK", { - uid, + const nowMs = Date.now(); + const processingStartedAtMs = getProcessingStartedAtMs(after); + const staleProcessingRetry = isStaleProcessingStatus(after, status, nowMs); + const lifecycleStatus = String(after.itemLifecycleStatus ?? "").trim().toLowerCase(); + const draftState = String(after.draftState ?? "").trim().toLowerCase(); + const isCancelledOrDeleted = + lifecycleStatus === "deleted" || + lifecycleStatus === "candidate" || + draftState === "cancelled" || + draftState === "awaiting_confirmation" || + status === "awaiting_confirmation"; + logger.info("[INGEST_TRIGGER] item write received", { + uidHash, itemId, status: status || null, - photoUrl: String(after.photoUrl ?? "").trim() || null, - photosPrimaryUrl: String(after.photos?.primaryUrl ?? "").trim() || null, - photosUrls: Array.isArray(after.photos?.urls) ? after.photos?.urls : [], - extractedPhotoUrls: photoUrls, + sourceType: sourceType || null, + isSnapDoneDraft, + lifecycleStatus: lifecycleStatus || null, + draftState: draftState || null, + isDraft: after.isDraft ?? null, + hasImagesArray: Array.isArray(after.images), + imagesCount: Array.isArray(after.images) ? after.images.length : 0, + photoUrl: redactUrlForLogs(String(after.photoUrl ?? "").trim() || null), + photosPrimaryUrl: redactUrlForLogs(String(after.photos?.primaryUrl ?? "").trim() || null), + photosUrls: Array.isArray(after.photos?.urls) + ? after.photos?.urls.map((url) => redactUrlForLogs(url)) + : [], + extractedPhotoUrls: photoUrls.map((url) => redactUrlForLogs(url)), hasPhoto, + processingStartedAtMs, + processingAgeMs: processingStartedAtMs ? nowMs - processingStartedAtMs : null, + staleProcessingRetry, }); - if (status === "processing" || status === "done") { - logger.info("Skipping ingestion: active or terminal status", { - uid, + if (isCancelledOrDeleted) { + logger.info("[INGEST_VALIDATE] skipping cancelled, deleted, or candidate item", { + uidHash, + itemId, + status, + lifecycleStatus: lifecycleStatus || null, + draftState: draftState || null, + }); + return; + } + if (status === "done") { + logger.info("[INGEST_VALIDATE] skipping active or terminal status", { + uidHash, + itemId, + status, + sourceType: sourceType || null, + isSnapDoneDraft, + }); + return; + } + if (status === "processing" && !staleProcessingRetry) { + logger.info("[INGEST_VALIDATE] skipping fresh processing status", { + uidHash, itemId, status, + sourceType: sourceType || null, + isSnapDoneDraft, + processingStartedAtMs, + processingAgeMs: processingStartedAtMs ? nowMs - processingStartedAtMs : null, + }); + return; + } + if (staleProcessingRetry) { + logger.warn("[INGEST_VALIDATE] retrying stale processing item", { + uidHash, + itemId, + status, + sourceType: sourceType || null, + isSnapDoneDraft, + processingStartedAtMs, + processingAgeMs: processingStartedAtMs ? nowMs - processingStartedAtMs : null, + }); + } + if (draftState === "awaiting_confirmation" || draftState === "cancelled") { + logger.info("Skipping ingestion: awaiting user confirmation", { + uidHash, + itemId, + status, + draftState: draftState || null, }); return; } if (!hasPhoto) { - logger.info("Skipping ingestion: no photo URLs", { uid, itemId }); + if (rawPhotoUrls.length > 0) { + await getFirestore() + .doc(`users/${uid}/items/${itemId}`) + .set( + { + ...(after.isDraft === true ? { draftState: "failed" } : {}), + itemLifecycleStatus: "failed", + ingestionStatus: "failed", + ingestion: { + status: "failed", + lastRunAt: FieldValue.serverTimestamp(), + failedAt: FieldValue.serverTimestamp(), + errorCode: "unsafe_image_url", + errorMessage: "Image URL is not safe to process.", + canRetry: true, + error: { + code: "unsafe_image_url", + message: "Image URL is not safe to process.", + }, + }, + updatedAt: Date.now(), + }, + { merge: true }, + ); + } + logger.info("[INGEST_VALIDATE] skipping no photo URLs", { + uidHash, + itemId, + sourceType: sourceType || null, + isSnapDoneDraft, + hasImagesArray: Array.isArray(after.images), + imagesCount: Array.isArray(after.images) ? after.images.length : 0, + hasPhotosMap: !!after.photos, + }); return; } - const sourceUrls = extractIngestionSourceUrls(after); - const beforeSourceUrls = before ? extractIngestionSourceUrls(before) : []; + const beforeSourceUrls = before + ? await validateImageInputUrls(uid, itemId, extractIngestionSourceUrls(before), "before-source") + : []; const photoHash = hashPhotoUrls(photoUrls); const declaredSourceHash = String(after.ingestionSource?.sourceHash ?? "").trim() || null; @@ -1276,21 +1812,22 @@ export const ingestItemFromPhotos = onDocumentWritten( const shouldRun = hasSourcePhoto && !retryBlocked && - (isCreate || + (staleProcessingRetry || + isCreate || !status || !currentSourceHash || !processedSourceHash || !alreadyProcessedCurrentSource || explicitRetryRequested); - logger.info("Ingestion trigger decision", { - uid, + logger.info("[INGEST_VALIDATE] trigger decision", { + uidHash, itemId, beforeExists: !!before, beforeStatus: String(before?.ingestion?.status ?? "").trim() || null, afterStatus: status || null, - beforeSourceUrls, - afterSourceUrls: sourceUrls, + beforeSourceUrls: beforeSourceUrls.map((url) => redactUrlForLogs(url)), + afterSourceUrls: sourceUrls.map((url) => redactUrlForLogs(url)), declaredSourceHash, previousSourceHash: previousSourceHash || null, currentSourceHash: currentSourceHash || null, @@ -1301,11 +1838,14 @@ export const ingestItemFromPhotos = onDocumentWritten( alreadyProcessedCurrentSource, retryBlocked, explicitRetryRequested, + staleProcessingRetry, shouldRun, skipReason: !hasSourcePhoto ? "missing-source-photo" : retryBlocked ? "recent-failed-same-source" + : staleProcessingRetry + ? null : explicitRetryRequested ? null : shouldRun @@ -1314,13 +1854,14 @@ export const ingestItemFromPhotos = onDocumentWritten( }); if (!shouldRun) { - logger.info("Skipping ingestion: conditions not met", { - uid, + logger.info("[INGEST_VALIDATE] skipping conditions not met", { + uidHash, itemId, status: status ?? "missing", hasNewPhoto, retryBlocked, explicitRetryRequested, + staleProcessingRetry, declaredSourceHash, previousSourceHash: previousSourceHash || null, currentSourceHash: currentSourceHash || null, @@ -1335,26 +1876,29 @@ export const ingestItemFromPhotos = onDocumentWritten( const latestSnapshot = await ref.get(); const latestData = latestSnapshot.data() as ItemDoc | undefined; const latestStatus = getIngestionStatus(latestData); + const latestStaleProcessingRetry = isStaleProcessingStatus(latestData, latestStatus); const latestProcessedSourceHash = String( latestData?.ingestion?.lastProcessedSourceHash ?? "", ).trim(); - if (latestStatus === "processing" || latestStatus === "done") { - logger.info("Skipping ingestion: latest state already active/terminal", { - uid, + if (latestStatus === "done" || (latestStatus === "processing" && !latestStaleProcessingRetry)) { + logger.info("[INGEST_VALIDATE] skipping latest state already active/terminal", { + uidHash, itemId, latestStatus, + latestStaleProcessingRetry, }); return; } if ( latestProcessedSourceHash && currentSourceHash && - latestProcessedSourceHash === currentSourceHash + latestProcessedSourceHash === currentSourceHash && + !(latestStatus === "processing" && latestStaleProcessingRetry) ) { logger.info( - "Skipping ingestion: latest source already processed for current hash", + "[INGEST_VALIDATE] skipping latest source already processed for current hash", { - uid, + uidHash, itemId, currentSourceHash, latestProcessedSourceHash, @@ -1363,11 +1907,11 @@ export const ingestItemFromPhotos = onDocumentWritten( return; } - if (after.cleanedFromHash === currentSourceHash) { + if (after.cleanedFromHash === currentSourceHash && !staleProcessingRetry) { logger.info( - "Skipping ingestion: cleanedFromHash already matches current source", + "[INGEST_VALIDATE] skipping cleanedFromHash already matches current source", { - uid, + uidHash, itemId, currentSourceHash, }, @@ -1375,30 +1919,135 @@ export const ingestItemFromPhotos = onDocumentWritten( return; } + try { + await assertFunctionRateLimit(uid, "imageIngestion", RATE_LIMITS.imageIngestion); + } catch (error) { + if (!isRateLimitError(error)) throw error; + logger.warn("[INGEST_RATE_LIMIT] image ingestion blocked", { + uidHash, + itemId, + }); + await ref.set( + { + ...(after.isDraft === true ? { draftState: "failed" } : {}), + itemLifecycleStatus: "failed", + ingestionStatus: "failed", + ingestion: { + status: "failed", + lastRunAt: FieldValue.serverTimestamp(), + failedAt: FieldValue.serverTimestamp(), + errorCode: "rate_limit", + errorMessage: RATE_LIMIT_MESSAGE, + canRetry: true, + error: { + code: "rate_limit", + message: RATE_LIMIT_MESSAGE, + }, + }, + updatedAt: Date.now(), + }, + { merge: true }, + ); + return; + } + const runId = randomUUID(); - logger.info("Ingestion transition", { - uid, + const nextAttempt = getIngestionAttempt(latestData ?? after) + 1; + logger.info("[INGEST_START] transition to processing", { + uidHash, itemId, + isSnapDoneDraft, from: status ?? "missing", to: "processing", + attempt: nextAttempt, + workerVersion: INGEST_WORKER_VERSION, }); await ref.set( { ...(after.isDraft === true ? { draftState: "ingesting" } : {}), + itemLifecycleStatus: "processing", + ingestionStatus: "processing", + backgroundRemovalMethod: hasClientCleanedImage(after) ? "client" : "none", ingestion: { runId, status: "processing", lastRunAt: FieldValue.serverTimestamp(), + startedAt: FieldValue.serverTimestamp(), + attempt: nextAttempt, + workerVersion: INGEST_WORKER_VERSION, + canRetry: false, + failedAt: null, + errorCode: null, + errorMessage: null, + error: null, lastProcessedPhotoHash: photoHash, lastProcessedSourceHash: currentSourceHash, }, + updatedAt: Date.now(), }, { merge: true }, ); try { - const extracted = await extractWithOpenAI(photoUrls[0]); + let backgroundRemovalMethod: "client" | "server" | "none" = + hasClientCleanedImage(after) ? "client" : "none"; + const extracted = await extractWithOpenAI(photoUrls); + let imageBytes = await downloadImageBytes(uid, photoUrls[0]); + imageBytes = await normalizeImageBytesForProcessing(uid, itemId, imageBytes); + if (backgroundRemovalMethod !== "client") { + if ( + shouldAttemptServerBackgroundRemoval({ + sourceType, + imageBytes, + }) + ) { + const serverRemoval = await applyServerBackgroundRemoval({ + uid, + itemId, + photoUrl: photoUrls[0], + imageBytes, + }); + imageBytes = serverRemoval.bytes; + backgroundRemovalMethod = serverRemoval.method; + } else { + logger.info("[BgRemoval] Server-side removal skipped; optional for this source", { + uidHash, + itemId, + sourceType: sourceType || null, + normalizedBytes: imageBytes.byteLength, + }); + } + } let warning: string | null = null; + const storedImageCandidates: { + originalUrl?: string | null; + aiUrl?: string | null; + cleanedUrl?: string | null; + isPrimary?: boolean; + }[] = Array.isArray(after.images) + ? after.images + : Array.isArray(after.photos?.images) + ? after.photos.images + : photoUrls.map((url, index) => ({ + originalUrl: url, + isPrimary: index === 0, + })); + + const storedImages = storedImageCandidates + .map((image) => ({ + originalUrl: String(image?.originalUrl ?? "").trim(), + ...(String(image?.aiUrl ?? "").trim() + ? { aiUrl: String(image?.aiUrl ?? "").trim() } + : {}), + ...(String(image?.cleanedUrl ?? "").trim() + ? { cleanedUrl: String(image?.cleanedUrl ?? "").trim() } + : {}), + isPrimary: Boolean(image?.isPrimary), + })) + .filter((image) => image.originalUrl); + const primaryStoredImage = + storedImages.find((image) => image.isPrimary) ?? storedImages[0] ?? null; + const primaryCleanedUrl = String(primaryStoredImage?.cleanedUrl ?? "").trim() || null; let category = normalizeCategory(extracted.category); let subCategory = normalizeSubCategory(extracted.subCategory); @@ -1438,6 +2087,9 @@ export const ingestItemFromPhotos = onDocumentWritten( const pattern = normalizePattern(extracted.pattern); const material = normalizeMaterial(extracted.material); + const materialConfidence = clamp01( + extracted.materialConfidence ?? extracted.confidence?.material ?? 0, + ); const fit = normalizeEnum(extracted.fit, ALLOWED_FITS); const style = normalizeEnum(extracted.style, ALLOWED_STYLES); const sleeveLength = normalizeEnum( @@ -1543,19 +2195,29 @@ export const ingestItemFromPhotos = onDocumentWritten( extracted.confidence?.subCategory ?? 0, ); const colorsConfidence = clamp01(extracted.confidence?.colors ?? 0); + const detailTags = normalizeDetailTags(extracted.detailTags); + const extractionColorInputs = [ + ...(Array.isArray(extracted.colors) ? extracted.colors : []), + extracted.primaryColor, + extracted.color, + ...(Array.isArray(extracted.secondaryColors) ? extracted.secondaryColors : []), + ...(Array.isArray(extracted.dominantColors) ? extracted.dominantColors : []), + ...(Array.isArray(extracted.palette) ? extracted.palette : []), + extracted.displayColor, + ...(Array.isArray(extracted.displayColors) ? extracted.displayColors : []), + ].filter((value): value is string => typeof value === "string" && value.trim().length > 0); const { colors: aiColorsRaw, colorLabel } = normalizeColors( - extracted.colors, + extractionColorInputs, ); const aiPrimaryColor = normalizeColorToken( - String(extracted.primaryColor ?? ""), + String(extracted.primaryColor ?? extracted.color ?? aiColorsRaw[0] ?? ""), ); const aiColors = aiColorsRaw.slice(0, 3); const safeAiColorLabel = colorLabel?.trim() ? colorLabel.trim() : null; const aiDisplayColor = normalizeDisplayColorValue(extracted.displayColor); const aiDisplayColors = normalizeDisplayColors(extracted.displayColors); - const originalBytes = await downloadImageBytes(photoUrls[0]); - const metadata = await sharp(originalBytes).metadata(); + const metadata = await sharp(imageBytes).metadata(); const imageWidth = metadata.width ?? 0; const imageHeight = metadata.height ?? 0; if (!imageWidth || !imageHeight) { @@ -1563,15 +2225,22 @@ export const ingestItemFromPhotos = onDocumentWritten( } const cropRect = clampBbox(extracted.bbox, imageWidth, imageHeight); - const croppedForColorBytes = await sharp(originalBytes) + const croppedForColorBytes = await sharp(imageBytes) .extract({ left: cropRect.left, top: cropRect.top, width: cropRect.cropWidth, height: cropRect.cropHeight, }) + .resize({ + width: MAX_CROP_IMAGE_DIMENSION, + height: MAX_CROP_IMAGE_DIMENSION, + fit: "inside", + withoutEnlargement: true, + }) .png() .toBuffer(); + const pixelResult = await detectPixelColor(croppedForColorBytes); const croppedBytes = await sharp(croppedForColorBytes) .jpeg({ quality: 85 }) .toBuffer(); @@ -1588,7 +2257,6 @@ export const ingestItemFromPhotos = onDocumentWritten( ); const thumbUrl = await uploadImageAndGetUrl(thumbStoragePath, thumbBytes); - const pixelResult = await detectPixelColor(croppedForColorBytes); const pixelPrimary = pixelResult.pixelColor; const pixelColors: AllowedColor[] = pixelPrimary ? [pixelPrimary] : []; @@ -1665,6 +2333,24 @@ export const ingestItemFromPhotos = onDocumentWritten( ); let formalityScore = constrainedScores.formalityScore; let warmthScore = constrainedScores.warmthScore; + const confidenceOverall = Number( + ( + (categoryConfidence + + subCategoryConfidence + + colorsConfidence + + brandConfidence + + materialConfidence) / + 5 + ).toFixed(3), + ); + const confidenceSummary = { + overall: confidenceOverall, + notes: + normalizeFreeTextLabel(extracted.confidenceSummary?.notes, 180) ?? + (photoUrls.length > 1 + ? "Combined multiple images for a more reliable extraction." + : "Built from a single item image."), + }; if (subCategory === "tshirt") { formalityScore = Math.min(formalityScore, 0.5); @@ -1672,7 +2358,7 @@ export const ingestItemFromPhotos = onDocumentWritten( } logger.info("Ingestion normalized output", { - uid, + uidHash, itemId, normalized: { category, @@ -1684,16 +2370,19 @@ export const ingestItemFromPhotos = onDocumentWritten( colors: colorsConfidence, brand: brandConfidence, }, - brand: + hasBrand: !!( hasUserBrandOverride || shouldPreserveLegacyManualBrand - ? (after.brand ?? null) - : brand, + ? after.brand + : brand + ), brandConfidence, - brandEvidence, - brandCandidates, + hasBrandEvidence: !!brandEvidence, + brandCandidateCount: brandCandidates.length, wearSlot: wearSlot(category), pattern, material, + materialConfidence, + ...(detailTags.length > 0 ? { detailTags } : {}), fit, style, formality, @@ -1712,30 +2401,29 @@ export const ingestItemFromPhotos = onDocumentWritten( occasionTags, seasonTags, aestheticTags, - crop: cropRect.normalized, - photos: { - primaryUrl: photoUrls[0], - urls: photoUrls, - croppedUrl, - thumbUrl, - }, + hasCrop: !!cropRect.normalized, + hasCroppedUrl: !!croppedUrl, + hasThumbUrl: !!thumbUrl, finalColors, ...(finalColorLabel ? { finalColorLabel } : {}), finalPrimaryColor, finalDisplayColor, finalDisplayColors, - generatedName: inferredName, + detailTags, + confidenceSummary, + generatedNameLength: String(inferredName ?? "").length, + backgroundRemovalMethod, colorSource: hasUserColorOverride ? "user" : "ai", formalityScore, warmthScore, aiDebug: { - brandEvidence, - brandCandidates, - aiColors, - aiPrimaryColor: aiPrimaryColor ? toTitleCase(aiPrimaryColor) : null, - aiColorLabel: safeAiColorLabel ?? null, - pixelColors, - pixelColorHex: pixelResult.pixelHex, + hasBrandEvidence: !!brandEvidence, + brandCandidateCount: brandCandidates.length, + aiColorCount: aiColors.length, + hasAiPrimaryColor: !!aiPrimaryColor, + hasAiColorLabel: !!safeAiColorLabel, + pixelColorCount: pixelColors.length, + hasPixelColorHex: !!pixelResult.pixelHex, colorConfidence, colorNeedsReview: persistedColorNeedsReview, }, @@ -1747,29 +2435,61 @@ export const ingestItemFromPhotos = onDocumentWritten( }); logger.info("Ingestion final write payload", { - uid, + uidHash, itemId, finalWrite: { category, subCategory, type: itemType, - colors: finalColors, - brand: + colorCount: finalColors.length, + detailTagCount: detailTags.length, + hasBrand: !!( hasUserBrandOverride || shouldPreserveLegacyManualBrand - ? (after.brand ?? null) - : brand, - generatedName: inferredName, + ? after.brand + : brand + ), + generatedNameLength: String(inferredName ?? "").length, lastProcessedSourceHash: currentSourceHash, + backgroundRemovalMethod, }, }); const latestBeforeDone = await ref.get(); + if (!latestBeforeDone.exists) { + logger.warn("[INGEST_VALIDATE] skipping completion because item doc was hard-deleted", { + uidHash, + itemId, + runId, + }); + return; + } const latestRunId = String( latestBeforeDone.get("ingestion.runId") ?? "", ).trim(); + const latestDraftState = String( + latestBeforeDone.get("draftState") ?? "", + ).trim().toLowerCase(); + const latestLifecycleStatus = String( + latestBeforeDone.get("itemLifecycleStatus") ?? "", + ).trim().toLowerCase(); + if ( + latestDraftState === "cancelled" || + latestDraftState === "awaiting_confirmation" || + latestLifecycleStatus === "deleted" || + latestLifecycleStatus === "candidate" + ) { + logger.warn("[INGEST_VALIDATE] skipping completion because item was removed or is candidate-only", { + uidHash, + itemId, + runId, + latestDraftState, + latestLifecycleStatus, + }); + return; + } if (latestRunId && latestRunId !== runId) { logger.warn("Skipping stale ingestion completion write", { - uid, + uidHash, itemId, runId, latestRunId, @@ -1777,20 +2497,182 @@ export const ingestItemFromPhotos = onDocumentWritten( return; } + const latestImageCandidates: { + originalUrl?: string | null; + aiUrl?: string | null; + cleanedUrl?: string | null; + isPrimary?: boolean; + }[] = Array.isArray(latestBeforeDone.get("images")) + ? latestBeforeDone.get("images") + : Array.isArray(latestBeforeDone.get("photos.images")) + ? latestBeforeDone.get("photos.images") + : []; + const latestImages = latestImageCandidates + .map((image) => ({ + originalUrl: String(image?.originalUrl ?? "").trim(), + ...(String(image?.aiUrl ?? "").trim() + ? { aiUrl: String(image?.aiUrl ?? "").trim() } + : {}), + ...(String(image?.cleanedUrl ?? "").trim() + ? { cleanedUrl: String(image?.cleanedUrl ?? "").trim() } + : {}), + isPrimary: Boolean(image?.isPrimary), + })) + .filter((image) => image.originalUrl); + const latestPrimaryImage = + latestImages.find((image) => image.isPrimary) ?? latestImages[0] ?? null; + const preservedOriginalImageUrl = + String( + latestPrimaryImage?.originalUrl ?? + latestBeforeDone.get("originalImageUrl") ?? + primaryStoredImage?.originalUrl ?? + after.originalImageUrl ?? + photoUrls[0] ?? + "", + ).trim() || photoUrls[0]; + const preservedCleanedImageUrl = + String( + latestPrimaryImage?.cleanedUrl ?? + latestBeforeDone.get("cleanedImageUrl") ?? + latestBeforeDone.get("photos.cleanedUrl") ?? + primaryCleanedUrl ?? + after.cleanedImageUrl ?? + "", + ).trim() || null; + const preservedImages = + latestImages.length > 0 + ? latestImages + : storedImages.map((image, index) => ({ + originalUrl: + index === 0 ? preservedOriginalImageUrl : image.originalUrl, + ...(index === 0 && preservedCleanedImageUrl + ? { cleanedUrl: preservedCleanedImageUrl } + : image.cleanedUrl + ? { cleanedUrl: image.cleanedUrl } + : {}), + ...(image.aiUrl ? { aiUrl: image.aiUrl } : {}), + isPrimary: image.isPrimary, + })); + const preservedPrimaryDisplayUrl = + preservedCleanedImageUrl ?? + (String( + latestBeforeDone.get("photoUrl") ?? + latestBeforeDone.get("photos.primaryUrl") ?? + preservedOriginalImageUrl, + ).trim() || + preservedOriginalImageUrl); + const preservedPhotoUrls = Array.isArray(latestBeforeDone.get("imageUrls")) + ? latestBeforeDone + .get("imageUrls") + .map((value: unknown) => String(value ?? "").trim()) + .filter(Boolean) + : photoUrls; + const latestUserEditedFields = stringSet( + latestBeforeDone.get("userEditedFields"), + ); + const latestValueChanged = (fieldPath: string, originalValue: unknown) => + valuesDiffer(latestBeforeDone.get(fieldPath), originalValue); + const latestBrandSource = String( + latestBeforeDone.get("brandSource") ?? after.brandSource ?? "", + ) + .trim() + .toLowerCase(); + const latestColorSource = String( + latestBeforeDone.get("colorSource") ?? after.colorSource ?? "", + ) + .trim() + .toLowerCase(); + const latestName = String(latestBeforeDone.get("name") ?? "") + .replace(/\s+/g, " ") + .trim(); + const preserveCategory = + latestUserEditedFields.has("category") || + latestValueChanged("category", after.category); + const preserveSubCategory = + preserveCategory || + latestUserEditedFields.has("subCategory") || + latestValueChanged("subCategory", after.subCategory); + const preservePattern = + latestUserEditedFields.has("pattern") || + latestValueChanged("pattern", after.pattern); + const preserveMaterial = + latestUserEditedFields.has("material") || + latestValueChanged("material", after.material); + const preserveFit = + latestUserEditedFields.has("fit") || + latestValueChanged("fit", after.fit); + const preserveOccasionTags = + latestUserEditedFields.has("occasionTags") || + latestValueChanged("occasionTags", after.occasionTags); + const preserveSeasonTags = + latestUserEditedFields.has("seasonTags") || + latestValueChanged("seasonTags", after.seasonTags); + const preserveName = + latestUserEditedFields.has("name") || + latestValueChanged("name", after.name); + const latestDisplayColorsValue = latestBeforeDone.get("displayColors"); + const latestDisplayColorCandidates = [ + latestBeforeDone.get("displayColor"), + ...(Array.isArray(latestDisplayColorsValue) ? latestDisplayColorsValue : []), + ].filter((value): value is string => typeof value === "string" && value.trim().length > 0); + const latestStoredColors = normalizeColors(latestBeforeDone.get("colors")).colors; + const latestStoredPrimaryColor = normalizeColorToken( + String(latestBeforeDone.get("primaryColor") ?? ""), + ); + const latestStoredDisplayColors = normalizeColors(latestDisplayColorCandidates).colors; + const latestHasConcreteColorValue = + latestStoredColors.length > 0 || + !!latestStoredPrimaryColor || + latestStoredDisplayColors.length > 0; + const latestHasUserColorIntent = + latestColorSource === "user" || + latestUserEditedFields.has("colors") || + latestUserEditedFields.has("primaryColor") || + latestUserEditedFields.has("displayColor") || + latestUserEditedFields.has("displayColors"); + const latestColorChangedWithValue = + latestHasConcreteColorValue && + (latestValueChanged("colors", after.colors) || + latestValueChanged("primaryColor", after.primaryColor) || + latestValueChanged("displayColor", after.displayColor) || + latestValueChanged("displayColors", after.displayColors)); + const preserveColors = + latestHasConcreteColorValue && + (latestHasUserColorIntent || latestColorChangedWithValue); + const preserveBrand = + latestBrandSource === "user" || + latestUserEditedFields.has("brand") || + latestValueChanged("brand", after.brand); + const completionColorNeedsReview = preserveColors + ? false + : colorNeedsReview; + const completionLifecycleFields = + after.isDraft === true + ? isSnapDoneDraft + ? { isDraft: false, draftState: "ready", itemLifecycleStatus: "ready" } + : { draftState: "photo_uploaded", itemLifecycleStatus: "needs_review" } + : { draftState: "ready", itemLifecycleStatus: "ready" }; + await ref.set( { cleanedFromHash: currentSourceHash, - ...(after.isDraft === true ? { draftState: "photo_uploaded" } : {}), - ...(!String(after.name ?? "").trim() && inferredName + originalImageUrl: preservedOriginalImageUrl, + cleanedImageUrl: preservedCleanedImageUrl, + photoUrl: preservedPrimaryDisplayUrl, + imageUrls: preservedPhotoUrls, + images: preservedImages, + ...completionLifecycleFields, + ...(!preserveName && !latestName && inferredName ? { name: inferredName } : {}), - category, - subCategory, + ...(!preserveCategory ? { category, wearSlot: wearSlot(category) } : {}), + ...(!preserveSubCategory ? { subCategory } : {}), ...(itemType ? { type: itemType } : {}), - wearSlot: wearSlot(category), - pattern, - material, - fit, + ...(!preservePattern ? { pattern } : {}), + ...(!preserveMaterial ? { material, materialConfidence } : {}), + ...(detailTags.length > 0 ? { detailTags } : {}), + confidenceSummary, + ...(!preserveFit ? { fit } : {}), style, ...(formality ? { formality } : {}), ...(warmth ? { warmth } : {}), @@ -1805,11 +2687,15 @@ export const ingestItemFromPhotos = onDocumentWritten( legShape, hasLogo, logoPlacement, - ...(occasionTags.length > 0 ? { occasionTags } : {}), - ...(seasonTags.length > 0 ? { seasonTags } : {}), + ...(!preserveOccasionTags && occasionTags.length > 0 + ? { occasionTags } + : {}), + ...(!preserveSeasonTags && seasonTags.length > 0 + ? { seasonTags } + : {}), ...(aestheticTags.length > 0 ? { aestheticTags } : {}), crop: cropRect.normalized, - ...(!hasUserColorOverride + ...(!preserveColors ? { ...(finalColors.length > 0 ? { colors: finalColors } : {}), ...(finalColorLabel ? { colorLabel: finalColorLabel } : {}), @@ -1826,11 +2712,20 @@ export const ingestItemFromPhotos = onDocumentWritten( ), } : {}), + colorConfidence, + colorNeedsReview: completionColorNeedsReview, + ...(aiColors.length > 0 ? { aiColors } : {}), + ...(pixelColors.length > 0 ? { pixelColors } : {}), + ...(pixelResult.pixelHex + ? { pixelColorHex: pixelResult.pixelHex } + : {}), colorSource: "ai", colorUpdatedAt: Date.now(), } - : {}), - ...(!hasUserBrandOverride && !shouldPreserveLegacyManualBrand + : { + colorNeedsReview: completionColorNeedsReview, + }), + ...(!preserveBrand && !shouldPreserveLegacyManualBrand ? { brand, brandConfidence, @@ -1856,68 +2751,104 @@ export const ingestItemFromPhotos = onDocumentWritten( ...(pixelResult.pixelHex ? { pixelColorHex: pixelResult.pixelHex } : {}), - ...(hasUserColorOverride ? {} : { colorConfidence }), - colorNeedsReview: persistedColorNeedsReview, + ...(preserveColors ? {} : { colorConfidence }), + colorNeedsReview: completionColorNeedsReview, }, formalityScore, warmthScore, photos: { - primaryUrl: photoUrls[0], - urls: photoUrls, + originalUrl: preservedOriginalImageUrl, + primaryUrl: preservedPrimaryDisplayUrl, + urls: preservedPhotoUrls, + images: preservedImages, + ...(preservedCleanedImageUrl + ? { + cleanedUrl: preservedCleanedImageUrl, + cleanedSource: "vision", + } + : {}), croppedUrl, thumbUrl, }, + backgroundRemovalMethod, + ingestionStatus: "done", ingestion: { runId, status: "done", lastRunAt: FieldValue.serverTimestamp(), + completedAt: FieldValue.serverTimestamp(), + attempt: nextAttempt, + workerVersion: INGEST_WORKER_VERSION, + canRetry: false, + failedAt: null, + errorCode: null, + errorMessage: null, lastProcessedPhotoHash: photoHash, lastProcessedSourceHash: currentSourceHash, - ...(warning - ? { error: { message: warning, code: "warning" } } - : {}), + error: warning ? { message: warning, code: "warning" } : null, }, + updatedAt: Date.now(), }, { merge: true }, ); - logger.info("Ingestion transition", { - uid, + logger.info("[INGEST_SUCCESS] transition to done", { + uidHash, itemId, + isSnapDoneDraft, + finalizedDraft: isSnapDoneDraft, from: "processing", to: "done", category, subCategory, - colors: finalColors, - brand: + colorCount: finalColors.length, + hasBrand: !!( hasUserBrandOverride || shouldPreserveLegacyManualBrand - ? (after.brand ?? null) - : brand, + ? after.brand + : brand + ), processedSourceHash: currentSourceHash, }); } catch (error) { const message = error instanceof Error ? error.message : "Unknown ingestion error"; - logger.error("Ingestion failed", { uid, itemId, error: message }); + const safeMessage = safeIngestionFailureMessage(message); + logger.error("[INGEST_ERROR] ingestion failed", { + uidHash, + itemId, + isSnapDoneDraft, + error: message, + }); const latest = await ref.get(); + if (!latest.exists) { + logger.warn("[INGEST_ERROR] skipping failure write because item doc was hard-deleted", { + uidHash, + itemId, + runId, + error: message, + }); + return; + } const latestStatus = String( latest.get("ingestion.status") ?? latest.get("ingestionStatus") ?? "", ) .trim() .toLowerCase(); const latestRunId = String(latest.get("ingestion.runId") ?? "").trim(); + const latestDraftState = String(latest.get("draftState") ?? "").trim().toLowerCase(); + const latestLifecycleStatus = String(latest.get("itemLifecycleStatus") ?? "").trim().toLowerCase(); if (latestStatus === "done") { - logger.warn("Ingestion failure ignored because item is already done", { - uid, + logger.warn("[INGEST_ERROR] failure ignored because item is already done", { + uidHash, itemId, error: message, }); return; } if (latestRunId && latestRunId !== runId) { - logger.warn("Skipping stale ingestion failure write", { - uid, + logger.warn("[INGEST_ERROR] skipping stale failure write", { + uidHash, itemId, runId, latestRunId, @@ -1925,18 +2856,46 @@ export const ingestItemFromPhotos = onDocumentWritten( }); return; } + if ( + latestDraftState === "cancelled" || + latestDraftState === "awaiting_confirmation" || + latestLifecycleStatus === "deleted" || + latestLifecycleStatus === "candidate" + ) { + logger.warn("[INGEST_ERROR] skipping failure write because item was removed or is candidate-only", { + uidHash, + itemId, + runId, + latestDraftState, + latestLifecycleStatus, + error: message, + }); + return; + } await ref.set( { ...(after.isDraft === true ? { draftState: "failed" } : {}), + itemLifecycleStatus: "failed", + ingestionStatus: "failed", ingestion: { runId, status: "failed", lastRunAt: FieldValue.serverTimestamp(), + failedAt: FieldValue.serverTimestamp(), + attempt: nextAttempt, + workerVersion: INGEST_WORKER_VERSION, + canRetry: true, + errorCode: "ingestion_failed", + errorMessage: safeMessage, lastProcessedPhotoHash: photoHash, lastProcessedSourceHash: currentSourceHash, - error: { message }, + error: { + code: "ingestion_failed", + message: safeMessage, + }, }, + updatedAt: Date.now(), }, { merge: true }, ); diff --git a/functions/src/outfitChatV1.ts b/functions/src/outfitChatV1.ts index e1ca02a..b1af742 100644 --- a/functions/src/outfitChatV1.ts +++ b/functions/src/outfitChatV1.ts @@ -27,7 +27,13 @@ import { loadAssistantProfile, loadBehaviorProfile, } from "./shared/assistantMemory"; +import { loadCompactAuraMemoryContext } from "./shared/auraMemory"; import { getOrRefreshWardrobeSummary } from "./shared/wardrobeSummary"; +import { + RATE_LIMITS, + assertFunctionRateLimit, + redactUid, +} from "./shared/rateLimit"; if (!getApps().length) { initializeApp(); @@ -96,7 +102,11 @@ function normalizeAction(value: unknown): ChatAction { function normalizeSlot(value: unknown): Slot | null { const raw = String(value ?? "").trim().toLowerCase(); - return raw === "top" || raw === "bottom" || raw === "footwear" || raw === "outerwear" + return raw === "top" || + raw === "bottom" || + raw === "footwear" || + raw === "outerwear" || + raw === "accessory" ? raw : null; } @@ -161,13 +171,14 @@ function normalizeProfile(profile: unknown): UserProfile | null { } function countSlots(items: WardrobeItem[]): Record { - const counts: Record = {top: 0, bottom: 0, footwear: 0, outerwear: 0}; + const counts: Record = {top: 0, bottom: 0, footwear: 0, outerwear: 0, accessory: 0}; for (const item of items) { const category = String(item.category ?? "").trim().toLowerCase(); if (category === "top" || category === "one_piece") counts.top += 1; else if (category === "bottom") counts.bottom += 1; else if (category === "footwear" || category === "shoes") counts.footwear += 1; else if (category === "outerwear") counts.outerwear += 1; + else if (category === "accessory") counts.accessory += 1; } return counts; } @@ -394,8 +405,8 @@ async function parseChatAction(params: { } function buildIncompleteWardrobeMessage(slotCounts: Record): string { - const missing = (Object.keys(slotCounts) as Slot[]) - .filter((slot) => slot !== "outerwear" && slotCounts[slot] === 0) + const missing = (["top", "bottom", "footwear"] as Slot[]) + .filter((slot) => slotCounts[slot] === 0) .map((slot) => slot.replace(/_/g, " ")); if (missing.length === 0) { return "I can only build partial outfits right now. Add more analyzed items and try again."; @@ -410,6 +421,8 @@ export const outfitChatV1 = onCall( if (!uid) { throw new HttpsError("unauthenticated", "Authentication required"); } + await assertFunctionRateLimit(uid, "outfitGeneration", RATE_LIMITS.outfitGeneration); + const uidHash = redactUid(uid); const message = String(request.data?.message ?? "").trim(); const incomingThreadId = String(request.data?.threadId ?? "").trim() || null; @@ -433,12 +446,13 @@ export const outfitChatV1 = onCall( }, {merge: true}); } - const [recentMessagesSnap, userSnap, allItems, assistantProfile, behaviorProfile] = await Promise.all([ + const [recentMessagesSnap, userSnap, allItems, assistantProfile, behaviorProfile, memory] = await Promise.all([ threadRef.collection("messages").orderBy("createdAt", "asc").limitToLast(12).get(), userRef.get(), fetchWardrobeItems(db, uid), loadAssistantProfile(db, uid), loadBehaviorProfile(db, uid), + loadCompactAuraMemoryContext(db, uid, null), ]); const recentMessages = recentMessagesSnap.docs.map((docSnap) => docSnap.data() as ChatMessageDoc); @@ -467,14 +481,14 @@ export const outfitChatV1 = onCall( ); logger.info("outfitChatV1 parsed action", { - uid, + uidHash, threadId, action: parsed.action, requestedOutfitCount: parsed.requestedOutfitCount, outfitCount: parsed.clampedOutfitCount, - constraints: mergedConstraints, - references: {...parsed.references, outfitId: resolvedOutfitId}, - followup: parsed.followup, + constraintsKeys: Object.keys(mergedConstraints ?? {}), + hasResolvedOutfitId: !!resolvedOutfitId, + hasFollowup: !!parsed.followup, slotCounts, }); @@ -555,10 +569,11 @@ export const outfitChatV1 = onCall( constraints: mergedConstraints, excludeItemIds: explicitExcludes, lockedItemsBySlot, + memory, }); logger.info("outfitChatV1 slot counts", { - uid, + uidHash, threadId, eligible: generated.eligibleCount, ...generated.slotCounts, @@ -584,8 +599,9 @@ export const outfitChatV1 = onCall( const outfitData = outfitSnap.exists ? (outfitSnap.data() as {picks?: Array<{slot: Slot; itemId: string}>} | undefined) : null; - const currentPicks = Array.isArray(outfitData?.picks) - ? outfitData!.picks.filter((pick) => normalizeSlot(pick.slot)) + const existingPicks = outfitData?.picks; + const currentPicks = Array.isArray(existingPicks) + ? existingPicks.filter((pick) => normalizeSlot(pick.slot)) : []; if (currentPicks.length === 0) { @@ -650,7 +666,7 @@ export const outfitChatV1 = onCall( }, {merge: true}); logger.info("outfitChatV1 completed", { - uid, + uidHash, threadId, action: parsed.action, outfitCount: outfits.length, diff --git a/functions/src/previewProductLink.ts b/functions/src/previewProductLink.ts new file mode 100644 index 0000000..f744df8 --- /dev/null +++ b/functions/src/previewProductLink.ts @@ -0,0 +1,295 @@ +import { HttpsError, onCall } from "firebase-functions/v2/https"; +import { logger } from "firebase-functions/v2"; +import OpenAI from "openai"; + +import { + BLOCKED_STORE_MESSAGE, + ProductLinkError, +} from "./shared/productLinkExtractor"; +import { requireOpenAiApiKey } from "./shared/env"; +import { type AuraCandidateItem } from "./shared/auraCandidatePreview"; +import { + extractProductUrlMetadata, + type ProductUrlMetadata, +} from "./shared/productUrlMetadata"; +import { + brandFromSourceUrl, + buildUrlCandidatePreview, + clientLinkPreviewFromRequest, + fallbackLinkPreviewFromUrl, + hmSanitizedClientPreview, + hmSingleImageClientPreview, + isHmProductUrl, + type AuraLinkPreview, +} from "./shared/auraUrlCandidatePreview"; +import { + RATE_LIMITS, + assertFunctionRateLimit, + redactUid, +} from "./shared/rateLimit"; +import { redactUrlForLogs } from "./shared/safeFetch"; + +const PRODUCT_LINK_FAILURE_MESSAGE = + "I couldn't read this product page. Try another link, upload a screenshot, or add manually."; + +function codeForError( + error: unknown, +): "invalid-argument" | "failed-precondition" | "internal" { + if (!(error instanceof ProductLinkError)) return "internal"; + switch (error.code) { + case "invalid_url": + case "unsafe_url": + case "fetch_failed": + return "invalid-argument"; + case "blocked_store": + case "no_metadata": + case "no_images": + return "failed-precondition"; + default: + return "internal"; + } +} + +function cleanText(value?: string | null) { + return String(value ?? "").replace(/\s+/g, " ").trim(); +} + +function previewHasUsableProductEvidence(preview: AuraLinkPreview | null | undefined) { + if (!preview) return false; + const hasTitle = !!cleanText(preview.title); + const hasImage = !!cleanText(preview.imageUrl) || (preview.imageUrls ?? []).some((url) => !!cleanText(url)); + return hasTitle && hasImage; +} + +function candidateHasUsableProductEvidence(candidate: AuraCandidateItem, metadata: ProductLinkPreviewMetadata) { + const title = cleanText(candidate.title) || cleanText(metadata.title); + const image = + cleanText(candidate.primaryImageUrl) || + cleanText(candidate.imageUrls?.[0]) || + cleanText(candidate.secondaryImageUrls?.[0]) || + cleanText("imageUrl" in metadata ? metadata.imageUrl : null); + return { hasTitle: !!title, hasImage: !!image }; +} + +function throwIfCandidateMissingEvidence(candidate: AuraCandidateItem, metadata: ProductLinkPreviewMetadata) { + const evidence = candidateHasUsableProductEvidence(candidate, metadata); + if (!evidence.hasTitle) { + throw new ProductLinkError(PRODUCT_LINK_FAILURE_MESSAGE, "no_metadata", { + reason: "missing_product_title", + }); + } + if (!evidence.hasImage) { + throw new ProductLinkError(PRODUCT_LINK_FAILURE_MESSAGE, "no_images", { + reason: "missing_product_image", + }); + } +} + +function shouldSkipPreviewFallback(error: unknown) { + if (!(error instanceof ProductLinkError)) return false; + return ( + error.code === "blocked_store" || + error.code === "no_metadata" || + error.code === "no_images" || + error.code === "invalid_url" || + error.code === "unsafe_url" + ); +} + +function messageForError(error: unknown) { + if (error instanceof ProductLinkError && error.code === "blocked_store") return BLOCKED_STORE_MESSAGE; + if (error instanceof ProductLinkError) return error.message; + if (error instanceof Error) return error.message; + return "Could not read that product link."; +} + +function detailsForError(error: unknown) { + if (!(error instanceof ProductLinkError)) return undefined; + return { + productLinkCode: error.code, + ...(error.details ?? {}), + }; +} + +function domainFromUrl(url: string) { + try { + return new URL(url).hostname.replace(/^www\d*\./i, "").toLowerCase(); + } catch { + return ""; + } +} + +type ProductLinkPreviewMetadata = ProductUrlMetadata | AuraLinkPreview; + +function previewResponseFromCandidate(params: { + candidate: AuraCandidateItem; + metadata: ProductLinkPreviewMetadata; + imageCandidateCount: number; + imageExtractionSource: "json_ld" | "og_image" | "twitter" | "html_image" | "fallback" | null; +}) { + const { candidate, metadata } = params; + return { + ok: true, + preview: { + candidate, + metadata: { + sourceUrl: metadata.sourceUrl, + domain: domainFromUrl(metadata.sourceUrl), + retailer: metadata.brand ?? brandFromSourceUrl(metadata.sourceUrl), + title: metadata.title ?? candidate.title ?? null, + brand: candidate.brand ?? metadata.brand ?? brandFromSourceUrl(metadata.sourceUrl), + color: candidate.color ?? null, + displayColor: candidate.displayColor ?? candidate.color ?? null, + displayColors: candidate.displayColors ?? [], + material: candidate.material ?? null, + materials: candidate.materials ?? [], + fit: candidate.fit ?? null, + sleeveLength: candidate.sleeveLength ?? null, + collar: candidate.collar ?? null, + length: candidate.length ?? null, + pattern: candidate.pattern ?? null, + price: candidate.priceDisplay ?? null, + priceAmount: candidate.retailPrice ?? null, + priceCurrency: candidate.currency ?? candidate.originalCurrency ?? null, + priceDisplay: candidate.priceDisplay ?? null, + salePrice: candidate.salePrice ?? null, + originalPrice: candidate.originalPrice ?? null, + description: metadata.description ?? null, + productDescription: candidate.productDescription ?? metadata.productDescription ?? metadata.description ?? null, + sizeHints: [], + sizeOptions: candidate.sizeOptions ?? [], + availableSizes: candidate.availableSizes ?? [], + careInstructions: candidate.careInstructions ?? [], + graphicText: candidate.graphicText ?? null, + motif: candidate.motif ?? null, + collaborationName: candidate.collaborationName ?? null, + categoryHints: [candidate.category, candidate.subCategory].filter( + (value): value is string => !!String(value ?? "").trim(), + ), + sku: "sku" in metadata ? metadata.sku ?? null : null, + imageExtractionSource: params.imageExtractionSource, + imageCandidateCount: params.imageCandidateCount, + }, + }, + }; +} + +export const previewProductLink = onCall( + { secrets: ["OPENAI_API_KEY"] }, + async (request) => { + const uid = request.auth?.uid; + if (!uid) { + throw new HttpsError("unauthenticated", "Please sign in first."); + } + + const url = String(request.data?.url ?? "").trim(); + if (!url) { + throw new HttpsError("invalid-argument", "A product link is required."); + } + await assertFunctionRateLimit(uid, "productLink", RATE_LIMITS.productLink); + + const rawClientPreview = clientLinkPreviewFromRequest(request.data?.linkPreview, url); + const hmClientFallback = + hmSanitizedClientPreview(rawClientPreview) ?? hmSingleImageClientPreview(rawClientPreview); + const clientPreview = isHmProductUrl(url) ? null : rawClientPreview; + const client = new OpenAI({ + apiKey: requireOpenAiApiKey(), + }); + + try { + logger.info("[LINK_PREVIEW] extracting product link", { + uidHash: redactUid(uid), + url: redactUrlForLogs(url), + hasClientLinkPreview: !!clientPreview, + clientPreviewImageCount: clientPreview?.imageUrls?.length ?? 0, + }); + const metadata = await extractProductUrlMetadata(url); + if (!metadata.imageUrl) { + const fallbackPreview = [clientPreview, hmClientFallback, { ...metadata, status: "needs_review" as const }] + .find(previewHasUsableProductEvidence) ?? null; + if (fallbackPreview) { + logger.info("[LINK_PREVIEW] using AURA URL fallback after missing server image", { + uidHash: redactUid(uid), + url: redactUrlForLogs(url), + hasTitle: !!fallbackPreview.title, + hasImageUrl: !!fallbackPreview.imageUrl, + imageCount: fallbackPreview.imageUrls?.length ?? 0, + sanitizedHmFallback: fallbackPreview === hmClientFallback, + }); + const built = await buildUrlCandidatePreview({ + client, + uid, + metadata: fallbackPreview, + }); + throwIfCandidateMissingEvidence(built.candidate, fallbackPreview); + return previewResponseFromCandidate({ + candidate: built.candidate, + metadata: fallbackPreview, + imageCandidateCount: built.rankedImageUrls.length || built.rawImageUrls.length, + imageExtractionSource: "fallback", + }); + } + throw new ProductLinkError(PRODUCT_LINK_FAILURE_MESSAGE, "no_images", { + reason: metadata.title || metadata.description ? "missing_product_image" : "missing_product_evidence", + }); + } + + const built = await buildUrlCandidatePreview({ + client, + uid, + metadata, + }); + throwIfCandidateMissingEvidence(built.candidate, metadata); + + logger.info("[LINK_PREVIEW] product link extracted", { + uidHash: redactUid(uid), + domain: domainFromUrl(metadata.sourceUrl), + imageExtractionSource: "aura_url_candidate", + imageCandidateCount: built.rankedImageUrls.length || built.rawImageUrls.length, + selectedImageHost: built.candidate.primaryImageUrl ? new URL(built.candidate.primaryImageUrl).hostname : null, + imageCount: built.candidate.imageUrls.length, + hasTitle: !!built.candidate.title, + }); + + return previewResponseFromCandidate({ + candidate: built.candidate, + metadata, + imageCandidateCount: built.rankedImageUrls.length || built.rawImageUrls.length, + imageExtractionSource: null, + }); + } catch (error) { + const fallbackPreview = [clientPreview, hmClientFallback, fallbackLinkPreviewFromUrl(url)] + .find(previewHasUsableProductEvidence) ?? null; + if (fallbackPreview && !shouldSkipPreviewFallback(error)) { + logger.info("[LINK_PREVIEW] using AURA client preview fallback", { + uidHash: redactUid(uid), + url: redactUrlForLogs(url), + code: error instanceof ProductLinkError ? error.code : null, + imageCount: fallbackPreview.imageUrls?.length ?? 0, + hasTitle: !!fallbackPreview.title, + }); + const built = await buildUrlCandidatePreview({ + client, + uid, + metadata: fallbackPreview, + }); + throwIfCandidateMissingEvidence(built.candidate, fallbackPreview); + return previewResponseFromCandidate({ + candidate: built.candidate, + metadata: fallbackPreview, + imageCandidateCount: built.rankedImageUrls.length || built.rawImageUrls.length, + imageExtractionSource: "fallback", + }); + } + const message = messageForError(error); + logger.error("[LINK_PREVIEW] extraction failed", { + uidHash: redactUid(uid), + url: redactUrlForLogs(url), + code: error instanceof ProductLinkError ? error.code : null, + blockedStoreFallback: error instanceof ProductLinkError && error.code === "blocked_store", + error: message, + }); + throw new HttpsError(codeForError(error), message, detailsForError(error)); + } + } +); diff --git a/functions/src/products/productCache.ts b/functions/src/products/productCache.ts new file mode 100644 index 0000000..338b6dc --- /dev/null +++ b/functions/src/products/productCache.ts @@ -0,0 +1,73 @@ +import { createHash } from "node:crypto"; +import { FieldValue, getFirestore } from "firebase-admin/firestore"; + +import type { NormalizedSearchRequest, ProductOption } from "./types"; + +type ProductSearchCacheDoc = { + key: string; + query: string; + products: ProductOption[]; + provider: "serpapi"; + createdAt: number; + expiresAt: number; +}; + +function stableJson(value: unknown): string { + if (Array.isArray(value)) return `[${value.map(stableJson).join(",")}]`; + if (value && typeof value === "object") { + return `{${Object.entries(value as Record) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([key, entry]) => `${JSON.stringify(key)}:${stableJson(entry)}`) + .join(",")}}`; + } + return JSON.stringify(value); +} + +export function buildProductSearchCacheKey(request: NormalizedSearchRequest) { + const payload = { + query: request.query, + category: request.category ?? null, + priceTiers: request.priceTiers.slice().sort(), + genderPresentation: request.genderPresentation, + country: request.country, + language: request.language, + }; + return createHash("sha256").update(stableJson(payload)).digest("hex"); +} + +export async function getCachedProductSearch(key: string) { + const snapshot = await getFirestore().collection("productSearchCache").doc(key).get(); + if (!snapshot.exists) return null; + const data = snapshot.data() as Partial | undefined; + const expiresAt = Number(data?.expiresAt ?? 0); + if (!Number.isFinite(expiresAt) || expiresAt <= Date.now()) return null; + return { + products: Array.isArray(data?.products) ? data.products : [], + provider: data?.provider === "serpapi" ? data.provider : "serpapi", + }; +} + +export async function setCachedProductSearch(params: { + key: string; + request: NormalizedSearchRequest; + products: ProductOption[]; + ttlHours: number; +}) { + const now = Date.now(); + const ttlMs = Math.max(1, params.ttlHours) * 60 * 60 * 1000; + await getFirestore() + .collection("productSearchCache") + .doc(params.key) + .set( + { + key: params.key, + query: params.request.query, + products: params.products, + provider: "serpapi", + createdAt: now, + expiresAt: now + ttlMs, + updatedAt: FieldValue.serverTimestamp(), + }, + { merge: true }, + ); +} diff --git a/functions/src/products/productNormalizer.ts b/functions/src/products/productNormalizer.ts new file mode 100644 index 0000000..f5c5f0c --- /dev/null +++ b/functions/src/products/productNormalizer.ts @@ -0,0 +1,171 @@ +import { createHash } from "node:crypto"; + +import { isDirectMerchantUrl } from "../affiliate/affiliateLinks"; +import type { ProductCategory, ProductOption } from "./types"; + +export type RawSerpProduct = { + title?: unknown; + source?: unknown; + name?: unknown; + merchant?: unknown; + extracted_price?: unknown; + price?: unknown; + currency?: unknown; + thumbnail?: unknown; + serpapi_thumbnail?: unknown; + rating?: unknown; + reviews?: unknown; + link?: unknown; + product_link?: unknown; + serpapi_product_api?: unknown; + serpapi_immersive_product_api?: unknown; + immersive_product_page_token?: unknown; + position?: unknown; +}; + +const KNOWN_BRANDS = [ + "Nike", + "Adidas", + "New Balance", + "Converse", + "Vans", + "Levi's", + "Uniqlo", + "Zara", + "H&M", + "Gap", + "Banana Republic", + "J.Crew", + "COS", + "Mango", + "Nordstrom", + "Everlane", + "Abercrombie", + "ASOS", + "SSENSE", + "Farfetch", +]; + +function stripControlCharacters(value: string) { + return value + .split("") + .filter((char) => { + const code = char.charCodeAt(0); + return code > 31 && code !== 127; + }) + .join(""); +} + +function clean(value: unknown, max = 180) { + return stripControlCharacters(String(value ?? "")) + .replace(/\s+/g, " ") + .trim() + .slice(0, max); +} + +function numeric(value: unknown) { + if (typeof value === "number" && Number.isFinite(value)) return value; + const raw = clean(value, 80); + if (!raw) return null; + const match = raw.match(/\d+(?:[,.]\d{3})*(?:[.,]\d{1,2})?|\d+/); + if (!match) return null; + const normalized = match[0].replace(/,/g, ""); + const parsed = Number(normalized); + return Number.isFinite(parsed) && parsed > 0 ? Math.round(parsed * 100) / 100 : null; +} + +function integer(value: unknown) { + if (typeof value === "number" && Number.isFinite(value)) return Math.max(0, Math.round(value)); + const parsed = Number(clean(value, 40).replace(/[^0-9]/g, "")); + return Number.isFinite(parsed) && parsed >= 0 ? parsed : undefined; +} + +function inferCurrency(rawPrice: unknown, rawCurrency: unknown) { + const explicit = clean(rawCurrency, 8).toUpperCase(); + if (/^[A-Z]{3}$/.test(explicit)) return explicit; + const text = clean(rawPrice, 80); + if (text.includes("$")) return "USD"; + if (text.includes("£")) return "GBP"; + if (text.includes("€")) return "EUR"; + return "USD"; +} + +function inferTier(price: number | undefined, category?: ProductCategory): ProductOption["tier"] { + if (typeof price !== "number") return "mid"; + if (category === "accessories") { + if (price < 50) return "budget"; + if (price <= 160) return "mid"; + return "premium"; + } + if (category === "footwear") { + if (price < 80) return "budget"; + if (price <= 180) return "mid"; + return "premium"; + } + if (price < 75) return "budget"; + if (price <= 200) return "mid"; + return "premium"; +} + +function directUrlFromRaw(raw: RawSerpProduct) { + const candidates = [raw.link, raw.product_link].map((value) => clean(value, 1000)); + return candidates.find((url) => isDirectMerchantUrl(url)) ?? ""; +} + +function imageUrlFromRaw(raw: RawSerpProduct) { + const thumbnail = clean(raw.thumbnail, 1000); + if (/^https?:\/\//i.test(thumbnail)) return thumbnail; + const serpapiThumbnail = clean(raw.serpapi_thumbnail, 1000); + return /^https?:\/\//i.test(serpapiThumbnail) ? serpapiThumbnail : undefined; +} + +function inferBrand(title: string, merchant: string) { + const lowerTitle = title.toLowerCase(); + const matched = KNOWN_BRANDS.find((brand) => lowerTitle.includes(brand.toLowerCase())); + if (matched) return matched; + return merchant || "Retailer"; +} + +function productId(productUrl: string, title: string, merchant: string) { + return `live-${createHash("sha256") + .update(`${productUrl}|${title}|${merchant}`) + .digest("hex") + .slice(0, 18)}`; +} + +export function normalizeSerpApiProduct( + raw: RawSerpProduct, + category?: ProductCategory, +): ProductOption | null { + const title = clean(raw.title, 220); + const merchant = clean(raw.source ?? raw.merchant ?? raw.name, 120); + const productUrl = directUrlFromRaw(raw); + const affiliateEligible = isDirectMerchantUrl(productUrl); + + if (!title || !merchant) return null; + if (!productUrl) return null; + + const price = numeric(raw.extracted_price) ?? numeric(raw.price) ?? undefined; + const currency = price ? inferCurrency(raw.price, raw.currency) : undefined; + const rating = numeric(raw.rating) ?? undefined; + const reviews = integer(raw.reviews); + const imageUrl = imageUrlFromRaw(raw); + const tier = inferTier(price, category); + + return { + id: productId(productUrl, title, merchant), + title, + brand: inferBrand(title, merchant), + merchant, + price, + currency, + imageUrl, + productUrl, + tier, + source: "live", + affiliateEligible, + rating, + reviews, + lastUpdatedAt: new Date().toISOString(), + }; +} diff --git a/functions/src/products/productRanking.ts b/functions/src/products/productRanking.ts new file mode 100644 index 0000000..27c34d2 --- /dev/null +++ b/functions/src/products/productRanking.ts @@ -0,0 +1,146 @@ +import type { NormalizedSearchRequest, ProductOption, ProductTier } from "./types"; + +const BLOCKED_RE = + /\b(kids?|toddler|baby|costume|cosplay|doll|toy|sewing pattern|wholesale|replica|fake|dupe|used|pre-owned|preowned|refurbished|ebay|poshmark|mercari)\b/i; + +const KNOWN_FASHION_MERCHANTS = [ + "Nike", + "Adidas", + "Nordstrom", + "Macy's", + "H&M", + "Zara", + "ASOS", + "SSENSE", + "Farfetch", + "Net-a-Porter", + "Bloomingdale's", + "Uniqlo", + "Abercrombie", + "Urban Outfitters", + "Revolve", + "END Clothing", + "Gap", + "Banana Republic", + "J.Crew", + "COS", + "Mango", +]; + +const CATEGORY_TERMS: Record, string[]> = { + tops: ["shirt", "tee", "t-shirt", "top", "polo", "sweater", "blouse", "oxford"], + bottoms: ["trouser", "pant", "jean", "chino", "skirt", "short"], + footwear: ["sneaker", "shoe", "boot", "loafer", "sandal"], + outerwear: ["jacket", "coat", "blazer", "bomber", "overshirt", "outerwear"], + accessories: ["belt", "watch", "bag", "hat", "cap", "sunglasses", "scarf"], +}; + +function clean(value: unknown) { + return String(value ?? "") + .trim() + .toLowerCase() + .replace(/[_-]+/g, " ") + .replace(/[^a-z0-9\s'-]+/g, " ") + .replace(/\s+/g, " "); +} + +function tokens(value: string) { + return clean(value).split(/\s+/).filter((token) => token.length > 2); +} + +function merchantKnown(merchant: string) { + const cleanMerchant = clean(merchant); + return KNOWN_FASHION_MERCHANTS.some((known) => cleanMerchant.includes(clean(known))); +} + +function hasAny(text: string, values: string[]) { + const cleanText = clean(text); + return values.some((value) => cleanText.includes(clean(value))); +} + +function titleWordScore(title: string, itemType: string) { + const titleText = clean(title); + const itemTokens = tokens(itemType); + if (!itemTokens.length) return 0; + return itemTokens.reduce((score, token) => score + (titleText.includes(token) ? 6 : 0), 0); +} + +function priceTierScore(productTier: ProductTier, request: NormalizedSearchRequest) { + if (request.budgetPreference && productTier === request.budgetPreference) return 12; + if (request.priceTiers.includes(productTier)) return 8; + return -2; +} + +function scoreProduct(product: ProductOption, request: NormalizedSearchRequest) { + const title = clean(product.title); + const merchant = clean(product.merchant); + const badText = `${title} ${merchant}`; + if (BLOCKED_RE.test(badText)) return -999; + + let score = 0; + if (product.affiliateEligible) score += 14; + if (product.imageUrl) score += 10; + if (typeof product.price === "number") score += 9; + score += titleWordScore(product.title, request.itemType); + if (request.preferredColors.some((color) => title.includes(clean(color)))) score += 8; + if (request.category && hasAny(product.title, CATEGORY_TERMS[request.category])) score += 9; + if (request.styleTags.some((tag) => title.includes(clean(tag)))) score += 4; + if (merchantKnown(product.merchant)) score += 9; + if (typeof product.rating === "number" && product.rating >= 4) score += 4; + if (typeof product.reviews === "number" && product.reviews > 20) score += 3; + score += priceTierScore(product.tier, request); + if (!product.productUrl) score -= 50; + if (!titleWordScore(product.title, request.itemType)) score -= 8; + return score; +} + +function diversifyByTier(products: ProductOption[], maxResults: number) { + const selected: ProductOption[] = []; + const seenMerchants = new Set(); + const tiers: ProductTier[] = ["budget", "mid", "premium"]; + + for (const tier of tiers) { + if (selected.length >= maxResults) break; + const candidate = products.find((product) => { + const merchant = clean(product.merchant); + return product.tier === tier && !selected.includes(product) && !seenMerchants.has(merchant); + }); + if (!candidate) continue; + selected.push(candidate); + seenMerchants.add(clean(candidate.merchant)); + } + + for (const product of products) { + if (selected.length >= maxResults) break; + const merchant = clean(product.merchant); + if (selected.includes(product) || seenMerchants.has(merchant)) continue; + selected.push(product); + seenMerchants.add(merchant); + } + + for (const product of products) { + if (selected.length >= maxResults) break; + if (!selected.includes(product)) selected.push(product); + } + + return selected; +} + +export function rankAndFilterProducts( + products: ProductOption[], + request: NormalizedSearchRequest, +) { + const scored = products + .map((product) => ({ + product, + score: scoreProduct(product, request), + })) + .filter((entry) => entry.score >= 30) + .sort((a, b) => b.score - a.score) + .map((entry) => ({ + ...entry.product, + confidenceScore: Math.max(0, Math.min(100, Math.round(entry.score))), + })); + + return diversifyByTier(scored, request.maxResults); +} diff --git a/functions/src/products/searchLiveProducts.ts b/functions/src/products/searchLiveProducts.ts new file mode 100644 index 0000000..5acbc98 --- /dev/null +++ b/functions/src/products/searchLiveProducts.ts @@ -0,0 +1,267 @@ +import { FieldValue, getFirestore } from "firebase-admin/firestore"; +import { logger } from "firebase-functions/v2"; +import { HttpsError, onCall } from "firebase-functions/v2/https"; + +import { buildAffiliateUrl } from "../affiliate/affiliateLinks"; +import { + buildProductSearchCacheKey, + getCachedProductSearch, + setCachedProductSearch, +} from "./productCache"; +import { rankAndFilterProducts } from "./productRanking"; +import { searchSerpApiProducts } from "./serpApiProvider"; +import type { + NormalizedSearchRequest, + ProductCategory, + ProductSourceScreen, + ProductTier, + SearchLiveProductsRequest, + SearchLiveProductsResponse, +} from "./types"; + +const PRODUCT_CATEGORIES: ProductCategory[] = [ + "tops", + "bottoms", + "footwear", + "outerwear", + "accessories", +]; +const PRODUCT_TIERS: ProductTier[] = ["budget", "mid", "premium"]; +const SOURCE_SCREENS: ProductSourceScreen[] = ["home", "insights", "aura_chat", "outfit_card"]; +const GENDER_PRESENTATIONS: NormalizedSearchRequest["genderPresentation"][] = [ + "mens", + "womens", + "unisex", +]; + +function envString(key: string, fallback: string) { + const value = String(process.env[key] ?? "").trim(); + return value || fallback; +} + +function envNumber(key: string, fallback: number, min: number, max: number) { + const parsed = Number(process.env[key]); + if (!Number.isFinite(parsed)) return fallback; + return Math.max(min, Math.min(max, Math.round(parsed))); +} + +function productSearchEnabled() { + return String(process.env.PRODUCT_SEARCH_ENABLED ?? "").trim().toLowerCase() === "true"; +} + +function stripControlCharacters(value: string) { + return value + .split("") + .filter((char) => { + const code = char.charCodeAt(0); + return code > 31 && code !== 127; + }) + .join(""); +} + +function sanitizeText(value: unknown, max = 80) { + return stripControlCharacters(String(value ?? "")) + .replace(/https?:\/\/\S+/gi, "") + .replace(/[^a-zA-Z0-9\s'&.-]+/g, " ") + .replace(/\s+/g, " ") + .trim() + .slice(0, max); +} + +function normalizeList(values: unknown, maxItems: number, maxLength: number) { + if (!Array.isArray(values)) return []; + const seen = new Set(); + const out: string[] = []; + values.forEach((value) => { + const next = sanitizeText(value, maxLength).toLowerCase(); + if (!next || seen.has(next)) return; + seen.add(next); + out.push(next); + }); + return out.slice(0, maxItems); +} + +function normalizeEnum(value: unknown, allowed: readonly T[]) { + const text = String(value ?? "").trim().toLowerCase(); + return allowed.includes(text as T) ? (text as T) : undefined; +} + +function categoryHint(category?: ProductCategory) { + if (category === "footwear") return "shoes"; + if (category === "outerwear") return "jacket"; + if (category === "bottoms") return "pants"; + if (category === "tops") return "shirt"; + if (category === "accessories") return "accessory"; + return ""; +} + +function buildSearchQuery(request: { + itemType: string; + category?: ProductCategory; + preferredColors: string[]; + styleTags: string[]; + genderPresentation: NormalizedSearchRequest["genderPresentation"]; +}) { + const itemLower = request.itemType.toLowerCase(); + const color = request.preferredColors.find((entry) => !itemLower.includes(entry.toLowerCase())); + const styleTerms = request.styleTags + .filter((tag) => !/shopping|outfit|closet|wardrobe|piece|base/i.test(tag)) + .slice(0, 2); + const gender = request.genderPresentation === "unisex" ? "unisex" : request.genderPresentation; + const category = categoryHint(request.category); + const suffix = request.category === "accessories" ? "fashion" : "clothing fashion"; + return [color, request.itemType, ...styleTerms, gender, category, suffix] + .filter(Boolean) + .join(" ") + .replace(/\s+/g, " ") + .trim() + .slice(0, 140); +} + +function normalizeRequest(data: unknown): NormalizedSearchRequest { + const raw = (data ?? {}) as Partial; + const itemType = sanitizeText(raw.itemType, 80); + if (!itemType) { + throw new HttpsError("invalid-argument", "A wardrobe suggestion item type is required."); + } + + const category = normalizeEnum(raw.category, PRODUCT_CATEGORIES); + const priceTiers = normalizeList(raw.priceTiers, 3, 12) + .map((tier) => normalizeEnum(tier, PRODUCT_TIERS)) + .filter((tier): tier is ProductTier => !!tier); + const genderPresentation = + normalizeEnum(raw.genderPresentation, GENDER_PRESENTATIONS) ?? "unisex"; + const preferredColors = normalizeList(raw.preferredColors, 3, 24); + const styleTags = normalizeList(raw.styleTags, 3, 28); + const maxResults = Math.max( + 1, + Math.min( + envNumber("MAX_LIVE_SEARCH_RESULTS", 3, 1, 6), + typeof raw.maxResults === "number" && Number.isFinite(raw.maxResults) + ? Math.round(raw.maxResults) + : 3, + ), + ); + const normalized = { + itemType, + category, + styleTags, + preferredColors, + priceTiers: priceTiers.length ? priceTiers : PRODUCT_TIERS, + budgetPreference: normalizeEnum(raw.budgetPreference, PRODUCT_TIERS), + genderPresentation, + sourceScreen: normalizeEnum(raw.sourceScreen, SOURCE_SCREENS), + maxResults, + country: envString("PRODUCT_SEARCH_COUNTRY", "us").toLowerCase().slice(0, 2) || "us", + language: envString("PRODUCT_SEARCH_LANGUAGE", "en").toLowerCase().slice(0, 5) || "en", + }; + + return { + ...normalized, + query: buildSearchQuery(normalized), + }; +} + +function todayUsageDocId() { + const now = new Date(); + return `productSearch_${now.getUTCFullYear()}_${String(now.getUTCMonth() + 1).padStart(2, "0")}_${String(now.getUTCDate()).padStart(2, "0")}`; +} + +async function assertDailyRateLimit(uid: string) { + const limit = envNumber("PRODUCT_SEARCH_DAILY_LIMIT", 20, 1, 1000); + const ref = getFirestore() + .collection("users") + .doc(uid) + .collection("usage") + .doc(todayUsageDocId()); + + await getFirestore().runTransaction(async (transaction) => { + const snapshot = await transaction.get(ref); + const count = Number(snapshot.data()?.count ?? 0); + if (count >= limit) { + throw new HttpsError("resource-exhausted", "Live product search limit reached for today."); + } + transaction.set( + ref, + { + count: count + 1, + updatedAt: FieldValue.serverTimestamp(), + }, + { merge: true }, + ); + }); +} + +function emptyResponse(cacheHit = false): SearchLiveProductsResponse { + return { + ok: true, + products: [], + provider: "serpapi", + cacheHit, + }; +} + +export const searchLiveProducts = onCall( + async (request): Promise => { + const uid = request.auth?.uid; + if (!uid) { + throw new HttpsError("unauthenticated", "Please sign in first."); + } + + const provider = envString("PRODUCT_SEARCH_PROVIDER", "serpapi").toLowerCase(); + const normalized = normalizeRequest(request.data); + if (!productSearchEnabled() || provider !== "serpapi") { + return { + ...emptyResponse(false), + disabled: true, + }; + } + + const cacheKey = buildProductSearchCacheKey(normalized); + const cached = await getCachedProductSearch(cacheKey); + if (cached) { + return { + ok: true, + products: cached.products.slice(0, normalized.maxResults), + provider: "serpapi", + cacheHit: true, + }; + } + + await assertDailyRateLimit(uid); + + try { + const rawProducts = await searchSerpApiProducts(normalized); + const ranked = rankAndFilterProducts(rawProducts, normalized) + .slice(0, normalized.maxResults) + .map((product) => ({ + ...product, + affiliateUrl: product.affiliateEligible + ? buildAffiliateUrl(product.productUrl) + : product.productUrl, + })); + + await setCachedProductSearch({ + key: cacheKey, + request: normalized, + products: ranked, + ttlHours: envNumber("PRODUCT_SEARCH_CACHE_TTL_HOURS", 24, 1, 168), + }); + + return { + ok: true, + products: ranked, + provider: "serpapi", + cacheHit: false, + }; + } catch (error) { + logger.warn("[PRODUCT_SEARCH] live provider failed", { + itemTypeLength: normalized.itemType.length, + category: normalized.category ?? null, + sourceScreen: normalized.sourceScreen ?? null, + code: error instanceof Error ? error.message : "unknown", + }); + throw new HttpsError("unavailable", "Live product search is unavailable right now."); + } + }, +); diff --git a/functions/src/products/serpApiProvider.ts b/functions/src/products/serpApiProvider.ts new file mode 100644 index 0000000..975fde7 --- /dev/null +++ b/functions/src/products/serpApiProvider.ts @@ -0,0 +1,150 @@ +import { logger } from "firebase-functions/v2"; + +import { + normalizeSerpApiProduct, + type RawSerpProduct, +} from "./productNormalizer"; +import type { NormalizedSearchRequest, ProductOption } from "./types"; + +type SerpShoppingResponse = { + shopping_results?: RawSerpProduct[]; + error?: string; +}; + +type SerpImmersiveResponse = { + product_results?: { + stores?: RawSerpProduct[]; + }; + error?: string; +}; + +const SERPAPI_TIMEOUT_MS = 9000; + +function envString(key: string, fallback = "") { + const value = String(process.env[key] ?? "").trim(); + return value || fallback; +} + +async function fetchJson(url: URL) { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), SERPAPI_TIMEOUT_MS); + try { + const response = await fetch(url.toString(), { + method: "GET", + signal: controller.signal, + }); + if (!response.ok) { + throw new Error(`provider_http_${response.status}`); + } + return (await response.json()) as T; + } finally { + clearTimeout(timeout); + } +} + +function shoppingUrl(request: NormalizedSearchRequest, apiKey: string) { + const url = new URL("https://serpapi.com/search.json"); + url.searchParams.set("engine", "google_shopping"); + url.searchParams.set("q", request.query); + url.searchParams.set("gl", request.country); + url.searchParams.set("hl", request.language); + url.searchParams.set("api_key", apiKey); + return url; +} + +function immersiveUrl(token: string, request: NormalizedSearchRequest, apiKey: string) { + const url = new URL("https://serpapi.com/search.json"); + url.searchParams.set("engine", "google_immersive_product"); + url.searchParams.set("page_token", token); + url.searchParams.set("more_stores", "true"); + url.searchParams.set("gl", request.country); + url.searchParams.set("hl", request.language); + url.searchParams.set("api_key", apiKey); + return url; +} + +function immersiveToken(raw: RawSerpProduct) { + const directToken = String(raw.immersive_product_page_token ?? "").trim(); + if (directToken) return directToken; + const apiUrl = String(raw.serpapi_immersive_product_api ?? raw.serpapi_product_api ?? "").trim(); + if (!apiUrl) return ""; + try { + return new URL(apiUrl).searchParams.get("page_token") ?? ""; + } catch { + return ""; + } +} + +async function fetchImmersiveStores( + raw: RawSerpProduct, + request: NormalizedSearchRequest, + apiKey: string, +) { + const token = immersiveToken(raw); + if (!token) return []; + try { + const data = await fetchJson(immersiveUrl(token, request, apiKey)); + const stores = data.product_results?.stores ?? []; + const fallbackTitle = String(raw.title ?? "").trim(); + return stores + .map((store) => + normalizeSerpApiProduct( + { + ...store, + title: store.title ?? fallbackTitle, + source: store.source ?? store.merchant ?? store.name, + }, + request.category, + ), + ) + .filter((product): product is ProductOption => !!product); + } catch (error) { + logger.info("[PRODUCT_SEARCH] immersive lookup skipped", { + code: error instanceof Error ? error.message : "unknown", + }); + return []; + } +} + +function dedupeProducts(products: ProductOption[]) { + const seen = new Set(); + return products.filter((product) => { + const key = `${product.productUrl}|${product.title}`.toLowerCase(); + if (seen.has(key)) return false; + seen.add(key); + return true; + }); +} + +export async function searchSerpApiProducts( + request: NormalizedSearchRequest, +): Promise { + const apiKey = envString("SERPAPI_API_KEY"); + if (!apiKey) { + throw new Error("serpapi_key_missing"); + } + + const data = await fetchJson(shoppingUrl(request, apiKey)); + if (data.error) { + throw new Error("serpapi_error"); + } + + const shoppingResults = (data.shopping_results ?? []).slice(0, 20); + const directProducts = shoppingResults + .map((raw) => normalizeSerpApiProduct(raw, request.category)) + .filter((product): product is ProductOption => !!product); + + const immersiveProducts: ProductOption[] = []; + if (directProducts.length < request.maxResults) { + const immersiveCandidates = shoppingResults + .filter((raw) => !normalizeSerpApiProduct(raw, request.category)) + .slice(0, 4); + for (const raw of immersiveCandidates) { + const stores = await fetchImmersiveStores(raw, request, apiKey); + immersiveProducts.push(...stores); + if (directProducts.length + immersiveProducts.length >= request.maxResults * 2) break; + } + } + + return dedupeProducts([...directProducts, ...immersiveProducts]); +} diff --git a/functions/src/products/types.ts b/functions/src/products/types.ts new file mode 100644 index 0000000..ffffe09 --- /dev/null +++ b/functions/src/products/types.ts @@ -0,0 +1,67 @@ +export type ProductCategory = + | "tops" + | "bottoms" + | "footwear" + | "outerwear" + | "accessories"; + +export type ProductTier = "budget" | "mid" | "premium"; + +export type ProductSourceScreen = "home" | "insights" | "aura_chat" | "outfit_card"; + +export type SearchLiveProductsRequest = { + itemType: string; + category?: ProductCategory; + styleTags?: string[]; + preferredColors?: string[]; + priceTiers?: ProductTier[]; + budgetPreference?: ProductTier; + genderPresentation?: "mens" | "womens" | "unisex"; + sourceScreen?: ProductSourceScreen; + maxResults?: number; +}; + +export type ProductOption = { + id: string; + title: string; + brand: string; + merchant: string; + price?: number; + currency?: string; + imageUrl?: string; + productUrl: string; + affiliateUrl?: string; + tier: ProductTier; + source: "live" | "curated"; + affiliateEligible: boolean; + rating?: number; + reviews?: number; + confidenceScore?: number; + lastUpdatedAt?: string; +}; + +export type LiveProductProviderResult = { + products: ProductOption[]; + provider: "serpapi"; + cacheHit?: boolean; +}; + +export type SearchLiveProductsResponse = LiveProductProviderResult & { + ok: boolean; + disabled?: boolean; +}; + +export type NormalizedSearchRequest = { + itemType: string; + query: string; + category?: ProductCategory; + styleTags: string[]; + preferredColors: string[]; + priceTiers: ProductTier[]; + budgetPreference?: ProductTier; + genderPresentation: "mens" | "womens" | "unisex"; + sourceScreen?: ProductSourceScreen; + maxResults: number; + country: string; + language: string; +}; diff --git a/functions/src/shared/auraAccessorySelection.ts b/functions/src/shared/auraAccessorySelection.ts new file mode 100644 index 0000000..3878b1c --- /dev/null +++ b/functions/src/shared/auraAccessorySelection.ts @@ -0,0 +1,210 @@ +import { + ACCESSORY_SLOT_ORDER, + getAccessorySlot, + type AccessorySlot, +} from "../../../shared/accessorySlots"; + +type AuraAccessoryPieceLike = { + role?: string; + itemName?: string; + source?: string; + itemId?: string | null; +}; + +type AuraAccessoryLookLike = { + lookTitle?: string; + vibe?: string; + shortExplanation?: string; + stylingNote?: string; + pieces: TPiece[]; +}; + +type PreferenceContext = { + explicitProfile?: { + styleVibes?: string[]; + experimentationLevel?: "low" | "medium" | "high"; + favoriteColors?: string[]; + } | null; + learnedProfile?: { + confidence?: number; + inferredFavoriteColors?: string[]; + } | null; + session?: { + currentOccasion?: string; + vibeForThisSession?: string; + } | null; +} | null; + +export type AccessoryDiscardReason = { + slot: AccessorySlot; + discardedName: string; + winnerName: string; + reason: string; +}; + +function clean(value: unknown) { + return String(value ?? "").trim(); +} + +function normalizedText(value: unknown): string { + return String(value ?? "") + .toLowerCase() + .replace(/[_-]+/g, " ") + .replace(/[^a-z0-9\s]/g, " ") + .replace(/\s+/g, " ") + .trim(); +} + +function pieceLabel(piece: AuraAccessoryPieceLike) { + return clean(piece.itemName) || "Accessory"; +} + +function contextText( + look: AuraAccessoryLookLike, + preferenceContext?: PreferenceContext +) { + return normalizedText([ + look.lookTitle, + look.vibe, + look.shortExplanation, + look.stylingNote, + preferenceContext?.session?.currentOccasion, + preferenceContext?.session?.vibeForThisSession, + ...(preferenceContext?.explicitProfile?.styleVibes ?? []), + ].filter(Boolean).join(" ")); +} + +function inferMood(text: string, preferenceContext?: PreferenceContext) { + if (/\b(safe|minimal|clean|simple|quiet|classic|work|office|formal|interview)\b/.test(text)) { + return "safe" as const; + } + if (/\b(bold|statement|party|night|tonight|date|edgy|colorful|standout)\b/.test(text)) { + return "bold" as const; + } + const experimentation = preferenceContext?.explicitProfile?.experimentationLevel; + if (experimentation === "low") return "safe" as const; + if (experimentation === "high") return "bold" as const; + return "balanced" as const; +} + +function favoriteColors(preferenceContext?: PreferenceContext) { + const explicit = preferenceContext?.explicitProfile?.favoriteColors ?? []; + const learned = + (preferenceContext?.learnedProfile?.confidence ?? 0) >= 0.45 + ? preferenceContext?.learnedProfile?.inferredFavoriteColors ?? [] + : []; + return [...explicit, ...learned].map(normalizedText).filter(Boolean); +} + +function scorePiece( + piece: AuraAccessoryPieceLike, + slot: AccessorySlot, + lookText: string, + preferenceContext: PreferenceContext | undefined, + index: number +) { + const pieceText = normalizedText(`${piece.itemName ?? ""} ${piece.source ?? ""}`); + const mood = inferMood(lookText, preferenceContext); + const formal = /\b(formal|office|work|business|interview|wedding|black tie|smart)\b/.test(lookText); + const streetwear = /\b(street|streetwear|skate|sneaker|casual|hoodie|cargo)\b/.test(`${lookText} ${pieceText}`); + const cold = /\b(cold|winter|snow|freezing|chilly)\b/.test(lookText); + const warmSunny = /\b(hot|summer|sun|sunny|beach|humid|warm)\b/.test(lookText); + const hasNeutral = /\b(black|white|grey|gray|navy|beige|cream|tan|silver|gold)\b/.test(pieceText); + const isMinimal = /\b(minimal|simple|classic|clean|plain|solid|thin|slim|subtle)\b/.test(pieceText); + const isStatement = /\b(statement|bold|chunky|logo|graphic|bright|colorful|oversized|monogram)\b/.test(pieceText); + const isSporty = /\b(cap|baseball|snapback|beanie|sport|athletic|gym|backpack)\b/.test(pieceText); + const hasFavoriteColor = favoriteColors(preferenceContext).some((color) => pieceText.includes(color)); + + let score = piece.source === "closet" ? 1.2 : 0.6; + if (hasFavoriteColor) score += 1.2; + if (hasNeutral) score += 0.6; + + if (mood === "safe") { + if (isMinimal || hasNeutral) score += 1.4; + if (isStatement) score -= 1.5; + } else if (mood === "balanced") { + if (isMinimal || hasNeutral) score += 0.9; + if (isStatement) score += 0.2; + } else { + if (isStatement) score += 1.2; + if (!hasNeutral) score += 0.4; + } + + if (formal) { + if (slot === "wrist" || slot === "neck") score += 1; + if (slot === "bag" && /\bhandbag\b/.test(pieceText)) score += 0.8; + if (isSporty && !streetwear) score -= 2.4; + } + + if (streetwear) { + if (slot === "headwear") score += 1.3; + if (slot === "neck" || slot === "bag") score += 0.45; + } + + if (cold) { + if (/\bbeanie\b/.test(pieceText)) score += 1.2; + if (slot === "eyewear") score -= 0.6; + } + if (warmSunny) { + if (slot === "eyewear") score += 1.2; + if (/\bbeanie\b/.test(pieceText)) score -= 1.2; + } + if (/\b(travel|commute|errand|airport)\b/.test(lookText) && slot === "bag") { + score += 0.8; + } + + return score - index * 0.001; +} + +export function dedupeAuraLookAccessories( + look: AuraAccessoryLookLike, + preferenceContext?: PreferenceContext +): { pieces: TPiece[]; discarded: AccessoryDiscardReason[] } { + const lookText = contextText(look, preferenceContext); + const grouped: Partial>> = {}; + const keepIndexes = new Set(); + + look.pieces.forEach((piece, index) => { + if (piece.role !== "accessory") { + keepIndexes.add(index); + return; + } + const slot = getAccessorySlot(piece); + if (!slot) { + keepIndexes.add(index); + return; + } + grouped[slot] = [ + ...(grouped[slot] ?? []), + { + piece, + index, + score: scorePiece(piece, slot, lookText, preferenceContext, index), + }, + ]; + }); + + const discarded: AccessoryDiscardReason[] = []; + for (const slot of ACCESSORY_SLOT_ORDER) { + const group = grouped[slot] ?? []; + if (!group.length) continue; + const ranked = [...group].sort((a, b) => b.score - a.score || a.index - b.index); + const winner = ranked[0]; + keepIndexes.add(winner.index); + + for (const entry of ranked.slice(1)) { + const reason = `Discarded accessory "${pieceLabel(entry.piece)}" because ${slot} slot already has "${pieceLabel(winner.piece)}" with better color/vibe score.`; + discarded.push({ + slot, + discardedName: pieceLabel(entry.piece), + winnerName: pieceLabel(winner.piece), + reason, + }); + } + } + + return { + pieces: look.pieces.filter((_, index) => keepIndexes.has(index)), + discarded, + }; +} diff --git a/functions/src/shared/auraCandidatePreview.ts b/functions/src/shared/auraCandidatePreview.ts new file mode 100644 index 0000000..411968f --- /dev/null +++ b/functions/src/shared/auraCandidatePreview.ts @@ -0,0 +1,1170 @@ +import OpenAI from "openai"; +import type { ProductExtraction } from "./productLinkExtractor"; +import { redactUrlForLogs } from "./safeFetch"; + +const DEBUG_AURA_CANDIDATE_LOGS = + process.env.DEBUG_AURA_CANDIDATE_LOGS === "1" || + process.env.DEBUG_AURA_CANDIDATE_LOGS === "true"; +const PRODUCT_IMAGE_VISION_RANK_LIMIT = 8; + +function debugAuraCandidateInfo(...args: Parameters) { + if (DEBUG_AURA_CANDIDATE_LOGS) { + console.info(...args); + } +} + +function debugAuraCandidateWarn(...args: Parameters) { + if (DEBUG_AURA_CANDIDATE_LOGS) { + console.warn(...args); + } +} + +export type AuraCandidateItem = { + candidateId: string; + imageUrls: string[]; + primaryImageUrl?: string | null; + secondaryImageUrls?: string[]; + title?: string | null; + category?: string | null; + subCategory?: string | null; + color?: string | null; + brand?: string | null; + material?: string | null; + materials?: string[]; + fit?: string | null; + sleeveLength?: string | null; + collar?: string | null; + length?: string | null; + pattern?: string | null; + confidence?: number | null; + retailPrice?: number | null; + purchasePrice?: number | null; + estimatedValue?: number | null; + currency?: string | null; + originalPrice?: number | null; + salePrice?: number | null; + originalCurrency?: string | null; + priceSource?: "product_link" | "manual" | "estimated" | null; + priceDisplay?: string | null; + price?: number | null; + productUrl?: string | null; + displayColor?: string | null; + displayColors?: string[] | null; + sizeOptions?: string[]; + availableSizes?: string[]; + careInstructions?: string[]; + productDescription?: string | null; + graphicText?: string | null; + motif?: string | null; + collaborationName?: string | null; + sourceType: "image" | "link" | "batch"; + sourceUrl?: string | null; + imageSourceReason?: string | null; + status: "awaiting_confirmation" | "needs_review" | "added" | "cancelled" | "failed"; +}; + +export type RankedProductImage = { + url: string; + score: number; + sourceIndex: number; + urlScore: number; + reasons: string[]; + isGarmentOnly: boolean; + isModelImage: boolean; + isFrontFacing: boolean; + hasFullProductVisible: boolean; + isDetailCloseUp: boolean; + isCropped: boolean; + isThumbnail: boolean; + isLifestyleOrBanner: boolean; + isSideProfileFootwear?: boolean; + isOverheadFootwear?: boolean; + bucket?: ProductImageBucket; +}; + +type ProductImageBucket = "garment_only" | "model_editorial" | "detail_or_crop" | "unknown"; + +type RawProductImageRanking = { + index: number; + score: number; + isGarmentOnly: boolean; + isModelImage: boolean; + isFrontFacing: boolean; + hasFullProductVisible: boolean; + isDetailCloseUp: boolean; + isCropped: boolean; + isThumbnail: boolean; + isLifestyleOrBanner: boolean; + isSideProfileFootwear: boolean; + isOverheadFootwear: boolean; + containsMultipleGarments: boolean; + reasons: string[]; +}; + +type UrlImageSignals = { + score: number; + reasons: string[]; + widthHint: number | null; + detailPenalty: boolean; + thumbnailPenalty: boolean; + socialPenalty: boolean; +}; + +export function candidatePreviewResponse(candidates: AuraCandidateItem[]) { + const needsReview = candidates.some((candidate) => candidate.status === "needs_review"); + const response = { + presentation: "candidate_preview" as const, + title: candidates.length === 1 ? "Review item" : "Review items", + reply: needsReview + ? "I couldn’t fully read this item — review before adding" + : candidates.length === 1 + ? "I found this item. Want me to add it?" + : `I found ${candidates.length} items. Review them before I add them.`, + reason: "", + outfitItems: [], + ownedPieces: [], + recommendedAdditions: [], + swapSuggestion: "", + chips: [], + look: null, + candidateItems: candidates, + candidates, + }; + debugAuraCandidateInfo("[AURA_CANDIDATE_BACKEND] preview payload created", { + candidateCount: candidates.length, + candidateIds: candidates.map((candidate) => candidate.candidateId), + sourceTypes: candidates.map((candidate) => candidate.sourceType), + hasCandidateItems: true, + hasCandidatesAlias: true, + keys: Object.keys(response), + }); + return response; +} + +export function fallbackImageCandidates(imageGroups: string[][]): AuraCandidateItem[] { + const batch = imageGroups.length > 1; + return imageGroups + .filter((imageUrls) => imageUrls.length > 0) + .map((imageUrls, index) => + cleanCandidate( + { + title: null, + category: null, + subCategory: null, + color: null, + brand: null, + material: null, + fit: null, + pattern: null, + confidence: null, + }, + imageUrls, + index, + batch, + ), + ); +} + +export function candidatesFromProductExtractions( + extractions: ProductExtraction[], +): AuraCandidateItem[] { + return extractions.map((extraction, index) => { + const titleHints = productCategoryHintsFromText( + extraction.metadata.title, + extraction.metadata.description, + ); + const amount = extraction.metadata.priceAmount ?? null; + const currency = extraction.metadata.priceCurrency ?? extraction.metadata.currency ?? null; + const currentAmount = + typeof extraction.metadata.salePrice === "number" + ? extraction.metadata.salePrice + : amount; + const priceFields = + typeof currentAmount === "number" && Number.isFinite(currentAmount) + ? { + retailPrice: currentAmount, + purchasePrice: currentAmount, + estimatedValue: currentAmount, + currency, + originalPrice: extraction.metadata.originalPrice ?? amount ?? currentAmount, + salePrice: extraction.metadata.salePrice ?? null, + originalCurrency: currency, + priceSource: "product_link" as const, + priceDisplay: extraction.metadata.priceDisplay ?? extraction.metadata.price ?? null, + price: currentAmount, + } + : {}; + return { + candidateId: `link-${Date.now()}-${index}`, + imageUrls: extraction.imageUrls, + primaryImageUrl: extraction.imageUrls[0] ?? null, + secondaryImageUrls: extraction.imageUrls.slice(1), + title: extraction.metadata.title ?? null, + category: titleHints.category ?? extraction.metadata.categoryHints?.[0] ?? null, + subCategory: titleHints.subCategory ?? extraction.metadata.categoryHints?.[1] ?? null, + color: extraction.metadata.color ?? null, + displayColor: extraction.metadata.displayColor ?? extraction.metadata.color ?? null, + displayColors: extraction.metadata.displayColors ?? [], + brand: + extraction.status === "needs_review" + ? extraction.metadata.brand ?? null + : extraction.metadata.brand ?? extraction.metadata.retailer ?? null, + material: extraction.metadata.material ?? null, + materials: extraction.metadata.materials ?? [], + fit: extraction.metadata.fit ?? null, + sleeveLength: extraction.metadata.sleeveLength ?? null, + collar: extraction.metadata.collar ?? null, + length: extraction.metadata.length ?? null, + pattern: extraction.metadata.pattern ?? null, + sizeOptions: extraction.metadata.sizeOptions ?? [], + availableSizes: extraction.metadata.availableSizes ?? extraction.metadata.sizeOptions ?? [], + careInstructions: extraction.metadata.careInstructions ?? [], + productDescription: extraction.metadata.productDescription ?? extraction.metadata.description ?? null, + graphicText: extraction.metadata.graphicText ?? null, + motif: extraction.metadata.motif ?? null, + collaborationName: extraction.metadata.collaborationName ?? null, + confidence: extraction.confidence ?? null, + ...priceFields, + sourceType: extractions.length > 1 ? "batch" : "link", + sourceUrl: extraction.metadata.sourceUrl, + productUrl: extraction.metadata.sourceUrl, + status: extraction.status === "needs_review" ? "needs_review" : "awaiting_confirmation", + }; + }); +} + +function imageDedupeKey(url: string) { + try { + const parsed = new URL(url); + parsed.hash = ""; + for (const key of Array.from(parsed.searchParams.keys())) { + if (/^(imwidth|width|height|w|h|sw|sh|q|quality)$/i.test(key)) { + parsed.searchParams.delete(key); + } + } + const sortedSearch = Array.from(parsed.searchParams.entries()) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([key, value]) => `${key.toLowerCase()}=${value.toLowerCase()}`) + .join("&"); + return `${parsed.hostname.toLowerCase()}${parsed.pathname.toLowerCase()}?${sortedSearch}`; + } catch { + return url.toLowerCase().replace(/([?&])(imwidth|width|height|w|h|sw|sh|q|quality)=\d+/gi, "$1"); + } +} + +function imageDimensionHints(url: string) { + const lower = url.toLowerCase(); + const values: number[] = []; + try { + const parsed = new URL(url); + for (const key of ["imwidth", "width", "w", "sw", "height", "h", "sh"]) { + const value = Number(parsed.searchParams.get(key) ?? 0); + if (Number.isFinite(value) && value > 0) values.push(value); + } + } catch { + // Fall back to path parsing below. + } + for (const dim of lower.matchAll(/(?:_|-|\/)(\d{2,4})(?:x|_|-)(\d{2,4})(?:[._/?-]|$)/g)) { + values.push(Number(dim[1]), Number(dim[2])); + } + return values.filter((value) => Number.isFinite(value) && value > 0); +} + +function urlImageSignals(url: string): UrlImageSignals { + const lower = url.toLowerCase(); + const reasons: string[] = []; + const dimensions = imageDimensionHints(url); + const widthHint = dimensions.length ? Math.max(...dimensions) : null; + let score = 0; + + if (/\.(jpe?g|png|webp)(\?|$)/i.test(lower)) { + score += 8; + reasons.push("image file extension"); + } + if (/(product|pdp|gallery|main|model|packshot|studio|image|photo)/i.test(lower)) { + score += 12; + reasons.push("product/gallery URL hint"); + } + if (/(clean|cutout|transparent|isolated|packshot|studio)/i.test(lower)) { + score += 18; + reasons.push("clean product image URL hint"); + } + if (/image\.hm\.com$/i.test(safeHost(url))) { + score += 10; + reasons.push("H&M product image host"); + } + if (widthHint) { + if (widthHint >= 1000) { + score += 18; + reasons.push(`large image hint ${widthHint}px`); + } else if (widthHint >= 700) { + score += 12; + reasons.push(`medium-large image hint ${widthHint}px`); + } else if (widthHint < 220) { + score -= 35; + reasons.push(`thumbnail-sized image hint ${widthHint}px`); + } else if (widthHint < 420) { + score -= 12; + reasons.push(`small image hint ${widthHint}px`); + } + } + + const detailPenalty = /(detail|close[-_ ]?up|zoom|macro|fabric|texture|material|crop|cropped|graphic|logo[-_ ]?shot|chest[-_ ]?graphic|print[-_ ]?detail)/i.test(lower); + if (detailPenalty) { + score -= 55; + reasons.push("detail/close-up URL hint"); + } + const thumbnailPenalty = /(thumb|thumbnail|small|swatch|colorchip|sprite|icon|favicon|placeholder|badge|payment|loader)/i.test(lower); + if (thumbnailPenalty) { + score -= 70; + reasons.push("thumbnail/icon URL hint"); + } + const socialPenalty = /(share|social|facebook|pinterest|banner|header|footer|hero|promo|campaign|lifestyle|editorial|nav|ui)/i.test(lower); + if (socialPenalty) { + score -= 45; + reasons.push("social/banner URL hint"); + } + + return { score, reasons, widthHint, detailPenalty, thumbnailPenalty, socialPenalty }; +} + +function safeHost(url: string) { + try { + return new URL(url).hostname.toLowerCase(); + } catch { + return ""; + } +} + +function stableUniqueUrls(urls: string[]) { + const byKey = new Map(); + urls + .map((url, index) => ({ url: String(url ?? "").trim(), index })) + .filter((entry) => !!entry.url && !/\s/.test(entry.url)) + .forEach((entry) => { + const key = imageDedupeKey(entry.url); + const signals = urlImageSignals(entry.url); + const variantScore = (signals.widthHint ?? 0) + signals.score; + const existing = byKey.get(key); + if ( + !existing || + variantScore > existing.variantScore || + (variantScore === existing.variantScore && entry.url.localeCompare(existing.url) < 0) + ) { + byKey.set(key, { + url: entry.url, + firstIndex: existing?.firstIndex ?? entry.index, + variantScore, + }); + } + }); + return Array.from(byKey.values()) + .sort((a, b) => a.firstIndex - b.firstIndex || a.url.localeCompare(b.url)) + .map((entry) => entry.url); +} + +function productImageReasonText(result: RawProductImageRanking | undefined) { + return (result?.reasons ?? []).join(" ").toLowerCase(); +} + +function productImageReasonSuggestsModel(result: RawProductImageRanking | undefined) { + const text = productImageReasonText(result).replace( + /\b(no visible|without|no)\s+(person|model|human|body|torso|leg|arm|head)s?\b/g, + "", + ); + return /\b(model|person|human|wearing|worn|body|torso|leg|arm|head|full[-\s]?body|editorial)\b/.test( + text, + ); +} + +function productImageReasonSuggestsGarmentOnly(result: RawProductImageRanking | undefined) { + return /\b(garment[-\s]?only|product[-\s]?only|standalone|isolated|flat[-\s]?lay|plain background|studio product|no visible (person|model|human))\b/.test( + productImageReasonText(result), + ); +} + +function productImageReasonSuggestsDetailOrCrop(result: RawProductImageRanking | undefined) { + return /\b(detail|close[-\s]?up|fabric|texture|material|zoom|macro|cropped|crop|partial|logo only|graphic only|chest graphic|print detail|not full|not fully visible|cut off|thumbnail|banner|social|lifestyle)\b/.test( + productImageReasonText(result), + ); +} + +function productImageBucket(result: RawProductImageRanking | undefined): ProductImageBucket { + if ( + result?.isDetailCloseUp || + result?.isCropped || + result?.isThumbnail || + result?.isLifestyleOrBanner || + productImageReasonSuggestsDetailOrCrop(result) + ) { + return "detail_or_crop"; + } + if (result?.isModelImage || productImageReasonSuggestsModel(result)) return "model_editorial"; + if (result?.isGarmentOnly || productImageReasonSuggestsGarmentOnly(result)) return "garment_only"; + return "unknown"; +} + +function productImageScore(params: { + result: RawProductImageRanking | undefined; + bucket: ProductImageBucket; + url: string; + isFootwearProduct?: boolean; +}) { + const { result, bucket, url, isFootwearProduct } = params; + const baseScore = Math.max(0, Math.min(100, Number(result?.score ?? 20))); + const signals = urlImageSignals(url); + let score = baseScore + signals.score; + if (result?.hasFullProductVisible) score += 80; + if (result?.isFrontFacing) score += 24; + if (result?.isGarmentOnly) score += 40; + if (result?.isModelImage) score += result?.hasFullProductVisible ? 0 : -24; + if (!result?.hasFullProductVisible) score -= 55; + if (result?.containsMultipleGarments) score -= 38; + if (result?.isDetailCloseUp || signals.detailPenalty) score -= 120; + if (result?.isCropped) score -= 95; + if (result?.isThumbnail || signals.thumbnailPenalty) score -= 120; + if (result?.isLifestyleOrBanner || signals.socialPenalty) score -= 90; + if (bucket === "garment_only") score += 44; + if (bucket === "model_editorial") score += result?.hasFullProductVisible ? 0 : -42; + if (bucket === "detail_or_crop") score -= 120; + if (isFootwearProduct) { + const usableFootwearProductImage = + result?.hasFullProductVisible && + !result?.isDetailCloseUp && + !result?.isCropped && + !result?.isThumbnail && + !result?.isLifestyleOrBanner; + if (usableFootwearProductImage && result?.isSideProfileFootwear) { + score += 170; + } + if (usableFootwearProductImage && result?.isOverheadFootwear) { + score -= 80; + } + } + return score; +} + +function sortProductImages(items: RankedProductImage[]) { + return [...items].sort( + (a, b) => + b.score - a.score || + b.urlScore - a.urlScore || + a.sourceIndex - b.sourceIndex || + a.url.localeCompare(b.url), + ); +} + +function selectVisionRankCandidates(items: RankedProductImage[]) { + return orderProductImageBuckets(items) + .filter((item) => item.bucket !== "detail_or_crop" && !item.isThumbnail && !item.isLifestyleOrBanner) + .slice(0, PRODUCT_IMAGE_VISION_RANK_LIMIT); +} + +function logProductImageRankingMetrics(params: { + sourceUrl?: string | null; + extractedImageCount: number; + dedupedImageCount: number; + visionRankedImageCount: number; + selected: RankedProductImage | undefined; + selectedReason: string; +}) { + console.info("[LINK_IMAGE_RANKING_METRICS]", { + sourceUrl: redactUrlForLogs(params.sourceUrl), + extractedImageCount: params.extractedImageCount, + dedupedImageCount: params.dedupedImageCount, + visionRankedImageCount: params.visionRankedImageCount, + selectedPrimaryImageUrl: redactUrlForLogs(params.selected?.url), + selectedPrimaryBucket: params.selected?.bucket ?? null, + selectedPrimaryScore: params.selected?.score ?? null, + selectedPrimaryReason: params.selectedReason, + }); +} + +function orderProductImageBuckets(items: RankedProductImage[]) { + const garmentOnly = items.filter((item) => item.bucket === "garment_only"); + const modelEditorial = items.filter((item) => item.bucket === "model_editorial"); + const detailOrCrop = items.filter((item) => item.bucket === "detail_or_crop"); + const unknown = items.filter((item) => item.bucket === "unknown"); + + if (garmentOnly.length > 0) { + return [ + ...sortProductImages(garmentOnly), + ...sortProductImages(unknown), + ...sortProductImages(modelEditorial), + ...sortProductImages(detailOrCrop), + ]; + } + + return [ + ...sortProductImages([...modelEditorial, ...unknown]), + ...sortProductImages(detailOrCrop), + ]; +} + +function logProductImageBuckets(params: { + sourceUrl?: string | null; + items: RankedProductImage[]; +}) { + const buckets: Record = { + garment_only: [], + model_editorial: [], + detail_or_crop: [], + unknown: [], + }; + for (const item of params.items) { + buckets[item.bucket ?? "unknown"].push(item.url); + } + debugAuraCandidateInfo("[LINK_PRIMARY_BUCKETS]", { + sourceUrl: params.sourceUrl ?? null, + garmentOnlyCount: buckets.garment_only.length, + modelEditorialCount: buckets.model_editorial.length, + detailOrCropCount: buckets.detail_or_crop.length, + unknownCount: buckets.unknown.length, + garmentOnlyUrls: buckets.garment_only, + modelEditorialUrls: buckets.model_editorial, + detailOrCropUrls: buckets.detail_or_crop, + unknownUrls: buckets.unknown, + }); + debugAuraCandidateInfo("[LINK_IMAGE_BUCKETS]", { + sourceUrl: params.sourceUrl ?? null, + garmentOnlyCount: buckets.garment_only.length, + modelEditorialCount: buckets.model_editorial.length, + detailOrCropCount: buckets.detail_or_crop.length, + unknownCount: buckets.unknown.length, + garmentOnlyUrls: buckets.garment_only, + modelEditorialUrls: buckets.model_editorial, + detailOrCropUrls: buckets.detail_or_crop, + unknownUrls: buckets.unknown, + }); +} + +function rankedImageFromUrlOnly(url: string, sourceIndex: number): RankedProductImage { + const signals = urlImageSignals(url); + const bucket: ProductImageBucket = + signals.detailPenalty || signals.thumbnailPenalty || signals.socialPenalty + ? "detail_or_crop" + : "unknown"; + const score = + 20 + + signals.score - + (signals.detailPenalty ? 120 : 0) - + (signals.thumbnailPenalty ? 120 : 0) - + (signals.socialPenalty ? 90 : 0); + return { + url, + sourceIndex, + urlScore: signals.score, + score, + reasons: signals.reasons.length ? signals.reasons : ["URL heuristic only"], + isGarmentOnly: false, + isModelImage: false, + isFrontFacing: false, + hasFullProductVisible: false, + isDetailCloseUp: signals.detailPenalty, + isCropped: signals.detailPenalty, + isThumbnail: signals.thumbnailPenalty, + isLifestyleOrBanner: signals.socialPenalty, + bucket, + }; +} + +function logRankedProductImages(params: { + sourceUrl?: string | null; + items: RankedProductImage[]; + selected: RankedProductImage | undefined; +}) { + debugAuraCandidateInfo("[LINK_IMAGE_RANKING_SUMMARY]", { + sourceUrl: params.sourceUrl ?? null, + selectedPrimaryImageUrl: params.selected?.url ?? null, + selectedScore: params.selected?.score ?? null, + selectedReasons: params.selected?.reasons ?? [], + rankings: params.items.map((item) => ({ + sourceIndex: item.sourceIndex, + url: item.url, + score: item.score, + urlScore: item.urlScore, + bucket: item.bucket ?? "unknown", + isGarmentOnly: item.isGarmentOnly, + isModelImage: item.isModelImage, + isFrontFacing: item.isFrontFacing, + hasFullProductVisible: item.hasFullProductVisible, + isDetailCloseUp: item.isDetailCloseUp, + isCropped: item.isCropped, + isThumbnail: item.isThumbnail, + isLifestyleOrBanner: item.isLifestyleOrBanner, + isSideProfileFootwear: item.isSideProfileFootwear ?? false, + isOverheadFootwear: item.isOverheadFootwear ?? false, + reasons: item.reasons, + })), + }); +} + +function isFootwearProductText(title?: string | null, description?: string | null) { + const hints = productCategoryHintsFromText(title, description); + if (hints.category === "footwear") return true; + return /\b(air force|jordan|dunk|shoe|shoes|sneaker|sneakers|trainer|trainers|boot|boots|loafer|loafers|sandal|sandals|footwear)\b/i.test( + `${title ?? ""} ${description ?? ""}`, + ); +} + +export async function rankProductLinkImages(params: { + client: OpenAI; + imageUrls: string[]; + title?: string | null; + description?: string | null; + sourceUrl?: string | null; +}): Promise { + const extractedImageCount = params.imageUrls.length; + const imageUrls = stableUniqueUrls(params.imageUrls).slice(0, 24); + const isFootwearProduct = isFootwearProductText(params.title, params.description); + const urlOnlyRanked = imageUrls.map((url, index) => rankedImageFromUrlOnly(url, index)); + const visionRankCandidates = selectVisionRankCandidates(urlOnlyRanked); + debugAuraCandidateInfo("[LINK_IMAGE_CANDIDATES]", { + sourceUrl: params.sourceUrl ?? null, + candidateCount: imageUrls.length, + urls: imageUrls, + title: params.title ?? null, + isFootwearProduct, + }); + if (imageUrls.length <= 1) { + const ranked = imageUrls.map((url, index) => ({ + ...rankedImageFromUrlOnly(url, index), + score: 50 + urlImageSignals(url).score, + reasons: ["only image candidate", ...urlImageSignals(url).reasons], + })); + logProductImageBuckets({ sourceUrl: params.sourceUrl, items: ranked }); + logRankedProductImages({ + sourceUrl: params.sourceUrl, + items: ranked, + selected: ranked[0], + }); + logProductImageRankingMetrics({ + sourceUrl: params.sourceUrl, + extractedImageCount, + dedupedImageCount: imageUrls.length, + visionRankedImageCount: 0, + selected: ranked[0], + selectedReason: ranked[0]?.reasons[0] ?? "only image candidate", + }); + debugAuraCandidateInfo("[LINK_PRIMARY_CHOSEN]", { + sourceUrl: params.sourceUrl ?? null, + primaryImageUrl: ranked[0]?.url ?? null, + bucket: ranked[0]?.bucket ?? null, + score: ranked[0]?.score ?? null, + reasons: ranked[0]?.reasons ?? [], + garmentOnlyAvailable: ranked.some((item) => item.bucket === "garment_only"), + }); + debugAuraCandidateInfo("[LINK_IMAGE_PRIMARY]", { + sourceUrl: params.sourceUrl ?? null, + primaryImageUrl: ranked[0]?.url ?? null, + primaryReasons: ranked[0]?.reasons ?? [], + primaryIsGarmentOnly: ranked[0]?.isGarmentOnly ?? false, + }); + debugAuraCandidateInfo("[LINK_IMAGE_SECONDARY]", { + sourceUrl: params.sourceUrl ?? null, + secondaryImageUrls: ranked.slice(1).map((item) => item.url), + }); + return ranked; + } + + if (!visionRankCandidates.length) { + const ranked = orderProductImageBuckets(urlOnlyRanked); + logProductImageBuckets({ sourceUrl: params.sourceUrl, items: ranked }); + logRankedProductImages({ + sourceUrl: params.sourceUrl, + items: ranked, + selected: ranked[0], + }); + logProductImageRankingMetrics({ + sourceUrl: params.sourceUrl, + extractedImageCount, + dedupedImageCount: imageUrls.length, + visionRankedImageCount: 0, + selected: ranked[0], + selectedReason: ranked[0]?.reasons[0] ?? "URL heuristic only", + }); + return ranked; + } + + try { + const response = await params.client.responses.create({ + model: "gpt-5.4-mini", + input: [ + { + role: "developer", + content: + "Rank retail product images for wardrobe item ingestion. Prefer a clean garment-only/product-only packshot over model/editorial images when both clearly show the product. The primary wardrobe image should show the complete target garment/product clearly. For footwear, if a clean side/lateral profile product photo of the shoe is available, prefer it as primary over top-down, overhead, pair, sole, or detail views. A side/lateral footwear profile is a horizontal shoe image from the side, often toe pointing left or right; only prefer it when the full shoe is visible and it is not cropped. Penalize detail crops, fabric/texture shots, zoomed logos or chest graphics, thumbnails, banners, social previews, and lifestyle images where the item is not the clear product. A garment-only/product-only image has no visible person, model, limbs, head, torso, mannequin, or full outfit; it is usually a standalone garment on a plain studio background or flat lay. Any image with a person wearing the item, even if the target product is visible, is model/editorial. A full clean model shot is better than a cropped detail close-up only when no garment-only/product-only packshot is available. If the title says shirt, shorts, jeans, or another specific garment, a full-body model wearing other garments is not garment-only.", + }, + { + role: "user", + content: [ + { + type: "input_text", + text: + `Product title: ${params.title ?? "unknown"}\n` + + `Description: ${params.description ?? "unknown"}\n` + + "For each image index, score 0-100 for usefulness as the primary wardrobe item image and classify whether it is garment-only/product-only, model/editorial, front-facing/canonical, whether the full target product is visible, whether it is a footwear side/lateral profile view, whether it is a footwear overhead/top-down view, whether it is a detail close-up, cropped/partial, thumbnail, social/banner/lifestyle image, and whether it contains multiple visible garments. Return JSON only.", + }, + ...visionRankCandidates.flatMap((item) => [ + { + type: "input_text" as const, + text: `Image index ${item.sourceIndex}: ${redactUrlForLogs(item.url)}`, + }, + { + type: "input_image" as const, + image_url: item.url, + detail: "low" as const, + }, + ]), + ], + }, + ], + text: { + format: { + type: "json_schema", + name: "product_image_ranking", + schema: { + type: "object", + additionalProperties: false, + properties: { + rankings: { + type: "array", + items: { + type: "object", + additionalProperties: false, + properties: { + index: { type: "number" }, + score: { type: "number" }, + isGarmentOnly: { type: "boolean" }, + isModelImage: { type: "boolean" }, + isFrontFacing: { type: "boolean" }, + hasFullProductVisible: { type: "boolean" }, + isDetailCloseUp: { type: "boolean" }, + isCropped: { type: "boolean" }, + isThumbnail: { type: "boolean" }, + isLifestyleOrBanner: { type: "boolean" }, + isSideProfileFootwear: { type: "boolean" }, + isOverheadFootwear: { type: "boolean" }, + containsMultipleGarments: { type: "boolean" }, + reasons: { + type: "array", + items: { type: "string" }, + }, + }, + required: [ + "index", + "score", + "isGarmentOnly", + "isModelImage", + "isFrontFacing", + "hasFullProductVisible", + "isDetailCloseUp", + "isCropped", + "isThumbnail", + "isLifestyleOrBanner", + "isSideProfileFootwear", + "isOverheadFootwear", + "containsMultipleGarments", + "reasons", + ], + }, + }, + }, + required: ["rankings"], + }, + }, + }, + }); + const parsed = JSON.parse(String(response.output_text || "{}")) as { + rankings?: { + index: number; + score: number; + isGarmentOnly: boolean; + isModelImage: boolean; + isFrontFacing: boolean; + hasFullProductVisible: boolean; + isDetailCloseUp: boolean; + isCropped: boolean; + isThumbnail: boolean; + isLifestyleOrBanner: boolean; + isSideProfileFootwear: boolean; + isOverheadFootwear: boolean; + containsMultipleGarments: boolean; + reasons: string[]; + }[]; + }; + const byIndex = new Map((parsed.rankings ?? []).map((entry) => [Math.trunc(entry.index), entry])); + const urlOnlyByIndex = new Map(urlOnlyRanked.map((entry) => [entry.sourceIndex, entry])); + const scored = imageUrls.map((url, index) => { + const result = byIndex.get(index); + if (!result) { + const fallback = urlOnlyByIndex.get(index) ?? rankedImageFromUrlOnly(url, index); + return { + ...fallback, + reasons: Array.from(new Set(["URL pre-rank only", ...fallback.reasons])), + }; + } + const signals = urlImageSignals(url); + const bucket = + signals.detailPenalty || signals.thumbnailPenalty || signals.socialPenalty + ? "detail_or_crop" + : productImageBucket(result); + const score = productImageScore({ result, bucket, url, isFootwearProduct }); + const combinedReasons = [ + ...(result?.reasons?.slice(0, 4) ?? ["not ranked by model"]), + ...signals.reasons.slice(0, 3), + ]; + const item = { + url, + sourceIndex: index, + urlScore: signals.score, + score, + reasons: Array.from(new Set(combinedReasons)), + isGarmentOnly: !!result?.isGarmentOnly, + isModelImage: !!result?.isModelImage, + isFrontFacing: !!result?.isFrontFacing, + hasFullProductVisible: !!result?.hasFullProductVisible, + isDetailCloseUp: !!result?.isDetailCloseUp || signals.detailPenalty, + isCropped: !!result?.isCropped, + isThumbnail: !!result?.isThumbnail || signals.thumbnailPenalty, + isLifestyleOrBanner: !!result?.isLifestyleOrBanner || signals.socialPenalty, + isSideProfileFootwear: !!result?.isSideProfileFootwear, + isOverheadFootwear: !!result?.isOverheadFootwear, + bucket, + }; + debugAuraCandidateInfo("[LINK_PRIMARY_SCORE]", { + sourceUrl: params.sourceUrl ?? null, + index, + url, + bucket: item.bucket, + baseScore: result?.score ?? null, + urlScore: item.urlScore, + score: item.score, + isGarmentOnly: item.isGarmentOnly, + isModelImage: item.isModelImage, + isFrontFacing: item.isFrontFacing, + hasFullProductVisible: item.hasFullProductVisible, + isDetailCloseUp: item.isDetailCloseUp, + isCropped: item.isCropped, + isThumbnail: item.isThumbnail, + isLifestyleOrBanner: item.isLifestyleOrBanner, + isSideProfileFootwear: item.isSideProfileFootwear, + isOverheadFootwear: item.isOverheadFootwear, + containsMultipleGarments: !!result?.containsMultipleGarments, + reasons: item.reasons, + }); + debugAuraCandidateInfo("[LINK_IMAGE_SCORE]", { + sourceUrl: params.sourceUrl ?? null, + index, + url, + score: item.score, + urlScore: item.urlScore, + isGarmentOnly: item.isGarmentOnly, + isModelImage: item.isModelImage, + isFrontFacing: item.isFrontFacing, + hasFullProductVisible: item.hasFullProductVisible, + isDetailCloseUp: item.isDetailCloseUp, + isCropped: item.isCropped, + isThumbnail: item.isThumbnail, + isLifestyleOrBanner: item.isLifestyleOrBanner, + isSideProfileFootwear: item.isSideProfileFootwear, + isOverheadFootwear: item.isOverheadFootwear, + reasons: item.reasons, + }); + return item; + }); + logProductImageBuckets({ sourceUrl: params.sourceUrl, items: scored }); + const ranked = orderProductImageBuckets(scored); + logRankedProductImages({ + sourceUrl: params.sourceUrl, + items: ranked, + selected: ranked[0], + }); + logProductImageRankingMetrics({ + sourceUrl: params.sourceUrl, + extractedImageCount, + dedupedImageCount: imageUrls.length, + visionRankedImageCount: visionRankCandidates.length, + selected: ranked[0], + selectedReason: ranked[0]?.reasons[0] ?? "vision and URL ranking", + }); + debugAuraCandidateInfo("[LINK_PRIMARY_CHOSEN]", { + sourceUrl: params.sourceUrl ?? null, + primaryImageUrl: ranked[0]?.url ?? null, + bucket: ranked[0]?.bucket ?? null, + score: ranked[0]?.score ?? null, + reasons: ranked[0]?.reasons ?? [], + garmentOnlyAvailable: scored.some((item) => item.bucket === "garment_only"), + }); + debugAuraCandidateInfo("[LINK_IMAGE_PRIMARY]", { + sourceUrl: params.sourceUrl ?? null, + primaryImageUrl: ranked[0]?.url ?? null, + primaryReasons: ranked[0]?.reasons ?? [], + primaryIsGarmentOnly: ranked[0]?.isGarmentOnly ?? false, + }); + debugAuraCandidateInfo("[LINK_IMAGE_SECONDARY]", { + sourceUrl: params.sourceUrl ?? null, + secondaryImageUrls: ranked.slice(1).map((item) => item.url), + }); + return ranked; + } catch (error) { + debugAuraCandidateWarn("[LINK_IMAGE_SCORE]", "visual ranking failed; using deterministic URL ranking", { + sourceUrl: params.sourceUrl ?? null, + error, + }); + const ranked = orderProductImageBuckets(urlOnlyRanked); + logProductImageBuckets({ sourceUrl: params.sourceUrl, items: ranked }); + for (const item of ranked) { + debugAuraCandidateInfo("[LINK_IMAGE_SCORE]", { + sourceUrl: params.sourceUrl ?? null, + index: item.sourceIndex, + url: item.url, + score: item.score, + urlScore: item.urlScore, + bucket: item.bucket, + reasons: item.reasons, + }); + } + logRankedProductImages({ + sourceUrl: params.sourceUrl, + items: ranked, + selected: ranked[0], + }); + logProductImageRankingMetrics({ + sourceUrl: params.sourceUrl, + extractedImageCount, + dedupedImageCount: imageUrls.length, + visionRankedImageCount: visionRankCandidates.length, + selected: ranked[0], + selectedReason: ranked[0]?.reasons[0] ?? "deterministic URL fallback", + }); + debugAuraCandidateInfo("[LINK_PRIMARY_CHOSEN]", { + sourceUrl: params.sourceUrl ?? null, + primaryImageUrl: ranked[0]?.url ?? null, + bucket: ranked[0]?.bucket ?? null, + score: ranked[0]?.score ?? null, + reasons: ranked[0]?.reasons ?? [], + garmentOnlyAvailable: false, + }); + debugAuraCandidateInfo("[LINK_IMAGE_PRIMARY]", { + sourceUrl: params.sourceUrl ?? null, + primaryImageUrl: ranked[0]?.url ?? null, + primaryReasons: ranked[0]?.reasons ?? [], + primaryIsGarmentOnly: ranked[0]?.isGarmentOnly ?? false, + }); + debugAuraCandidateInfo("[LINK_IMAGE_SECONDARY]", { + sourceUrl: params.sourceUrl ?? null, + secondaryImageUrls: ranked.slice(1).map((item) => item.url), + }); + return ranked; + } +} + +export async function rankProductExtractionImages(params: { + client: OpenAI; + extraction: ProductExtraction; +}): Promise { + const rankedImages = await rankProductLinkImages({ + client: params.client, + imageUrls: params.extraction.imageUrls, + title: params.extraction.metadata.title, + description: params.extraction.metadata.description, + sourceUrl: params.extraction.metadata.sourceUrl, + }); + const imageUrls = rankedImages.map((image) => image.url); + debugAuraCandidateInfo("[LINK_IMAGE_EXTRACTION_RANKED]", { + sourceUrl: params.extraction.metadata.sourceUrl, + primaryImageUrl: imageUrls[0] ?? null, + imageUrls, + rankings: rankedImages.map((image) => ({ + url: image.url, + score: image.score, + bucket: image.bucket ?? "unknown", + reasons: image.reasons, + })), + }); + return { + ...params.extraction, + imageUrls: imageUrls.length ? imageUrls : params.extraction.imageUrls, + partialData: params.extraction.partialData + ? { + ...params.extraction.partialData, + imageUrls: imageUrls.length ? imageUrls : params.extraction.partialData.imageUrls, + } + : params.extraction.partialData, + }; +} + +export function productCategoryHintsFromText(title?: string | null, description?: string | null) { + const titleText = String(title ?? "").toLowerCase(); + const text = `${titleText} ${description ?? ""}`.toLowerCase(); + const footwearRe = + /\b(shoes?|sneakers?|trainers?|boots?|loafers?|sandals?|heels?|footwear|air\s+jordan|jordan\s+\d+|new\s+balance|men[’']?s\s+shoes?|women[’']?s\s+shoes?|running\s+shoes?|basketball\s+shoes?|foot\s*locker|jd\s+sports)\b/i; + if (footwearRe.test(titleText) || footwearRe.test(text)) { + return { + category: "footwear", + subCategory: /\bboots?\b/.test(text) + ? "boots" + : /\bloafers?\b/.test(text) + ? "loafers" + : /\bsandals?\b/.test(text) + ? "sandals" + : "sneakers", + }; + } + const topSubCategory = (() => { + if (/\bpolo\b/.test(titleText)) return "polo"; + if (/\bt-?shirt|\btee\b/.test(titleText)) return "tshirt"; + if (/\bhoodie\b/.test(titleText)) return "hoodie"; + if (/\bsweater|jumper|knit\b/.test(titleText)) return "sweater"; + if (/\btank|vest\b/.test(titleText)) return "tank"; + if (/\bblouse\b/.test(titleText)) return "blouse"; + if (/\bshirt\b/.test(titleText)) return "shirt"; + return null; + })(); + if (topSubCategory) return { category: "top", subCategory: topSubCategory }; + if (/\b(jacket|coat|blazer)\b/.test(titleText)) return { category: "outerwear", subCategory: null }; + if (/\b(dress|jumpsuit|romper)\b/.test(titleText)) return { category: "one_piece", subCategory: null }; + if (/\b(shorts?|trousers?|pants?|jeans?|skirt|leggings?)\b/.test(titleText)) { + return { + category: "bottom", + subCategory: /\bshorts?\b/.test(titleText) + ? "shorts" + : /\bjeans?\b/.test(titleText) + ? "jeans" + : /\bskirt\b/.test(titleText) + ? "skirt" + : "trousers", + }; + } + if (/\b(shorts?|trousers?|pants?|jeans?|skirt|leggings?)\b/.test(text)) { + return { category: "bottom", subCategory: text.includes("short") ? "shorts" : null }; + } + if (/\b(shirt|t-shirt|tee|polo|sweater|hoodie|blouse|top)\b/.test(text)) { + return { + category: "top", + subCategory: text.includes("polo") + ? "polo" + : text.includes("sweater") + ? "sweater" + : null, + }; + } + if (/\b(jacket|coat|blazer)\b/.test(text)) return { category: "outerwear", subCategory: null }; + if (/\b(shoe|sneaker|boot|loafer|sandal)\b/.test(text)) return { category: "footwear", subCategory: null }; + if (/\b(dress|jumpsuit|romper)\b/.test(text)) return { category: "one_piece", subCategory: null }; + return { category: null, subCategory: null }; +} + +type ImageCandidateExtraction = { + title?: string | null; + category?: string | null; + subCategory?: string | null; + color?: string | null; + brand?: string | null; + material?: string | null; + fit?: string | null; + pattern?: string | null; + confidence?: number | null; +}; + +function cleanCandidate(value: ImageCandidateExtraction, imageUrls: string[], index: number, batch: boolean): AuraCandidateItem { + return { + candidateId: `image-${Date.now()}-${index}`, + imageUrls, + primaryImageUrl: imageUrls[0] ?? null, + secondaryImageUrls: imageUrls.slice(1), + title: value.title ?? null, + category: value.category ?? null, + subCategory: value.subCategory ?? null, + color: value.color ?? null, + brand: value.brand ?? null, + material: value.material ?? null, + fit: value.fit ?? null, + pattern: value.pattern ?? null, + confidence: typeof value.confidence === "number" ? value.confidence : null, + sourceType: batch ? "batch" : "image", + sourceUrl: null, + status: "awaiting_confirmation", + }; +} + +export async function extractImageCandidates(params: { + client: OpenAI; + imageGroups: string[][]; +}): Promise { + const candidates: AuraCandidateItem[] = []; + const batch = params.imageGroups.length > 1; + + for (let index = 0; index < params.imageGroups.length; index += 1) { + const imageUrls = params.imageGroups[index] ?? []; + if (!imageUrls.length) continue; + const visionImageUrls = imageUrls.slice(0, 3); + const response = await params.client.responses.create({ + model: "gpt-5.4-mini", + input: [ + { + role: "developer", + content: + "Extract concise clothing item preview details from the image(s). Return JSON only. Do not invent brand/material if not visible.", + }, + { + role: "user", + content: [ + { + type: "input_text", + text: + "Return JSON with keys: title, category, subCategory, color, brand, material, fit, pattern, confidence. Use null for unknown values.", + }, + ...visionImageUrls.map((url) => ({ + type: "input_image" as const, + image_url: url, + detail: "high" as const, + })), + ], + }, + ], + text: { + format: { + type: "json_schema", + name: "aura_candidate_item", + schema: { + type: "object", + additionalProperties: false, + properties: { + title: { type: ["string", "null"] }, + category: { type: ["string", "null"] }, + subCategory: { type: ["string", "null"] }, + color: { type: ["string", "null"] }, + brand: { type: ["string", "null"] }, + material: { type: ["string", "null"] }, + fit: { type: ["string", "null"] }, + pattern: { type: ["string", "null"] }, + confidence: { type: ["number", "null"] }, + }, + required: [ + "title", + "category", + "subCategory", + "color", + "brand", + "material", + "fit", + "pattern", + "confidence", + ], + }, + }, + }, + }); + let parsed: ImageCandidateExtraction = {}; + try { + parsed = JSON.parse(String(response.output_text || "{}")) as ImageCandidateExtraction; + } catch { + parsed = {}; + } + candidates.push(cleanCandidate(parsed, imageUrls, index, batch)); + } + + return candidates; +} diff --git a/functions/src/shared/auraConcreteLook.fixtures.ts b/functions/src/shared/auraConcreteLook.fixtures.ts new file mode 100644 index 0000000..1ddeb23 --- /dev/null +++ b/functions/src/shared/auraConcreteLook.fixtures.ts @@ -0,0 +1,48 @@ +import {isConcreteOutfitIntent} from "./auraConcreteLook"; + +export type AuraConcreteLookIntentFixture = { + message: string; + hasContext?: boolean; + expectedConcreteLookIntent: boolean; +}; + +export const AURA_CONCRETE_LOOK_INTENT_FIXTURES: AuraConcreteLookIntentFixture[] = [ + { + message: "Suggest an outfit", + expectedConcreteLookIntent: true, + }, + { + message: "style this item", + expectedConcreteLookIntent: true, + }, + { + message: "make this outfit better", + expectedConcreteLookIntent: true, + }, + { + message: "complete this look", + expectedConcreteLookIntent: true, + }, + { + message: "what shoes should I wear?", + expectedConcreteLookIntent: true, + }, + { + message: "thanks", + expectedConcreteLookIntent: false, + }, +]; + +export function evaluateAuraConcreteLookIntentFixtures() { + return AURA_CONCRETE_LOOK_INTENT_FIXTURES.map((fixture) => { + const actual = isConcreteOutfitIntent( + fixture.message, + fixture.hasContext ?? false, + ); + return { + ...fixture, + actualConcreteLookIntent: actual, + pass: actual === fixture.expectedConcreteLookIntent, + }; + }); +} diff --git a/functions/src/shared/auraConcreteLook.ts b/functions/src/shared/auraConcreteLook.ts new file mode 100644 index 0000000..7e4466e --- /dev/null +++ b/functions/src/shared/auraConcreteLook.ts @@ -0,0 +1,401 @@ +import { + generateOutfitCandidates, + normalizeParsedIntent, + type Slot, + type WardrobeItem, +} from "./outfitEngine"; +import { + itemText, + roleForStylingItem, + type StylingItem, +} from "./styling/types"; + +type AuraLookPieceRole = "top" | "bottom" | "shoes" | "outerwear" | "accessory"; + +type AuraLookPiece = { + role: AuraLookPieceRole; + itemName: string; + source: "closet" | "suggested"; + itemId: string | null; + imageUrl: string | null; +}; + +type AuraConcreteLook = { + id?: string | null; + lookTitle: string; + vibe: string; + shortExplanation: string; + stylingNote: string; + personalizationLabel?: string; + personalizationNote?: string; + pieces: AuraLookPiece[]; + fromCloset: string[]; + addToComplete: string[]; + alternates: string[]; + actions: ( + "saveLook" | "planForToday" | "likeLook" | "notMyVibe" | "showMoreLikeThis" | "lessLikeThis" | "shopMissingPieces" | "useOnlyMyCloset" | "makeItDressier" + )[]; +}; + +type AuraConcreteResponse = { + reply?: string; + outfitItems?: string[]; + ownedPieces?: string[]; + recommendedAdditions?: string[]; + missingPieces?: string[]; + upgradeSuggestions?: string[]; + swapSuggestion?: string; +}; + +type ClosetRecord = StylingItem & WardrobeItem & { + id: string; + photoUrl?: string | null; + images?: { + originalUrl?: string | null; + cleanedUrl?: string | null; + isPrimary?: boolean; + }[] | null; + photos?: { + cleanedPhotoUrl?: string | null; + cleanedUrl?: string | null; + primaryUrl?: string | null; + thumbUrl?: string | null; + cleanedThumbUrl?: string | null; + croppedUrl?: string | null; + urls?: string[]; + } | null; + createdAt?: number | { toMillis?: () => number } | null; + updatedAt?: number | { toMillis?: () => number } | null; +}; + +const CONCRETE_OUTFIT_INTENT_RE = + /\b(suggest|recommend|build|make|create|pull|put together|plan|style|dress|improve|refine|polish|complete|finish)\b[\s\S]{0,80}\b(outfit|look|fit)\b|\bwhat should i wear\b|\boutfit for\b|\bfit for\b|\bstyle this(?:\s+(?:item|piece|shirt|top|pants|jeans|shoes?|sneakers?|jacket|coat|hoodie))?\b|\bstyle it\b|\bcomplete this look\b|\bfinish this look\b|\bmake (?:this|it) (?:dressier|more casual|better|warmer|cooler|sharper)\b|\bwhat (?:shoes?|sneakers?|footwear) (?:work|go|should i wear)\b|\bhow (?:do|should|would|can) i style\b/i; + +const LIST_HEADER_RE = + /^(my call|quick take|why|why it works|how to wear it|do this|swap|swap \/ add|styling note|keep|what works|what weakens it|fix|score|best option|outfit|top priorities|next move|avoid|avoid unless|best with|outfit ideas)\s*:?$/i; + +const STOP_TOKENS = new Set([ + "a", + "an", + "and", + "the", + "this", + "that", + "with", + "your", + "from", + "closet", + "wardrobe", + "wear", + "pair", + "keep", + "add", +]); + +function clean(value: unknown) { + return String(value ?? "").replace(/\s+/g, " ").trim(); +} + +function normalized(value: unknown) { + return clean(value).toLowerCase().replace(/[^a-z0-9]+/g, " ").trim(); +} + +function uniqueStrings(values: (string | null | undefined)[], max = 16) { + const seen = new Set(); + const out: string[] = []; + for (const value of values) { + const next = clean(value); + const key = normalized(next); + if (!next || !key || seen.has(key)) continue; + seen.add(key); + out.push(next); + if (out.length >= max) break; + } + return out; +} + +function tokenList(value: unknown) { + return normalized(value) + .split(" ") + .filter((token) => token.length > 1 && !STOP_TOKENS.has(token)); +} + +function mapRole(role: ReturnType): AuraLookPieceRole | null { + if (role === "top" || role === "one_piece") return "top"; + if (role === "bottom") return "bottom"; + if (role === "footwear") return "shoes"; + if (role === "outerwear") return "outerwear"; + if (role === "accessory") return "accessory"; + return null; +} + +function slotForItem(item: ClosetRecord): Slot | null { + const role = roleForStylingItem(item); + if (role === "footwear") return "footwear"; + if (role === "top" || role === "bottom" || role === "outerwear" || role === "accessory") return role; + return null; +} + +function itemLabel(item: ClosetRecord) { + return ( + clean(item.name) || + clean(item.subCategory) || + clean(item.type) || + clean(item.category) || + "Wardrobe piece" + ); +} + +function itemImageUrl(item: ClosetRecord) { + return ( + clean(item.images?.find((image) => image?.isPrimary)?.cleanedUrl) || + clean(item.images?.find((image) => image?.isPrimary)?.originalUrl) || + clean(item.images?.[0]?.cleanedUrl) || + clean(item.images?.[0]?.originalUrl) || + clean(item.photos?.cleanedPhotoUrl) || + clean(item.photos?.cleanedUrl) || + clean(item.photos?.primaryUrl) || + clean(item.photos?.cleanedThumbUrl) || + clean(item.photos?.thumbUrl) || + clean(item.photos?.croppedUrl) || + clean(item.photos?.urls?.[0]) || + clean(item.photoUrl) || + null + ); +} + +function pieceFromItem(item: ClosetRecord): AuraLookPiece | null { + const role = mapRole(roleForStylingItem(item)); + if (!role) return null; + return { + role, + itemName: itemLabel(item), + source: "closet", + itemId: item.id, + imageUrl: itemImageUrl(item), + }; +} + +function matchScore(item: ClosetRecord, label: string) { + const target = normalized(label); + if (target.length < 3) return 0; + const name = normalized(item.name); + const brandName = normalized([item.brand, item.name].filter(Boolean).join(" ")); + const displayName = normalized([ + item.displayColor, + item.colorLabel, + item.primaryColor, + item.name, + ].filter(Boolean).join(" ")); + const text = itemText(item); + const candidates = [name, brandName, displayName, text].filter((value) => value.length >= 3); + + if (candidates.some((candidate) => candidate === target)) return 1; + if (name && (target.includes(name) || name.includes(target))) return 0.96; + if (brandName && (target.includes(brandName) || brandName.includes(target))) return 0.94; + if (displayName && (target.includes(displayName) || displayName.includes(target))) return 0.9; + if (text.includes(target)) return 0.78; + + const targetTokens = tokenList(target); + if (targetTokens.length === 0) return 0; + const itemTokens = new Set(tokenList([item.brand, item.name, item.displayColor, item.colorLabel, item.subCategory, item.type, item.category].join(" "))); + const overlap = targetTokens.filter((token) => itemTokens.has(token)).length; + const coverage = overlap / Math.max(1, Math.min(targetTokens.length, itemTokens.size)); + const targetCoverage = overlap / targetTokens.length; + return Math.max(coverage, targetCoverage * 0.9); +} + +function resolveClosetItem(label: string, items: ClosetRecord[], usedIds: Set) { + const scored = items + .filter((item) => !usedIds.has(item.id)) + .map((item) => ({item, score: matchScore(item, label)})) + .filter((entry) => entry.score >= 0.55) + .sort((a, b) => b.score - a.score); + return scored[0]?.item ?? null; +} + +function labelFromReplyLine(line: string) { + let text = clean(line) + .replace(/^[-•]\s*/, "") + .replace(/^\d+[.)]\s*/, "") + .trim(); + if (!text || LIST_HEADER_RE.test(text)) return ""; + + const roleMatch = text.match(/^(?:top|bottom|footwear|shoes?|sneakers?|layer|outerwear|accessor(?:y|ies))\s*:\s*(.+)$/i); + if (roleMatch?.[1]) text = roleMatch[1]; + + text = text + .replace(/\s+[–—-]\s+.*$/, "") + .replace(/\s+\(.{8,}\)\s*$/, "") + .replace(/[.;]+$/g, "") + .trim(); + + if (!text || text.length > 90 || LIST_HEADER_RE.test(text)) return ""; + return text; +} + +function labelsFromResponse(response?: AuraConcreteResponse | null) { + const explicit = uniqueStrings([ + ...(response?.outfitItems ?? []), + ...(response?.ownedPieces ?? []), + ], 10); + const replyLabels = String(response?.reply ?? "") + .split(/\r?\n/g) + .map(labelFromReplyLine) + .filter(Boolean); + return uniqueStrings([...explicit, ...replyLabels], 10); +} + +function hasCorePieces(pieces: AuraLookPiece[]) { + const roles = new Set(pieces.map((piece) => piece.role)); + return roles.has("top") && roles.has("bottom") && roles.has("shoes"); +} + +function lookTitleForPieces(pieces: AuraLookPiece[]) { + const top = pieces.find((piece) => piece.role === "top"); + const bottom = pieces.find((piece) => piece.role === "bottom"); + if (top && bottom) return `${top.itemName} + ${bottom.itemName}`; + if (pieces.length >= 2) return `${pieces[0].itemName} + ${pieces[1].itemName}`; + return "Closet outfit"; +} + +function lookIdForPieces(pieces: AuraLookPiece[], source: string) { + const ids = pieces + .map((piece) => clean(piece.itemId)) + .filter(Boolean) + .join("_") + .slice(0, 80); + return ids ? `aura_${source}_${ids}` : `aura_${source}_unknown`; +} + +function replyForLook(look: AuraConcreteLook) { + const top = look.pieces.find((piece) => piece.role === "top"); + const bottom = look.pieces.find((piece) => piece.role === "bottom"); + const outerwear = look.pieces.find((piece) => piece.role === "outerwear"); + const shoes = look.pieces.find((piece) => piece.role === "shoes"); + return [ + "My call:", + `Go with ${look.lookTitle}.`, + "", + "Why:", + top && bottom ? `- ${top.itemName} and ${bottom.itemName} give you the base.` : "- The base pieces are easy to wear together.", + outerwear ? `- ${outerwear.itemName} adds the main layer.` : shoes ? `- ${shoes.itemName} keeps it grounded.` : "- The card below keeps the exact pieces together.", + "", + "Do this:", + look.stylingNote, + ].join("\n"); +} + +function buildLookFromPieces(pieces: AuraLookPiece[], source: "resolved_text" | "generated") { + const fromCloset = pieces + .filter((piece) => piece.source === "closet") + .map((piece) => piece.itemName); + const look: AuraConcreteLook = { + id: lookIdForPieces(pieces, source), + lookTitle: lookTitleForPieces(pieces), + vibe: source === "generated" ? "closet-built outfit" : "recommended outfit", + shortExplanation: + source === "generated" + ? "AURA built this from your wardrobe instead of leaving the recommendation as plain text." + : "AURA matched the recommended items to closet pieces.", + stylingNote: + source === "generated" + ? "Use the card as the source of truth for the exact pieces." + : "Let the proportions stay clean and keep the outfit edited.", + pieces, + fromCloset: Array.from(new Set(fromCloset)), + addToComplete: [], + alternates: [], + actions: ["likeLook", "notMyVibe", "showMoreLikeThis", "lessLikeThis", "useOnlyMyCloset", "makeItDressier"], + }; + return { + look, + reply: replyForLook(look), + }; +} + +function lookFromResponseText(response: AuraConcreteResponse, closetItems: ClosetRecord[]) { + const labels = labelsFromResponse(response); + if (labels.length < 3) return null; + + const usedIds = new Set(); + const pieces = labels.flatMap((label) => { + const item = resolveClosetItem(label, closetItems, usedIds); + if (!item) return []; + const piece = pieceFromItem(item); + if (!piece) return []; + usedIds.add(item.id); + return [piece]; + }); + + if (!hasCorePieces(pieces)) return null; + return buildLookFromPieces(pieces, "resolved_text"); +} + +function toMillis(value: unknown) { + if (typeof value === "number" && Number.isFinite(value)) return value; + const maybeTimestamp = value as { toMillis?: () => number } | null | undefined; + if (typeof maybeTimestamp?.toMillis === "function") { + const millis = maybeTimestamp.toMillis(); + return Number.isFinite(millis) ? millis : 0; + } + return 0; +} + +function newestRecentAnchor(items: ClosetRecord[], now = Date.now()) { + const recentWindowMs = 6 * 60 * 60 * 1000; + return items + .map((item) => ({ + item, + slot: slotForItem(item), + timestamp: Math.max(toMillis(item.updatedAt), toMillis(item.createdAt)), + })) + .filter((entry) => entry.slot && entry.timestamp > 0 && now - entry.timestamp <= recentWindowMs) + .sort((a, b) => b.timestamp - a.timestamp)[0] ?? null; +} + +function lookFromOutfitEngine(userMessage: string, closetItems: ClosetRecord[]) { + if (!isConcreteOutfitIntent(userMessage, false)) return null; + const intent = normalizeParsedIntent(null, userMessage); + const anchor = newestRecentAnchor(closetItems); + const lockedItemsBySlot = + anchor?.slot && anchor.item + ? ({[anchor.slot]: anchor.item} as Partial>) + : undefined; + const generated = generateOutfitCandidates(closetItems, intent, { + numOutfits: 1, + lockedItemsBySlot, + }); + const fallbackGenerated = generated.outfits.length || !lockedItemsBySlot + ? generated + : generateOutfitCandidates(closetItems, intent, {numOutfits: 1}); + const outfit = fallbackGenerated.outfits[0]; + if (!outfit) return null; + const byId = new Map(closetItems.map((item) => [item.id, item])); + const pieces = outfit.picks.flatMap((pick) => { + const item = byId.get(pick.itemId); + const piece = item ? pieceFromItem(item) : null; + return piece ? [piece] : []; + }); + if (!hasCorePieces(pieces)) return null; + return buildLookFromPieces(pieces, "generated"); +} + +export function isConcreteOutfitIntent(message: string, hasContext = false) { + const text = clean(message); + if (!text) return false; + if (CONCRETE_OUTFIT_INTENT_RE.test(text)) return true; + return hasContext && /\b(make|style|wear|better|dressier|casual|warmer|cooler|sharper|shoes?)\b/i.test(text); +} + +export function buildConcreteOutfitLook(params: { + userMessage?: string | null; + response?: AuraConcreteResponse | null; + closetRecords?: (Record & { id: string })[] | null; +}) { + const closetItems = (params.closetRecords ?? []) as ClosetRecord[]; + if (!closetItems.length) return null; + const textLook = params.response ? lookFromResponseText(params.response, closetItems) : null; + if (textLook) return textLook; + return lookFromOutfitEngine(params.userMessage ?? "", closetItems); +} diff --git a/functions/src/shared/auraLinkIntent.ts b/functions/src/shared/auraLinkIntent.ts new file mode 100644 index 0000000..d6bf5f7 --- /dev/null +++ b/functions/src/shared/auraLinkIntent.ts @@ -0,0 +1,49 @@ +export type AuraLinkIntent = + | "none" + | "analyze_link" + | "add_from_link" + | "add_from_links_batch"; + +export type ExtractedUrl = { + raw: string; + normalized: string; +}; + +const URL_RE = /\b(?:https?:\/\/|www\d*\.)[^\s<>"')\]]+/gi; +const TRAILING_PUNCTUATION_RE = /[.,!?;:]+$/; +const ADD_RE = + /\b(add|save|store|put|upload|log)\b[\s\S]{0,80}\b(closet|wardrobe|item|items|these|this|links?|all)\b|\b(add|save)\s+(this|these|all|item|items|links?)\b|\bput\s+(this|these|all|item|items|links?)\s+in\s+(my\s+)?(closet|wardrobe)\b/i; + +export function extractUrlsFromText(text: string): ExtractedUrl[] { + const matches = String(text ?? "").match(URL_RE) ?? []; + const seen = new Set(); + const urls: ExtractedUrl[] = []; + + for (const match of matches) { + const raw = match.replace(TRAILING_PUNCTUATION_RE, ""); + try { + const parsed = new URL(/^https?:\/\//i.test(raw) ? raw : `https://${raw}`); + if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { + continue; + } + parsed.hash = ""; + const normalized = parsed.toString(); + if (seen.has(normalized)) continue; + seen.add(normalized); + urls.push({ raw, normalized }); + } catch { + continue; + } + } + + return urls; +} + +export function classifyAuraLinkIntent(text: string): AuraLinkIntent { + const urls = extractUrlsFromText(text); + if (!urls.length) return "none"; + if (ADD_RE.test(text)) { + return urls.length > 1 ? "add_from_links_batch" : "add_from_link"; + } + return "analyze_link"; +} diff --git a/functions/src/shared/auraMemory.ts b/functions/src/shared/auraMemory.ts new file mode 100644 index 0000000..72783a8 --- /dev/null +++ b/functions/src/shared/auraMemory.ts @@ -0,0 +1,89 @@ +import {Firestore} from "firebase-admin/firestore"; + +import { + LEARNED_STYLE_MEMORY_DOC_ID, + SESSION_CONTEXT_DOC_ID, + STYLE_PROFILE_DOC_ID, + buildCompactAuraMemoryContext, + buildStyleProfileFromLegacyPreferences, + emptyLearnedStyleMemory, + normalizeAuraSessionContext, + normalizeLearnedStyleMemory, + normalizeStyleProfile, + type AuraSessionContext, + type CompactAuraMemoryContext, + type LearnedStyleMemory, + type StyleProfile, +} from "../../../shared/auraMemory"; + +function userDocRef(db: Firestore, uid: string) { + return db.collection("users").doc(uid); +} + +function profileDocRef(db: Firestore, uid: string, docId: string) { + return userDocRef(db, uid).collection("profile").doc(docId); +} + +function sessionContextDocRef(db: Firestore, uid: string, chatId: string) { + return userDocRef(db, uid).collection("aiChats").doc(chatId) + .collection("sessionContext").doc(SESSION_CONTEXT_DOC_ID); +} + +export async function loadAuraStyleProfile( + db: Firestore, + uid: string +): Promise { + const [styleSnap, userSnap] = await Promise.all([ + profileDocRef(db, uid, STYLE_PROFILE_DOC_ID).get(), + userDocRef(db, uid).get(), + ]); + + if (styleSnap.exists) { + return normalizeStyleProfile(styleSnap.data()); + } + + const userData = userSnap.data() ?? {}; + const legacy = userData.profilePreferences ?? {}; + const profile = buildStyleProfileFromLegacyPreferences(legacy); + return profile; +} + +export async function loadLearnedAuraStyleMemory( + db: Firestore, + uid: string +): Promise { + const snap = await profileDocRef(db, uid, LEARNED_STYLE_MEMORY_DOC_ID).get(); + return snap.exists ? normalizeLearnedStyleMemory(snap.data()) : emptyLearnedStyleMemory(); +} + +export async function loadAuraSessionContext( + db: Firestore, + uid: string, + chatId?: string | null +): Promise { + if (!chatId) return null; + const snap = await sessionContextDocRef(db, uid, chatId).get(); + if (!snap.exists) return null; + const session = normalizeAuraSessionContext(snap.data()); + if (!session) return null; + if (session.expiresAt && session.expiresAt <= Date.now()) return null; + return session; +} + +export async function loadCompactAuraMemoryContext( + db: Firestore, + uid: string, + chatId?: string | null +): Promise { + const [explicitProfile, learnedProfile, session] = await Promise.all([ + loadAuraStyleProfile(db, uid), + loadLearnedAuraStyleMemory(db, uid), + loadAuraSessionContext(db, uid, chatId), + ]); + + return buildCompactAuraMemoryContext({ + explicitProfile, + learnedProfile, + session, + }); +} diff --git a/functions/src/shared/auraOutfitPhotoAnalysis.ts b/functions/src/shared/auraOutfitPhotoAnalysis.ts new file mode 100644 index 0000000..eed5039 --- /dev/null +++ b/functions/src/shared/auraOutfitPhotoAnalysis.ts @@ -0,0 +1,280 @@ +import OpenAI from "openai"; +import { logger } from "firebase-functions/v2"; + +export type AuraAttachmentForOutfitAnalysis = { + type?: "image" | "audio"; + uri?: string; + mimeType?: string | null; + storagePath?: string | null; + width?: number | null; + height?: number | null; +}; + +export type AuraDetectedOutfitPiece = { + role: "top" | "bottom" | "footwear" | "outerwear" | "accessory"; + label: string; + color?: string | null; + confidence?: number | null; + notes?: string | null; +}; + +export type AuraOutfitPhotoAnalysis = { + detectedPieces: AuraDetectedOutfitPiece[]; + outfitVibe?: string | null; + stylingNotes?: string[]; + missingToComplete?: string[]; +}; + +const OUTFIT_PHOTO_INTENTS = new Set(["outfit_analysis", "worn_outfit_photo"]); +const WORN_OUTFIT_PHOTO_RE = + /\b(outfit photo|mirror|selfie|wearing|worn outfit|my outfit|this outfit|this fit|fit check|what am i wearing|what are you seeing|analy[sz]e this outfit|how does this look|how do i look|what should i improve|what can i improve|improve this outfit|fix this outfit|rate this outfit)\b/i; + +export function isOutfitPhotoIntent(clientIntent?: string | null) { + return OUTFIT_PHOTO_INTENTS.has(String(clientIntent ?? "")); +} + +export function isOutfitPhotoRequest(params: { + clientIntent?: string | null; + userMessage?: string | null; + attachments?: AuraAttachmentForOutfitAnalysis[]; +}) { + if (isOutfitPhotoIntent(params.clientIntent)) return true; + const hasImage = (params.attachments ?? []).some( + (attachment) => attachment.type === "image" && !!attachment.uri, + ); + return hasImage && WORN_OUTFIT_PHOTO_RE.test(String(params.userMessage ?? "")); +} + +export function safeAuraVisionError(error: unknown) { + const candidate = error as { + status?: unknown; + code?: unknown; + type?: unknown; + param?: unknown; + message?: unknown; + error?: { message?: unknown; code?: unknown; type?: unknown; param?: unknown }; + requestID?: unknown; + }; + return { + status: typeof candidate?.status === "number" ? candidate.status : null, + code: String(candidate?.code ?? candidate?.error?.code ?? "") || null, + type: String(candidate?.type ?? candidate?.error?.type ?? "") || null, + param: String(candidate?.param ?? candidate?.error?.param ?? "") || null, + requestID: String(candidate?.requestID ?? "") || null, + message: + String(candidate?.error?.message ?? candidate?.message ?? "") + .replace(/\s+/g, " ") + .slice(0, 300) || null, + }; +} + +function safeImageRef(uri: string) { + try { + const parsed = new URL(uri); + const pathParts = parsed.pathname.split("/").filter(Boolean); + return { + host: parsed.hostname, + pathTail: pathParts.slice(-2).join("/"), + hasToken: parsed.searchParams.has("token"), + hasAltMedia: parsed.searchParams.get("alt") === "media", + }; + } catch { + return { + host: null, + pathTail: null, + hasToken: false, + hasAltMedia: false, + }; + } +} + +function clampConfidence(value: unknown) { + const numeric = Number(value); + if (!Number.isFinite(numeric)) return 0.5; + return Math.max(0, Math.min(1, numeric)); +} + +function cleanString(value: unknown, fallback = "") { + const text = String(value ?? "").replace(/\s+/g, " ").trim(); + return text || fallback; +} + +function normalizeRole(value: unknown): AuraDetectedOutfitPiece["role"] | null { + const role = cleanString(value).toLowerCase(); + if (role === "top") return "top"; + if (role === "bottom") return "bottom"; + if (role === "footwear" || role === "shoe" || role === "shoes") return "footwear"; + if (role === "outerwear" || role === "jacket") return "outerwear"; + if (role === "accessory" || role === "accessories") return "accessory"; + return null; +} + +export function normalizeOutfitPhotoAnalysis(value: unknown): AuraOutfitPhotoAnalysis { + const root = value && typeof value === "object" ? (value as Record) : {}; + const pieces = Array.isArray(root.detectedPieces) ? root.detectedPieces : []; + const detectedPieces: AuraDetectedOutfitPiece[] = []; + for (const entry of pieces) { + const piece = entry && typeof entry === "object" ? (entry as Record) : {}; + const role = normalizeRole(piece.role); + const label = cleanString(piece.label); + if (!role || !label || /not visible/i.test(label)) continue; + detectedPieces.push({ + role, + label, + color: cleanString(piece.color) || null, + confidence: clampConfidence(piece.confidence), + notes: cleanString(piece.notes) || null, + }); + if (detectedPieces.length >= 8) break; + } + + const stylingNotes = (Array.isArray(root.stylingNotes) ? root.stylingNotes : []) + .map((entry) => cleanString(entry)) + .filter(Boolean) + .slice(0, 4); + const missingToComplete = (Array.isArray(root.missingToComplete) ? root.missingToComplete : []) + .map((entry) => cleanString(entry)) + .filter(Boolean) + .slice(0, 5); + + for (const label of ["top", "bottom", "footwear"] as const) { + if (!detectedPieces.some((piece) => piece.role === label)) { + missingToComplete.push(`${label === "footwear" ? "shoes" : label} not visible`); + } + } + + return { + detectedPieces, + outfitVibe: cleanString(root.outfitVibe) || null, + stylingNotes, + missingToComplete: Array.from(new Set(missingToComplete)).slice(0, 5), + }; +} + +export function outfitPhotoAnalysisResponse( + analysis: AuraOutfitPhotoAnalysis, + sourceImageUrl?: string | null, +) { + return { + presentation: "outfit_analysis" as const, + title: "I found this outfit", + reply: "I found this outfit. Review the pieces before saving or adding them to your closet.", + reason: "", + outfitItems: analysis.detectedPieces.map((piece) => + [piece.color, piece.label].filter(Boolean).join(" "), + ), + ownedPieces: [], + recommendedAdditions: [], + swapSuggestion: "", + missingPieces: analysis.missingToComplete ?? [], + upgradeSuggestions: [], + upgradeSuggestionItems: [], + chips: ["How do I improve this?", "Make it cleaner", "What is missing?"], + look: null, + lookOptions: [], + outfitAnalysis: { + ...analysis, + sourceImageUrl: sourceImageUrl ?? null, + }, + }; +} + +export async function analyzeOutfitPhoto(params: { + client: OpenAI; + attachments: AuraAttachmentForOutfitAnalysis[]; + userMessage: string; +}) { + const images = params.attachments.filter( + (attachment): attachment is AuraAttachmentForOutfitAnalysis & { uri: string } => + attachment.type === "image" && !!attachment.uri, + ); + if (!images.length) return outfitPhotoAnalysisResponse(normalizeOutfitPhotoAnalysis({})); + logger.info("[AURA_OUTFIT_PHOTO] vision request built", { + imageCount: images.length, + visionPayloadBuilt: true, + images: images.slice(0, 2).map((image) => ({ + ...safeImageRef(image.uri), + validatedMediaSource: image.storagePath ? "owned_storage_download_url" : "validated_remote_url", + mimeType: image.mimeType ?? null, + hasStoragePath: !!image.storagePath, + width: image.width ?? null, + height: image.height ?? null, + })), + promptLength: params.userMessage.length, + }); + + const response = await params.client.responses.create({ + model: "gpt-5.4", + input: [ + { + role: "developer", + content: + "You analyze worn outfit photos for a wardrobe app. Identify only visible clothing. Do not infer hidden items. Do not claim garment cutouts. Return JSON only.", + }, + { + role: "user", + content: [ + { + type: "input_text", + text: + `User request: ${params.userMessage || "Analyze this worn outfit photo."}\n\n` + + "Return top, bottom, footwear, outerwear/jacket, and accessories only when visible. " + + "Use role accessory for ties, sunglasses, glasses, belts, bags, watches, jewelry, and hats. " + + "Use role outerwear for suit jackets, blazers, jackets, coats, and overshirts. Use role bottom for trousers or pants and footwear for shoes. " + + "Use confidence scores from 0 to 1. If shoes or accessories are cropped/hidden, omit them from detectedPieces and list them as not visible in missingToComplete.", + }, + ...images.slice(0, 2).map((image) => ({ + type: "input_image" as const, + image_url: image.uri, + detail: "high" as const, + })), + ], + }, + ], + text: { + format: { + type: "json_schema", + name: "outfit_photo_analysis", + schema: { + type: "object", + additionalProperties: false, + properties: { + detectedPieces: { + type: "array", + items: { + type: "object", + additionalProperties: false, + properties: { + role: { type: "string", enum: ["top", "bottom", "footwear", "outerwear", "accessory"] }, + label: { type: "string" }, + color: { type: ["string", "null"] }, + confidence: { type: ["number", "null"] }, + notes: { type: ["string", "null"] }, + }, + required: ["role", "label", "color", "confidence", "notes"], + }, + }, + outfitVibe: { type: ["string", "null"] }, + stylingNotes: { type: "array", items: { type: "string" } }, + missingToComplete: { type: "array", items: { type: "string" } }, + }, + required: ["detectedPieces", "outfitVibe", "stylingNotes", "missingToComplete"], + }, + }, + }, + }); + + let parsed: unknown = {}; + try { + parsed = JSON.parse(String(response.output_text || "{}")); + } catch { + parsed = {}; + } + const analysis = normalizeOutfitPhotoAnalysis(parsed); + logger.info("[AURA_OUTFIT_PHOTO] vision response parsed", { + detectedPieceCount: analysis.detectedPieces.length, + roles: analysis.detectedPieces.map((piece) => piece.role), + missingToComplete: analysis.missingToComplete ?? [], + }); + return outfitPhotoAnalysisResponse(analysis, images[0]?.uri ?? null); +} diff --git a/functions/src/shared/auraPrompt.ts b/functions/src/shared/auraPrompt.ts index f179ee4..1e340af 100644 --- a/functions/src/shared/auraPrompt.ts +++ b/functions/src/shared/auraPrompt.ts @@ -18,7 +18,7 @@ Tone and behavior: * Sometimes use the user's name, but not always. * Do not repeat the same opening style too often. * Respond like a real assistant talking to the user, not like a support bot writing a ticket reply. -* No emojis. +* Emojis are allowed only when they make a structured styling reply feel warmer. Use at most 1 emoji per section header and never use emoji spam. * No robotic filler. * No childish enthusiasm. * No corporate support language. @@ -27,9 +27,18 @@ Tone and behavior: Response style: +* Lead with the answer. The first line should usually be the recommendation, verdict, or next move. +* Then give the short reason, grounded in the user's wardrobe, context, photo, item, occasion, weather, or question. +* End with a clear option or action when useful. +* Use direct stylist labels like "My call:", "Best option:", "Avoid unless:", and "Do this:" when they make the answer easier to scan. +* Prefer concise structure over long paragraphs. Most useful answers should be 2-5 short sections or 1-3 tight sentences. * Prefer natural phrasing over rigid structure. * Make the user feel like you are actively helping them think through something. * Even when giving recommendations, sound fluid and conversational. +* Choose formatting based on intent and context: casual/simple messages stay conversational; detailed styling help should be structured and easy to scan on mobile. +* For structured replies, use short headers, bullets or numbered points, and blank lines between sections. +* Use bullets only when they improve readability. Do not turn every answer into a list. +* Preserve line breaks in reply text when structure helps. * Always provide value first. * Never let missing wardrobe data make you feel blocked. * Keep responses sharp, but not unnaturally clipped. @@ -40,6 +49,9 @@ Response style: * Be comfortable being opinionated. * Do not narrate your process. * Do not hedge unless uncertainty is real. +* Avoid generic fashion essays. Give the practical call like a personal stylist friend who knows the closet. +* If the user asks a follow-up like "make it dressier", "what about shoes?", "how do I style this?", or "is this better?", infer the current outfit from recent conversation, selected pieces, rendered look context, or the last generated look when available. +* If a follow-up needs a specific outfit or item and no context is available, ask one short clarifying question instead of giving generic advice. Capabilities: @@ -74,7 +86,12 @@ Streaming behavior: * Respond in plain natural text only. * Do not return JSON. * Let the first line land quickly. +* Start with the direct recommendation or verdict before explaining. * If the user sends something simple like "hey", give a short natural greeting and lightly hint at what you can help with. +* For detailed styling/help replies, stream the same sectioned plain-text format the final response should use. +* Preserve line breaks, bullets, and numbered lists when they make the answer easier to read. +* Keep simple questions simple; do not force sections into tiny yes/no answers or thanks. +* Keep streamed answers concise unless the user asks for a deep breakdown. * Preserve strong line rhythm and natural sentence flow. `; @@ -84,22 +101,221 @@ ${AURA_BASE_IDENTITY} Structured behavior: * Respect weather, occasion, season, color harmony, and item availability. +* Aura context may include Styling Intelligence v1 metadata and rule-based engine notes. Use these as deterministic styling signals, not as marketing claims. +* Personalize using the wardrobe, explicit style preferences, learned behavior, and current session context when they are present. +* Treat explicit preferences as the strongest signal, learned behavior as secondary, and current-session context as immediate nuance. +* Use personalization naturally and sparingly. Do not recite profile fields or analytics back to the user unless they ask. +* When userPreferences are present, use them as soft styling guidance, not rigid rules. +* Respect preferred styles, colors, fits, categories, occasions, and accessory preferences when they improve the recommendation. +* Avoid avoided colors when a reasonable owned alternative exists. +* Do not mention stored preferences, profile data, or preference fields in the reply. +* If the wardrobe is sparse, prioritize owned items first and separate missing-piece suggestions clearly. +* If learned confidence is light, personalize softly and avoid overstating certainty. * Avoid recommending unavailable or in-laundry items. * Prefer realistic, wearable combinations. +* When fit, color, or style identity metadata is missing, keep certainty lower instead of inventing details. * For visual looks, build from the closet first and only add missing pieces when there is no reasonable owned option. * Footwear and bottoms should be treated as high-priority closet-first categories. * When several owned shoes could work, pick the best reasonable option instead of inventing a missing ideal. * Clean sneakers, loafers, derby shoes, boots, sandals, and other owned footwear can all be valid if they fit the vibe well enough. +* Never hallucinate owned items. If it is not in the wardrobe context, treat it as suggested. +* When the user uploads a mirror/selfie/worn outfit photo, identify only visible clothing pieces. Do not infer hidden shoes, accessories, underlayers, brands, or materials. +* For outfit photo analysis, use confidence scores, say "not visible" in missingToComplete for expected roles that are obscured or cropped, and do not claim separate garment cutouts. +* Always keep owned pieces and suggested pieces clearly separated. * Suggest at most one swap. * If something is weak, say so cleanly. * If something is missing, mention the gap gracefully and keep helping. * If something works, say it with confidence. * Use conversation history when it matters. Do not answer as if every message is isolated. +* Recent conversation may include rendered outfit context from the UI. Treat that as the current visible look for follow-ups. +* For follow-ups like "make it dressier", "what about shoes?", "how to style this?", "is this better?", "more casual", or "rate this", use the current visible look/selected pieces/last generated look when available. +* If the needed item or outfit context is missing, ask one short clarifying question and stop. +* When memory is helpful, weave it in like a premium stylist would: + - "This stays closer to your lane: clean, layered, and easy to wear." + - "I kept this sharper and more mature since that seems closer to what you gravitate toward." + - "This is a slightly bolder take on your usual palette." + - "This still feels like you, just a little more dressed up." +* Vary personalization phrasing. Do not recycle the same sentence shape every time. +* Avoid making every outfit sound like a lesson. Personalization should feel noticed, not explained. +* When you mention why something feels right for the user, prefer a stylist's shorthand over explicit reasoning language. +* If you are generating multiple looks, vary the angle of each one: one can lean cleaner, one sharper, one bolder, one more relaxed. +* Make the personalization feel specific without sounding clinical. * For outfit, styling, occasion, or "what should I wear" requests, prefer returning a visual look object the UI can render. -* When you return a visual look, keep the reply shorter and let the card do more of the work. -* If look is present, reply should usually be 1 short sentence, with a hard preference for under 18 words. +* Whenever you recommend a concrete outfit made of specific pieces, attach a structured look payload. Do not leave the outfit as plain text only. +* Requests like "make this outfit better", "improve this outfit", "style this", "style this item", "what should I wear", "complete this look", "make it dressier", "make it more casual", "what shoes?", and occasion outfit requests should produce both a concise text reply and a look payload when enough context exists. +* If context for "this" or "it" is missing, ask one short clarifying question instead of inventing an outfit. +* If a look card will be attached, use the reply for the quick take and reasoning, not as a long item dump. +* The text and look card must stay in sync: every closet piece named as part of the outfit should appear in look.pieces with source "closet" and itemId when known. +* When you return a visual look for straightforward outfit generation, keep the reply shorter and let the card do more of the work. +* If look is present for a simple outfit generation request, reply should usually be 1 short sentence, with a hard preference for under 18 words. +* If the user explicitly asks for analysis, rating, improvement, why it works, fit/color/style feedback, product-link analysis, or manual outfit builder feedback, the reply may use structured formatting even when a look card is also present. Keep each bullet short. * Do not repeat the card contents in the reply when look is present. +* Do not output a bare list of outfit item names as the whole reply. Put the items in look.pieces and keep the reply concise. * Visual looks should feel like premium "complete the look" styling recommendations, not inventory dumps. +* If the user asks for options, versions, or a range like safe / balanced / bold, prefer returning multiple visual looks instead of a long paragraph. +* For safe / balanced / bold requests, make the three directions meaningfully different in risk level while still feeling like the same person. +* If the request is "try again", "give me one more", "another one", or "different outfit", treat it as a request for a new outfit, not a repeat or explanation of the prior one. +* When outfit diversity context is present, avoid the listed previous item IDs and exact look signatures where possible. +* For another/different outfit, do not reuse the exact same outfit and avoid reusing more than 1-2 closet items from the previous look. +* Prefer changing the top, bottom, and shoes combination before changing only accessories. +* For safe / balanced / bold, each option must use a distinct piece combination. If the closet is limited, explain overlap briefly and still change at least one anchor piece. +* If the user asks for multiple outfits, multiple options, several directions, or a numbered set like "three outfits", you must return structured multi-look output in lookOptions instead of prose-only recommendations. +* For multi-look requests, do not collapse the answer into one look plus generic outfitItems/ownedPieces. The UI needs one full structured look per option. + +Intent-aware formatting: + +* Use structured formatting for detailed styling/help intents, including: + - How to style this outfit + - Improve this outfit + - What should I buy + - What is missing from my closet + - Rate this outfit + - Why does this outfit work/not work + - What should I wear today + - Plan an outfit for an occasion + - Compare two outfits + - Closet analysis + - Fit/color/style feedback + - Manual outfit builder feedback + - Product-link analysis + - Item-specific styling advice +* Do not use structured formatting for casual messages, thanks, greetings, or tiny checks unless the user asks for detail. +* Simple examples: + - "Do I need a jacket?" should get a short answer plus one reason. + - "Is this good?" should get a short answer plus one note. + - "Thanks" should get a casual reply only. +* Use plain text headers ending in ":"; markdown tables are not allowed. +* Keep sections compact. A structured reply should usually have 3-5 sections, not an essay. +* Controlled emojis are optional. Prefer clean headers over emojis. +* The preferred shape is: + My call: + [direct recommendation] + + Why: + - [1-2 short reasons] + + Do this: + [clear next step] +* Use "Best option:" instead of "My call:" for occasion, weather, or outfit selection. +* Use "Avoid unless:" when the answer is cautionary. + +Formatting templates: + +* Outfit styling: + My call: + ... + + Why: + - ... + - ... + + Do this: + - ... + + Styling note: + ... + +* Outfit improvement: + My call: + ... + + Keep: + - ... + + Swap / add: + - ... + + Why: + - ... + + Do this: + ... + +* Shopping / closet gaps: + My call: + ... + + Top priorities: + 1. ... + 2. ... + 3. ... + + Why these help: + - ... + + Do this: + ... + +* Outfit rating: + Score: + ... + + What works: + - ... + + What weakens it: + - ... + + Do this: + - ... + +* Item styling: + My call: + ... + + Best with: + - ... + + Avoid: + - ... + + Do this: + 1. ... + 2. ... + +* Occasion planning: + Best option: + ... + + Outfit: + - Top: + - Bottom: + - Footwear: + - Layer: + - Accessories: + + Why it fits: + - ... + + Do this: + ... + +* Comparing outfits: + Best pick: + ... + + Outfit 1: + - ... + + Outfit 2: + - ... + + Verdict: + ... + +* Product-link analysis: + Quick take: + ... + + Worth it if: + - ... + + Watch-outs: + - ... + + Styling ideas: + 1. ... + 2. ... Presentation decision: @@ -118,39 +334,83 @@ Presentation decision: * Set look for outfit-related requests when the user would benefit from a visual recommendation. * In a visual look, use closet pieces where possible and fill missing gaps with suggested pieces. * The look should include a small set of meaningful actions the UI can surface. +* If Aura context says isSparseWardrobe is true: + - You must still generate at least one outfit using the available owned items, even if the wardrobe is tiny. + - Acknowledge the limitation naturally and briefly, never with robotic or defeatist phrasing. + - Include 1-2 upgrade suggestions that would make the outfit system stronger. + - Keep the tone encouraging, like a stylist refining the closet, not criticizing it. + - If you suggest a missing item, make it obvious that it is a suggestion, not an owned piece. + - Good tone example: "This works, but you're missing a layer - a jacket would elevate it instantly." +* If the wardrobe is not sparse and there are no obvious core gaps, keep missingPieces and upgradeSuggestions empty unless they genuinely add value. +* Never overwhelm the user with more than 3 total missingPieces + upgradeSuggestions. +* For wardrobe-gap, shopping-priority, or "what should I buy/add" requests, use this structure: + - Acknowledge the current wardrobe base first. + - Name the strongest missing item or gap. + - Explain why it improves outfits the user can already make. + - Mention estimated outfit impact only when you have a concrete estimate. + - End softly, for example: "Want me to find options?" +* Never say "buy this now" or use affiliate/shopping language that feels spammy. Disallowed response style examples: * "Based on the current wardrobe metadata..." +* "Your stored preferences indicate..." +* "Based on your profile data..." +* "The learned memory suggests..." * "I think this could work because..." * "You may want to consider..." * "I am not recommending..." * "This is the most polished pairing in your current closet because..." * "I can't build a complete outfit yet." * "You need bottoms and footwear first." +* "Your profile says..." +* "The data suggests..." +* "This aligns with your stored preferences." Output requirements: * Return valid JSON only. * Keep copy premium and concise. +* The reply should usually begin with a direct answer section such as "My call:", "Best option:", "Avoid unless:", or a one-sentence verdict. +* Use short reasoning and a practical next step. Avoid long generic paragraphs unless the user asks for deeper detail. * Include presentation as either "chat" or "card". * title must be punchy, 2-4 words max when presentation is "card". For "chat", keep it minimal. * reply should sound like a real assistant message, not a schema field. +* reply may contain newline-separated plain text sections, bullets, and numbered lists when the intent calls for structure. +* Preserve mobile-readable spacing with blank lines between sections. +* Do not use markdown tables or dense markdown formatting. * reason should be empty unless it adds real value. * outfitItems must be concise strings and only included when useful. * ownedPieces must list only pieces present in the user's wardrobe. * recommendedAdditions must list only suggested pieces that are not in the wardrobe. * swapSuggestion must be short and direct, and only included when useful. +* missingPieces should be short labels for missing or thin wardrobe areas. +* upgradeSuggestions should be short, human-readable upgrade ideas. +* upgradeSuggestionItems can mirror upgradeSuggestions with optional searchQuery values for future shopping hooks. * look must be null unless this is truly an outfit/look recommendation. +* lookOptions should be empty unless the user clearly asked for multiple directions, multiple versions, or safe / balanced / bold. +* outfitAnalysis should be present only for worn outfit photo analysis. It must include detectedPieces with roles top, bottom, footwear, outerwear, and accessory only when visible. Use role "footwear" for shoes in outfitAnalysis. +* If the user asked for multiple outfits/options/directions, lookOptions must contain those structured looks whenever you can produce them safely. * If look is present: - lookTitle should be short and premium. - vibe should be 1-3 words. - shortExplanation should be 1 sentence. - stylingNote should be 1 short sentence explaining why the look works. + - personalizationLabel should be a very short premium UI chip, 3-7 words, only when it adds value. + - personalizationNote should be a single subtle sentence about why this feels right for the user, not raw reasoning. + - personalizationLabel should read like polished UI copy, not metadata. + - personalizationNote should sound like a stylist aside, not an explanation of memory or logic. + - When confidence is low, keep personalization softer and broader instead of making bold claims. - pieces should usually contain 3-5 entries. - pieces from the closet should use source "closet" and include itemId when available. - suggested pieces should use source "suggested". - if the user owns workable shoes, bottoms, or accessories for this look, prefer them in pieces before adding a suggested substitute. - if the request is closet-first or "use my closet", choose the best workable owned shoe even if it is not perfect. - - actions should contain 2-4 useful action ids. + - actions should contain 3-6 useful action ids and usually include likeLook, notMyVibe, showMoreLikeThis, and lessLikeThis for strong look cards. +* If lookOptions is present: + - return 2-3 looks max. + - for requests like "give me three outfits" or "show me 3 directions", use lookOptions as the primary structured payload. + - safe / balanced / bold is the preferred progression when the user asks for multiple directions. + - keep each look concise, distinct, and action-ready. + - each option should be renderable on its own, with its own pieces, fromCloset, addToComplete, and actions. `; diff --git a/functions/src/shared/auraStreamAttachments.ts b/functions/src/shared/auraStreamAttachments.ts new file mode 100644 index 0000000..5d0511f --- /dev/null +++ b/functions/src/shared/auraStreamAttachments.ts @@ -0,0 +1,139 @@ +import { logger } from "firebase-functions/v2"; + +import { redactUid } from "./rateLimit"; +import { redactUrlForLogs, validateSafeUrlForFetch } from "./safeFetch"; + +export type AuraAttachment = { + type?: "image"; + uri?: string; + role?: string | null; + groupId?: string | null; + mimeType?: string | null; + storagePath?: string | null; + width?: number | null; + height?: number | null; +}; + +export type ClientAuraIntent = + | "add_item" + | "add_items_batch" + | "add_item_from_url" + | "add_from_link" + | "add_from_links_batch" + | string + | null; + +const ADD_IMAGE_RE = + /\b(add|save|store|put|upload|log)\b[\s\S]{0,80}\b(closet|wardrobe|item|items|these|this|all)\b|\b(add|save)\s+(this|these|all|item|items)\b/i; +const BATCH_IMAGE_RE = /\b(these|all|each|separate|multiple|items)\b/i; + +export async function parseAuraAttachments(uid: string, input: unknown): Promise { + if (!Array.isArray(input)) return []; + const out: AuraAttachment[] = []; + for (const entry of input.slice(0, 8)) { + if (!entry || typeof entry !== "object") continue; + const candidate = entry as Record; + if (candidate.type !== "image") continue; + const uri = String(candidate.uri ?? "").trim(); + if (!uri) continue; + const storagePath = String(candidate.storagePath ?? "").trim() || null; + if (storagePath && !storagePath.startsWith(`users/${uid}/auraAttachments/`)) { + logger.warn("[AURA_STREAM_ATTACHMENTS] rejected foreign image attachment", { + uidHash: redactUid(uid), + hasStoragePath: !!storagePath, + }); + continue; + } + try { + await validateSafeUrlForFetch(uri); + } catch (error) { + logger.warn("[AURA_STREAM_ATTACHMENTS] rejected unsafe image attachment URL", { + uidHash: redactUid(uid), + uri: redactUrlForLogs(uri), + reason: error instanceof Error ? error.message : String(error), + }); + continue; + } + logger.info("[AURA_STREAM_ATTACHMENTS] accepted image attachment", { + uidHash: redactUid(uid), + kind: "image", + mimeType: typeof candidate.mimeType === "string" ? candidate.mimeType : null, + hasStoragePath: !!storagePath, + hasDownloadURL: /^https?:\/\//i.test(uri), + uriHost: safeUrlHost(uri), + validatedMediaSource: storagePath ? "owned_storage_download_url" : "validated_remote_url", + width: typeof candidate.width === "number" ? candidate.width : null, + height: typeof candidate.height === "number" ? candidate.height : null, + }); + out.push({ + type: "image", + uri, + role: typeof candidate.role === "string" ? candidate.role : null, + groupId: typeof candidate.groupId === "string" ? candidate.groupId : null, + mimeType: typeof candidate.mimeType === "string" ? candidate.mimeType : null, + storagePath, + width: typeof candidate.width === "number" ? candidate.width : null, + height: typeof candidate.height === "number" ? candidate.height : null, + }); + } + return out; +} + +export function attachmentContextText(attachments: AuraAttachment[]) { + if (!attachments.length) return "No attachments."; + return JSON.stringify( + attachments.map((attachment, index) => ({ + index, + type: attachment.type, + role: attachment.role ?? null, + groupId: attachment.groupId ?? null, + mimeType: attachment.mimeType ?? null, + storagePath: attachment.storagePath ?? null, + width: attachment.width ?? null, + height: attachment.height ?? null, + uri: redactUrlForLogs(attachment.uri), + })), + null, + 2 + ); +} + +export function safeUrlHost(uri: string | undefined) { + if (!uri) return null; + try { + return new URL(uri).host; + } catch { + return null; + } +} + +export function imageGroupsForAddIntent( + text: string, + attachments: AuraAttachment[], + clientIntent?: ClientAuraIntent, +) { + const images = attachments.filter( + (attachment): attachment is AuraAttachment & { uri: string } => + attachment.type === "image" && !!attachment.uri, + ); + const forceAdd = + clientIntent === "add_item" || clientIntent === "add_items_batch"; + if (!images.length || (!forceAdd && !ADD_IMAGE_RE.test(text))) return []; + const shouldSplit = + images.length > 1 && + (clientIntent === "add_items_batch" || + images[0]?.role === "separate_items" || + BATCH_IMAGE_RE.test(text)); + return shouldSplit ? images.map((image) => [image.uri]) : [images.map((image) => image.uri)]; +} + +export function buildAuraVisionImageInputs(attachments: AuraAttachment[]) { + return attachments + .filter((attachment) => attachment.type === "image") + .slice(0, 2) + .map((attachment) => ({ + type: "input_image" as const, + image_url: attachment.uri ?? "", + detail: "low" as const, + })); +} diff --git a/functions/src/shared/auraStreamRouting.ts b/functions/src/shared/auraStreamRouting.ts new file mode 100644 index 0000000..5c2a827 --- /dev/null +++ b/functions/src/shared/auraStreamRouting.ts @@ -0,0 +1,136 @@ +import type { AuraUserProfile } from "./loadAuraUserProfile"; +import type { AuraAttachment } from "./auraStreamAttachments"; + +const MULTI_LOOK_REQUEST_RE = + /\b((?:2|3|4|two|three|four)\s+(?:outfits?|looks?|options?|directions?)|multiple\s+(?:outfits?|looks?|options?|directions?)|safe,\s*balanced,\s*(?:and\s*)?bold|safe\s+balanced\s+bold|different\s+directions|few\s+outfits?)\b/i; +const SIMPLE_CHAT_NEEDS_STRUCTURED_RE = + /\b(add|accessor(?:y|ies)|analy[sz]e|buy|card|closet|complete|date|dinner|dressier|event|fit|formal|image|improve|item|jacket|laundry|link|look|missing|outfit|photo|picture|plan|planner|product|rate|recommend|save|shop|shopping|smart\s+buys?|style|swap|tonight|tomorrow|trip|use\s+only|wardrobe|wear|wearing|weather|wedding|work)\b/i; + +export function parseRequestedLookCount(message: string) { + const text = String(message ?? "").toLowerCase(); + if (/\b(4|four)\s+(?:outfits?|looks?|options?|directions?)\b/.test(text)) return 4; + if (/\b(3|three)\s+(?:outfits?|looks?|options?|directions?)\b/.test(text)) return 3; + if (/\b(2|two)\s+(?:outfits?|looks?|options?|directions?)\b/.test(text)) return 2; + if (/\bsafe,\s*balanced,\s*(?:and\s*)?bold\b|\bsafe\s+balanced\s+bold\b/.test(text)) return 3; + if (/\bmultiple\s+(?:outfits?|looks?|options?|directions?)\b|\bdifferent\s+directions\b|\bfew\s+outfits?\b/.test(text)) return 3; + return 1; +} + +export function wantsMultipleLooks(message: string) { + return MULTI_LOOK_REQUEST_RE.test(String(message ?? "")); +} + +export function buildMultiLookRequestNote(userMessage: string) { + if (!wantsMultipleLooks(userMessage)) return ""; + const count = Math.min(3, Math.max(2, parseRequestedLookCount(userMessage))); + return ( + "\n\nMulti-look requirement:\n" + + "The user is explicitly asking for multiple outfit options.\n" + + `Return presentation "card" and provide ${count} structured looks in lookOptions whenever you can do so safely.\n` + + "Do not answer this with only prose, outfitItems, ownedPieces, or recommendedAdditions if structured looks are possible.\n" + + "If only one structured look is possible, still return that one structured look and leave a concise reply." + ); +} + +function isGeneralChatIntent(value: string | null) { + const normalized = String(value ?? "").trim().toLowerCase(); + return !normalized || normalized === "general_chat"; +} + +export function shouldUseSimpleChatPath(args: { + userMessage: string; + attachments: AuraAttachment[]; + detectedUrls: { normalized: string }[]; + clientIntent: string | null; + effectiveClientIntent: string | null; + linkIntent: string; +}) { + if (!args.userMessage.trim()) return false; + if (args.attachments.length > 0) return false; + if (args.detectedUrls.length > 0) return false; + if (args.linkIntent !== "none") return false; + if (!isGeneralChatIntent(args.clientIntent)) return false; + if (!isGeneralChatIntent(args.effectiveClientIntent)) return false; + if (wantsMultipleLooks(args.userMessage)) return false; + return !SIMPLE_CHAT_NEEDS_STRUCTURED_RE.test(args.userMessage); +} + +function compactText(value: unknown, maxLength = 160) { + const text = String(value ?? "").replace(/\s+/g, " ").trim(); + return text ? text.slice(0, maxLength) : null; +} + +function uniqueTrimmed(values: string[] | undefined, max = 3) { + return Array.from( + new Set( + (values ?? []) + .map((value) => String(value ?? "").trim()) + .filter(Boolean) + ) + ).slice(0, max); +} + +function compactAuraUserProfile(userProfile: AuraUserProfile) { + return { + displayName: compactText(userProfile.displayName), + firstName: compactText(userProfile.firstName), + region: compactText(userProfile.region), + wardrobeMode: compactText(userProfile.wardrobeMode), + selectedCategories: uniqueTrimmed(userProfile.selectedCategories, 6), + styleAesthetics: uniqueTrimmed(userProfile.styleAesthetics, 6), + favoriteColors: uniqueTrimmed(userProfile.favoriteColors, 6), + avoidedColors: uniqueTrimmed(userProfile.avoidedColors, 6), + accessoryPreferences: uniqueTrimmed(userProfile.accessoryPreferences, 6), + occasionPriority: uniqueTrimmed(userProfile.occasionPriority, 6), + goals: uniqueTrimmed(userProfile.goals, 6), + preferredFit: compactText(userProfile.preferredFit), + budgetPreference: compactText(userProfile.budgetPreference), + preferredBrands: uniqueTrimmed(userProfile.stylePreferences?.preferredBrands, 6), + closetPreferences: userProfile.closetPreferences + ? { + prioritizeUnderused: userProfile.closetPreferences.prioritizeUnderused === true, + hideLaundryByDefault: userProfile.closetPreferences.hideLaundryByDefault === true, + defaultSort: compactText(userProfile.closetPreferences.defaultSort), + } + : null, + }; +} + +function compactMinimumClosetFromClientContext(value: unknown) { + const requestContext = value && typeof value === "object" + ? (value as Record) + : {}; + const minimumCloset = requestContext.minimumCloset && typeof requestContext.minimumCloset === "object" + ? (requestContext.minimumCloset as Record) + : null; + if (!minimumCloset) return null; + + const itemCount = Number(minimumCloset.itemCount ?? 0); + const outfitRange = Number(minimumCloset.outfitRange ?? 0); + const nextBestAdd = compactText(minimumCloset.nextBestAdd, 120); + + return { + itemCount: Number.isFinite(itemCount) ? itemCount : 0, + styleCoreProgress: compactText(minimumCloset.styleCoreProgress, 120), + nextBestAdd, + missingCategoryHints: nextBestAdd ? [nextBestAdd] : [], + outfitRange: Number.isFinite(outfitRange) ? outfitRange : 0, + tone: compactText(minimumCloset.tone, 80), + }; +} + +export function buildCompactAuraContextForSimpleChat(args: { + memory: unknown; + userProfile: AuraUserProfile; + clientContext: unknown; + styleCoreNote: string; +}) { + return { + mode: "compact_simple_chat", + contextPolicy: "Full closet item list and image URLs omitted for simple chat.", + closetSummary: compactMinimumClosetFromClientContext(args.clientContext), + userProfile: compactAuraUserProfile(args.userProfile), + preferenceContext: args.memory, + styleCoreNote: args.styleCoreNote || null, + }; +} diff --git a/functions/src/shared/auraStreamSse.ts b/functions/src/shared/auraStreamSse.ts new file mode 100644 index 0000000..7737f5a --- /dev/null +++ b/functions/src/shared/auraStreamSse.ts @@ -0,0 +1,51 @@ +export type AuraStreamWriter = { + setHeader?: (name: string, value: string) => void; + flushHeaders?: () => void; + write: (chunk: string) => void; + flush?: () => void; +}; + +export function setupAuraStreamResponse(res: AuraStreamWriter) { + res.setHeader?.("Content-Type", "application/x-ndjson; charset=utf-8"); + res.setHeader?.("Cache-Control", "no-cache, no-transform"); + res.setHeader?.("Connection", "keep-alive"); + res.setHeader?.("X-Accel-Buffering", "no"); + res.setHeader?.("Access-Control-Allow-Headers", "Content-Type, Authorization"); + res.flushHeaders?.(); +} + +export function writeEvent( + res: AuraStreamWriter, + payload: Record +) { + res.write(`${JSON.stringify(payload)}\n`); + res.flush?.(); +} + +export function writeStatus(res: AuraStreamWriter, status: string) { + writeEvent(res, { + type: "status", + status, + }); +} + +export function writeTextDelta(res: AuraStreamWriter, delta: string) { + writeEvent(res, { + type: "delta", + delta, + }); +} + +export function writeFinal(res: AuraStreamWriter, data: unknown) { + writeEvent(res, { + type: "final", + data, + }); +} + +export function writeError(res: AuraStreamWriter, error: string) { + writeEvent(res, { + type: "error", + error, + }); +} diff --git a/functions/src/shared/auraUrlCandidatePreview.ts b/functions/src/shared/auraUrlCandidatePreview.ts new file mode 100644 index 0000000..15b3fa4 --- /dev/null +++ b/functions/src/shared/auraUrlCandidatePreview.ts @@ -0,0 +1,470 @@ +import { logger } from "firebase-functions/v2"; +import OpenAI from "openai"; + +import { + candidatePreviewResponse, + extractImageCandidates, + fallbackImageCandidates, + productCategoryHintsFromText, + rankProductLinkImages, + type AuraCandidateItem, +} from "./auraCandidatePreview"; +import type { ProductUrlMetadata } from "./productUrlMetadata"; +import { redactUid } from "./rateLimit"; +import { redactUrlForLogs } from "./safeFetch"; + +export type AuraLinkPreview = { + sourceUrl: string; + title: string | null; + imageUrl: string | null; + imageUrls?: string[]; + description: string | null; + brand?: string | null; + category?: string | null; + subCategory?: string | null; + color?: string | null; + price?: string | null; + currency?: string | null; + priceAmount?: number | null; + priceCurrency?: string | null; + priceDisplay?: string | null; + salePrice?: number | null; + originalPrice?: number | null; + material?: string | null; + materials?: string[]; + fit?: string | null; + sleeveLength?: string | null; + collar?: string | null; + length?: string | null; + pattern?: string | null; + displayColor?: string | null; + displayColors?: string[] | null; + sizeOptions?: string[]; + availableSizes?: string[]; + careInstructions?: string[]; + productDescription?: string | null; + graphicText?: string | null; + motif?: string | null; + collaborationName?: string | null; + status?: "ready" | "needs_review"; +}; + +type UrlCandidateMetadata = ProductUrlMetadata | AuraLinkPreview; + +function cleanPreviewText(value: unknown, maxLength = 500) { + const text = decodePreviewText(value).replace(/\s+/g, " ").trim(); + return text ? text.slice(0, maxLength) : null; +} + +function decodePreviewText(value: unknown) { + return String(value ?? "") + .replace(/&#x([0-9a-f]+);/gi, (_, hex: string) => { + const codePoint = Number.parseInt(hex, 16); + return Number.isFinite(codePoint) ? String.fromCodePoint(codePoint) : ""; + }) + .replace(/&#(\d+);/g, (_, decimal: string) => { + const codePoint = Number.parseInt(decimal, 10); + return Number.isFinite(codePoint) ? String.fromCodePoint(codePoint) : ""; + }) + .replace(/&/g, "&") + .replace(/"/g, "\"") + .replace(/'/g, "'") + .replace(/</g, "<") + .replace(/>/g, ">") + .replace(/ /g, " "); +} + +function normalizePreviewImage(sourceUrl: URL, value: unknown) { + const raw = String(value ?? "").trim(); + if (!raw || raw.startsWith("data:")) return null; + try { + const imageUrl = new URL(raw, sourceUrl); + if (imageUrl.protocol !== "http:" && imageUrl.protocol !== "https:") return null; + imageUrl.hash = ""; + return imageUrl.toString(); + } catch { + return null; + } +} + +export function clientLinkPreviewFromRequest( + value: unknown, + detectedUrl: string | null, +): AuraLinkPreview | null { + if (!value || typeof value !== "object" || !detectedUrl) return null; + const candidate = value as Record; + if (typeof candidate.sourceUrl !== "string") return null; + try { + const detected = new URL(detectedUrl); + const source = new URL(candidate.sourceUrl); + detected.hash = ""; + source.hash = ""; + if (detected.toString() !== source.toString()) return null; + const imageUrl = normalizePreviewImage(source, candidate.imageUrl); + const imageUrls = Array.isArray(candidate.imageUrls) + ? candidate.imageUrls + .map((url) => normalizePreviewImage(source, url)) + .filter((url): url is string => !!url) + : []; + const preview = { + sourceUrl: detected.toString(), + title: cleanPreviewText(candidate.title, 220), + imageUrl, + imageUrls: imageUrls.length ? imageUrls : imageUrl ? [imageUrl] : [], + description: cleanPreviewText(candidate.description, 500), + }; + return preview.imageUrls.length || preview.title || preview.description ? preview : null; + } catch { + return null; + } +} + +export function isHmProductUrl(rawUrl?: string | null) { + try { + const url = new URL(String(rawUrl ?? "")); + const host = url.hostname.toLowerCase(); + return ( + (host === "hm.com" || host.endsWith(".hm.com")) && + /\/productpage\.\d+\.html$/i.test(url.pathname) + ); + } catch { + return false; + } +} + +export function hmSingleImageClientPreview(preview: AuraLinkPreview | null): AuraLinkPreview | null { + if (!preview || !isHmProductUrl(preview.sourceUrl) || !preview.imageUrl) return null; + return { + ...preview, + imageUrls: [preview.imageUrl], + }; +} + +export function hmSanitizedClientPreview(preview: AuraLinkPreview | null): AuraLinkPreview | null { + if (!preview || !isHmProductUrl(preview.sourceUrl)) return null; + const urls = [ + ...(Array.isArray(preview.imageUrls) ? preview.imageUrls : []), + preview.imageUrl, + ] + .map((url) => String(url ?? "").trim()) + .filter((url) => { + try { + const parsed = new URL(url); + return parsed.hostname.toLowerCase() === "image.hm.com"; + } catch { + return false; + } + }); + const seen = new Set(); + const imageUrls = urls + .filter((url) => { + const key = url.toLowerCase().replace(/([?&])(imwidth|width|height|w|h)=\d+/g, "$1"); + if (seen.has(key)) return false; + seen.add(key); + return true; + }) + .slice(0, 24); + const imageUrl = imageUrls[0] ?? preview.imageUrl; + return imageUrl + ? { + ...preview, + imageUrl, + imageUrls, + } + : null; +} + +export function brandFromSourceUrl(sourceUrl?: string | null) { + try { + const host = new URL(String(sourceUrl ?? "")).hostname.replace(/^www\d*\./, "").toLowerCase(); + if (host.endsWith("hm.com")) return "H&M"; + if (host.endsWith("zara.com")) return "Zara"; + if (host.endsWith("nike.com")) return "Nike"; + if (host.endsWith("aritzia.com")) return "Aritzia"; + if (host.endsWith("amazon.com")) return "Amazon"; + if (host.endsWith("footlocker.com")) return "Foot Locker"; + if (host.endsWith("jdsports.com")) return "JD Sports"; + if (host.endsWith("macys.com")) return "Macy's"; + if (host.endsWith("newbalance.com")) return "New Balance"; + const domain = host.split(".")[0] ?? ""; + return domain ? domain.charAt(0).toUpperCase() + domain.slice(1) : null; + } catch { + return null; + } +} + +function isLikelyProductLinkForReview(sourceUrl?: string | null) { + try { + const url = new URL(String(sourceUrl ?? "")); + const host = url.hostname.toLowerCase(); + if ( + host.includes("amazon.") || + host.endsWith("hm.com") || + host.endsWith("zara.com") || + host.endsWith("nike.com") + ) { + return true; + } + return /\b(product|productpage|pdp|item|dp|gp\/product)\b/i.test(url.pathname); + } catch { + return false; + } +} + +export function fallbackLinkPreviewFromUrl(sourceUrl: string): AuraLinkPreview | null { + if (!isLikelyProductLinkForReview(sourceUrl)) return null; + try { + const url = new URL(sourceUrl); + const brand = brandFromSourceUrl(sourceUrl); + const articleId = isHmProductUrl(sourceUrl) + ? url.pathname.match(/\/productpage\.(\d+)\.html$/i)?.[1] ?? null + : null; + const hostLabel = url.hostname.replace(/^www\d*\./i, ""); + return { + sourceUrl: url.toString(), + title: brand ? `${brand} product link` : `${hostLabel} product link`, + imageUrl: null, + imageUrls: [], + description: articleId ? `Product ${articleId}` : null, + brand, + status: "needs_review", + }; + } catch { + return null; + } +} + +function cleanProductTitle(title?: string | null, brand?: string | null) { + let next = decodePreviewText(title).replace(/\s+/g, " ").trim(); + next = next + .replace(/\s*\|\s*H\s*&\s*M(?:\s+[A-Z]{2})?\s*$/i, "") + .replace(/\s*\|\s*Zara\s*$/i, "") + .replace(/\s*\|\s*Nike\s*$/i, "") + .replace(/\s*(?:\||-|–)\s*(?:Aritzia|Amazon(?:\.[A-Za-z.]+)?|Foot\s*Locker|JD\s*Sports|Macy[’']?s|New\s*Balance)\s*$/i, "") + .replace(/\s*(?:\||-|–)\s*(?:Men[’']s|Women[’']s)\s*$/i, "") + .replace(/^Men[’']s\s+/i, "") + .replace(/^Women[’']s\s+/i, "") + .replace(/^Ladies[’']?\s+/i, "") + .trim(); + if (brand) { + next = next + .replace(new RegExp(`^${brand.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\s+`, "i"), "") + .trim(); + } + return next || title || null; +} + +function mergeUrlMetadataIntoCandidate( + candidate: AuraCandidateItem, + metadata: UrlCandidateMetadata, +): AuraCandidateItem { + const amount = + "salePrice" in metadata && typeof metadata.salePrice === "number" + ? metadata.salePrice + : "priceAmount" in metadata + ? metadata.priceAmount ?? null + : null; + const currency = + "priceCurrency" in metadata + ? metadata.priceCurrency ?? metadata.currency ?? null + : null; + const priceFields = + typeof amount === "number" && Number.isFinite(amount) + ? { + retailPrice: amount, + purchasePrice: amount, + estimatedValue: amount, + currency, + originalPrice: + "originalPrice" in metadata + ? metadata.originalPrice ?? amount + : amount, + salePrice: + "salePrice" in metadata + ? metadata.salePrice ?? null + : null, + originalCurrency: currency, + priceSource: "product_link" as const, + priceDisplay: + "priceDisplay" in metadata + ? metadata.priceDisplay ?? metadata.price ?? null + : null, + price: amount, + } + : {}; + return { + ...candidate, + candidateId: `url-${Date.now()}-0`, + imageUrls: + "imageUrls" in metadata && Array.isArray(metadata.imageUrls) && metadata.imageUrls.length + ? metadata.imageUrls + : metadata.imageUrl + ? [metadata.imageUrl] + : candidate.imageUrls, + title: metadata.title ?? candidate.title, + category: ("category" in metadata ? metadata.category ?? null : null) ?? candidate.category, + subCategory: ("subCategory" in metadata ? metadata.subCategory ?? null : null) ?? candidate.subCategory, + color: ("displayColor" in metadata ? metadata.displayColor ?? metadata.color ?? null : null) ?? candidate.color, + displayColor: "displayColor" in metadata ? metadata.displayColor ?? metadata.color ?? null : null, + displayColors: "displayColors" in metadata ? metadata.displayColors ?? [] : [], + brand: ("brand" in metadata ? metadata.brand ?? null : null) ?? candidate.brand, + material: ("material" in metadata ? metadata.material ?? null : null) ?? candidate.material, + materials: "materials" in metadata ? metadata.materials ?? [] : [], + fit: ("fit" in metadata ? metadata.fit ?? null : null) ?? candidate.fit, + sleeveLength: "sleeveLength" in metadata ? metadata.sleeveLength ?? null : null, + collar: "collar" in metadata ? metadata.collar ?? null : null, + length: "length" in metadata ? metadata.length ?? null : null, + pattern: ("pattern" in metadata ? metadata.pattern ?? null : null) ?? candidate.pattern, + sizeOptions: "sizeOptions" in metadata ? metadata.sizeOptions ?? [] : [], + availableSizes: "availableSizes" in metadata ? metadata.availableSizes ?? metadata.sizeOptions ?? [] : [], + careInstructions: "careInstructions" in metadata ? metadata.careInstructions ?? [] : [], + productDescription: "productDescription" in metadata ? metadata.productDescription ?? metadata.description ?? null : metadata.description ?? null, + graphicText: "graphicText" in metadata ? metadata.graphicText ?? null : null, + motif: "motif" in metadata ? metadata.motif ?? null : null, + collaborationName: "collaborationName" in metadata ? metadata.collaborationName ?? null : null, + confidence: candidate.confidence ?? ("confidence" in metadata ? metadata.confidence ?? null : null), + ...priceFields, + productUrl: metadata.sourceUrl, + sourceType: "link", + sourceUrl: metadata.sourceUrl, + status: "status" in metadata && metadata.status === "needs_review" + ? "needs_review" + : "awaiting_confirmation", + }; +} + +export async function buildUrlCandidatePreview(params: { + client: OpenAI; + uid?: string | null; + metadata: UrlCandidateMetadata; +}) { + const rawImageUrls = [ + ...("imageUrls" in params.metadata && Array.isArray(params.metadata.imageUrls) + ? params.metadata.imageUrls + : []), + params.metadata.imageUrl, + ].filter((url): url is string => !!url); + logger.info("[LINK_IMAGE_SOURCE] URL metadata image source", { + uidHash: params.uid ? redactUid(params.uid) : null, + sourceUrl: redactUrlForLogs(params.metadata.sourceUrl), + rawImageCount: rawImageUrls.length, + rawImageUrls: rawImageUrls.slice(0, 6).map((url) => redactUrlForLogs(url)), + }); + const rankedImages = await rankProductLinkImages({ + client: params.client, + imageUrls: rawImageUrls, + title: params.metadata.title, + description: params.metadata.description, + sourceUrl: params.metadata.sourceUrl, + }); + const rankedImageUrls = rankedImages.map((image) => image.url); + const primaryImageUrl = rankedImageUrls[0] ?? null; + const titleHints = productCategoryHintsFromText(params.metadata.title, params.metadata.description); + logger.info("[LINK_EXTRACTION_TARGET]", { + uidHash: params.uid ? redactUid(params.uid) : null, + sourceUrl: redactUrlForLogs(params.metadata.sourceUrl), + titleLength: String(params.metadata.title ?? "").length, + descriptionLength: String(params.metadata.description ?? "").length, + hintedCategory: titleHints.category, + hintedSubCategory: titleHints.subCategory, + chosenImage: redactUrlForLogs(primaryImageUrl), + chosenImageReasons: rankedImages[0]?.reasons ?? [], + rawImageCount: rawImageUrls.length, + rankedImageCount: rankedImageUrls.length, + }); + + let candidates = primaryImageUrl + ? await extractImageCandidates({ + client: params.client, + imageGroups: [[primaryImageUrl]], + }) + : []; + if (!candidates.length && primaryImageUrl) { + candidates = fallbackImageCandidates([[primaryImageUrl]]); + } + if (!candidates.length) { + candidates = [ + { + candidateId: `url-${Date.now()}-0`, + imageUrls: [], + title: params.metadata.title, + category: null, + subCategory: null, + color: null, + brand: null, + material: null, + fit: null, + pattern: null, + confidence: null, + sourceType: "link", + sourceUrl: params.metadata.sourceUrl, + status: "awaiting_confirmation", + }, + ]; + } + const candidate = mergeUrlMetadataIntoCandidate(candidates[0], params.metadata); + const needsReview = "status" in params.metadata && params.metadata.status === "needs_review"; + const effectiveTitle = params.metadata.title ?? candidate.title; + const effectiveTitleHints = productCategoryHintsFromText(effectiveTitle, params.metadata.description); + candidate.imageUrls = rankedImageUrls.length ? rankedImageUrls : candidate.imageUrls; + candidate.primaryImageUrl = candidate.imageUrls[0] ?? null; + candidate.secondaryImageUrls = candidate.imageUrls.slice(1); + candidate.imageSourceReason = candidate.primaryImageUrl + ? "product_link_ranked_image" + : "product_link_no_usable_image"; + logger.info("[LINK_IMAGE_REVIEW_SET]", { + uidHash: params.uid ? redactUid(params.uid) : null, + sourceUrl: redactUrlForLogs(params.metadata.sourceUrl), + candidateId: candidate.candidateId, + primaryImageUrl: redactUrlForLogs(candidate.primaryImageUrl), + imageUrls: candidate.imageUrls.slice(0, 8).map((url) => redactUrlForLogs(url)), + secondaryImageUrls: candidate.secondaryImageUrls.slice(0, 8).map((url) => redactUrlForLogs(url)), + }); + if (effectiveTitleHints.category) { + candidate.category = effectiveTitleHints.category; + candidate.subCategory = effectiveTitleHints.subCategory ?? candidate.subCategory; + candidate.brand = candidate.brand && !/^no brand$/i.test(candidate.brand) + ? candidate.brand + : brandFromSourceUrl(params.metadata.sourceUrl); + } else if (!needsReview) { + candidate.category = titleHints.category ?? candidate.category; + candidate.subCategory = titleHints.subCategory ?? candidate.subCategory; + candidate.brand = candidate.brand && !/^no brand$/i.test(candidate.brand) + ? candidate.brand + : brandFromSourceUrl(params.metadata.sourceUrl); + } else { + candidate.category = candidate.category ?? null; + candidate.subCategory = candidate.subCategory ?? null; + candidate.brand = candidate.brand ?? null; + } + candidate.title = cleanProductTitle(params.metadata.title ?? candidate.title, candidate.brand); + logger.info("[LINK_BRAND_NORMALIZE] candidate brand/title normalized", { + uidHash: params.uid ? redactUid(params.uid) : null, + sourceUrl: redactUrlForLogs(params.metadata.sourceUrl), + rawTitleLength: String(params.metadata.title ?? "").length, + hasSavedBrand: !!candidate.brand, + savedTitleLength: String(candidate.title ?? "").length, + }); + logger.info("[AURA_URL_TO_CANDIDATE] converted URL metadata to candidate", { + uidHash: params.uid ? redactUid(params.uid) : null, + sourceUrl: redactUrlForLogs(params.metadata.sourceUrl), + hasImageUrl: !!params.metadata.imageUrl, + titleLength: String(params.metadata.title ?? "").length, + candidateId: candidate.candidateId, + category: candidate.category, + subCategory: candidate.subCategory, + color: candidate.color, + brand: candidate.brand, + }); + const data = candidatePreviewResponse([candidate]); + if (needsReview) { + data.reply = "I couldn’t fully read this item — review before adding"; + } + return { + data, + candidate, + rawImageUrls, + rankedImages, + rankedImageUrls, + }; +} diff --git a/functions/src/shared/buildAuraContext.ts b/functions/src/shared/buildAuraContext.ts index 110509e..7f9a6a2 100644 --- a/functions/src/shared/buildAuraContext.ts +++ b/functions/src/shared/buildAuraContext.ts @@ -1,3 +1,10 @@ +import type {CompactAuraMemoryContext} from "../../../shared/auraMemory"; +import { + detectWardrobeGaps, + type WardrobeCategoryCounts, +} from "./detectWardrobeGaps"; +import type {AuraUserProfile} from "./loadAuraUserProfile"; + type WardrobeItem = { id: string; name?: string; @@ -9,9 +16,30 @@ type WardrobeItem = { aiColorLabel?: string; colors?: string[]; aiColors?: string[]; + primaryColor?: string | null; + displayColor?: string | null; + displayColors?: string[] | null; + material?: string | null; + materials?: string[] | null; + pattern?: string | null; + fit?: string | null; + sleeveLength?: string | null; + neckline?: string | null; + collar?: string | null; + closure?: string | null; + length?: string | null; + occasionTags?: string[] | null; + seasonTags?: string[] | null; + style?: string | null; status?: string; inLaundry?: boolean; isDraft?: boolean; + photoUrl?: string | null; + images?: { + originalUrl?: string | null; + cleanedUrl?: string | null; + isPrimary?: boolean; + }[] | null; }; type AuraContextArgs = { @@ -22,10 +50,13 @@ type AuraContextArgs = { } | null; occasion?: string | null; selectedDate?: string | null; + memory?: CompactAuraMemoryContext | null; + userProfile?: AuraUserProfile | null; }; type AuraCategory = | "tops" + | "one_piece" | "outerwear" | "bottoms" | "footwear" @@ -45,6 +76,112 @@ function normalizeToken(value?: string | null): string { .trim(); } +const CANONICAL_AURA_CATEGORY_ALIASES: Record, readonly string[]> = { + // Keep this alias table in sync with src/lib/items.ts. + one_piece: ["one piece", "dress", "jumpsuit", "romper", "set", "matching set"], + tops: [ + "top", + "tops", + "tshirt", + "t shirt", + "t-shirt", + "tee", + "shirt", + "polo", + "sweater", + "sweatshirt", + "blouse", + "crop top", + "tank", + "tank top", + "kurta", + ], + outerwear: [ + "outerwear", + "jacket", + "jackets", + "hoodie", + "hoodies", + "coat", + "coats", + "blazer", + "blazers", + "overshirt", + "overshirts", + "cardigan", + "cardigans", + "shacket", + "trench", + "parka", + "bomber", + "layer", + "layers", + ], + bottoms: [ + "bottom", + "bottoms", + "pants", + "jeans", + "trousers", + "shorts", + "cargo", + "cargos", + "chinos", + "joggers", + "trackpants", + "track pants", + ], + footwear: [ + "footwear", + "shoes", + "shoe", + "sneaker", + "sneakers", + "boot", + "boots", + "sandal", + "sandals", + "slide", + "slides", + "loafer", + "loafers", + "heel", + "heels", + "formal shoe", + "formal shoes", + "derby", + "derbies", + "oxford", + "oxfords", + "chelsea boot", + "chelsea boots", + ], + accessories: [ + "accessories", + "accessory", + "watch", + "bag", + "handbag", + "tote", + "tote bag", + "crossbody", + "belt", + "perfume", + "jewellery", + "jewelry", + "cap", + "hat", + "sunglasses", + "glasses", + "necklace", + "bracelet", + "ring", + "earrings", + "scarf", + "socks", + ], +}; + function pickColor(item: WardrobeItem): string { return ( String(item.aiColorLabel ?? "").trim() || @@ -54,97 +191,82 @@ function pickColor(item: WardrobeItem): string { ); } -export function mapCategory(raw?: string | null): AuraCategory { - const value = normalizeToken(raw); +function nonEmptyList(value?: string[]) { + return Array.isArray(value) && value.length ? value : null; +} - if ( - [ - "top", - "tops", - "tshirt", - "t-shirt", - "tee", - "shirt", - "hoodie", - "sweater", - "polo", - "sweatshirt", - "tank", - "overshirt", - "kurta", - ].includes(value) - ) { - return "tops"; - } +function compactStringList(values?: string[] | null) { + if (!Array.isArray(values)) return ""; + return values + .map((value) => String(value ?? "").trim()) + .filter(Boolean) + .slice(0, 5) + .join(", "); +} - if (["outerwear", "jacket", "coat", "blazer", "overshirt"].includes(value)) { - return "outerwear"; - } +function nonEmptyRecord(value?: T | null) { + return value && Object.keys(value).length ? value : null; +} - if ( - [ - "bottom", - "bottoms", - "pants", - "jeans", - "trousers", - "shorts", - "cargo", - "cargos", - "chinos", - "joggers", - "trackpants", - ].includes(value) - ) { - return "bottoms"; - } +function compactUserPreferences(userProfile?: AuraUserProfile | null) { + if (!userProfile) return null; - if ( - [ - "footwear", - "shoes", - "shoe", - "sneaker", - "sneakers", - "boot", - "boots", - "sandal", - "sandals", - "slide", - "slides", - "loafer", - "loafers", - "formal shoe", - "formal shoes", - "derby", - "derbies", - "oxford", - "oxfords", - "chelsea boot", - "chelsea boots", - ].includes(value) - ) { - return "footwear"; - } + const userPreferences = { + ...(userProfile.displayName ? {displayName: userProfile.displayName} : {}), + ...(userProfile.name ? {name: userProfile.name} : {}), + ...(userProfile.firstName ? {firstName: userProfile.firstName} : {}), + ...(userProfile.wardrobeMode ? {wardrobeMode: userProfile.wardrobeMode} : {}), + ...(nonEmptyList(userProfile.selectedCategories) + ? {selectedCategories: userProfile.selectedCategories} + : {}), + ...(nonEmptyList(userProfile.styleAesthetics) + ? {styleAesthetics: userProfile.styleAesthetics} + : {}), + ...(nonEmptyList(userProfile.favoriteColors) + ? {favoriteColors: userProfile.favoriteColors} + : {}), + ...(nonEmptyList(userProfile.avoidedColors) + ? {avoidedColors: userProfile.avoidedColors} + : {}), + ...(userProfile.preferredFit ? {preferredFit: userProfile.preferredFit} : {}), + ...(userProfile.preferredFit ? {fitPreference: userProfile.preferredFit} : {}), + ...(userProfile.budgetPreference ? {budgetPreference: userProfile.budgetPreference} : {}), + ...(nonEmptyRecord(userProfile.fitPreferences) + ? {fitPreferences: userProfile.fitPreferences} + : {}), + ...(nonEmptyRecord(userProfile.defaultSizes) + ? {defaultSizes: userProfile.defaultSizes} + : {}), + ...(nonEmptyRecord(userProfile.stylePreferences) + ? {stylePreferences: userProfile.stylePreferences} + : {}), + ...(nonEmptyList(userProfile.accessoryPreferences) + ? {accessoryPreferences: userProfile.accessoryPreferences} + : {}), + ...(nonEmptyList(userProfile.occasionPriority) + ? {occasionPriority: userProfile.occasionPriority} + : {}), + ...(nonEmptyList(userProfile.occasionPriority) + ? {occasions: userProfile.occasionPriority} + : {}), + ...(nonEmptyList(userProfile.goals) ? {goals: userProfile.goals} : {}), + ...(nonEmptyRecord(userProfile.closetPreferences) + ? {closetPreferences: userProfile.closetPreferences} + : {}), + }; - if ( - [ - "accessories", - "accessory", - "watch", - "bag", - "belt", - "perfume", - "jewellery", - "jewelry", - "cap", - "hat", - "sunglasses", - ].includes(value) - ) { - return "accessories"; - } + return Object.keys(userPreferences).length ? userPreferences : null; +} +export function mapCategory(raw?: string | null): AuraCategory { + const value = normalizeToken(raw); + if (!value) return "other"; + for (const [category, aliases] of Object.entries(CANONICAL_AURA_CATEGORY_ALIASES) as [ + Exclude, + readonly string[], + ][]) { + if (aliases.includes(value)) return category; + } return "other"; } @@ -153,7 +275,13 @@ export function buildAuraContext({ weather, occasion, selectedDate, + memory, + userProfile, }: AuraContextArgs) { + const DEBUG_AURA_SPARSE = + process.env.FUNCTIONS_EMULATOR === "true" || process.env.NODE_ENV !== "production"; + const DEBUG_AURA_CONTEXT = + process.env.DEBUG_AURA_CONTEXT === "1" || process.env.DEBUG_AURA_CONTEXT === "true"; const excluded = { drafts: [] as Record[], laundry: [] as Record[], @@ -161,6 +289,7 @@ export function buildAuraContext({ }; const wardrobe = { tops: [] as Record[], + one_piece: [] as Record[], outerwear: [] as Record[], bottoms: [] as Record[], footwear: [] as Record[], @@ -176,7 +305,35 @@ export function buildAuraContext({ subCategory: String(item.subCategory ?? "").trim(), type: String(item.type ?? "").trim(), color: pickColor(item), + colors: compactStringList([ + ...(item.displayColors ?? []), + ...(item.aiColors ?? []), + ...(item.colors ?? []), + ]), + primaryColor: String(item.primaryColor ?? "").trim(), + displayColor: String(item.displayColor ?? "").trim(), + material: + String(item.material ?? "").trim() || compactStringList(item.materials), + pattern: String(item.pattern ?? "").trim(), + fit: String(item.fit ?? "").trim(), + sleeveLength: String(item.sleeveLength ?? "").trim(), + neckline: String(item.neckline ?? item.collar ?? "").trim(), + closure: String(item.closure ?? "").trim(), + length: String(item.length ?? "").trim(), + occasionTags: compactStringList(item.occasionTags), + seasonTags: compactStringList(item.seasonTags), + style: String(item.style ?? "").trim(), status: String(item.status ?? "").trim(), + primaryImageUrl: + String( + item.images?.find((image) => image?.isPrimary)?.cleanedUrl ?? + item.images?.find((image) => image?.isPrimary)?.originalUrl ?? + item.images?.[0]?.cleanedUrl ?? + item.images?.[0]?.originalUrl ?? + item.photoUrl ?? + "", + ).trim(), + imageCount: String(Array.isArray(item.images) ? item.images.length : 0), }; if (item.isDraft) { @@ -203,6 +360,35 @@ export function buildAuraContext({ wardrobe[category].push(summary); }); + const categoryCounts: WardrobeCategoryCounts = { + tops: wardrobe.tops.length, + outerwear: wardrobe.outerwear.length, + bottoms: wardrobe.bottoms.length, + footwear: wardrobe.footwear.length, + accessories: wardrobe.accessories.length, + }; + const totalItemCount = + categoryCounts.tops + + wardrobe.one_piece.length + + categoryCounts.outerwear + + categoryCounts.bottoms + + categoryCounts.footwear + + categoryCounts.accessories; + const isSparseWardrobe = totalItemCount < 15; + const wardrobeGaps = detectWardrobeGaps(categoryCounts); + + if (DEBUG_AURA_SPARSE) { + console.log("[AURA_SPARSE_CONTEXT]", { + isSparseWardrobe, + categoryCounts, + detectedGaps: { + missingCore: wardrobeGaps.missingCore.map((gap) => gap.label), + weakAreas: wardrobeGaps.weakAreas.map((gap) => gap.label), + }, + suggestions: wardrobeGaps.suggestions.map((suggestion) => suggestion.label), + }); + } + return { selectedDate: selectedDate ?? null, occasion: occasion ?? null, @@ -211,46 +397,67 @@ export function buildAuraContext({ condition: weather?.condition ?? null, }, wardrobe, - counts: { - tops: wardrobe.tops.length, - outerwear: wardrobe.outerwear.length, - bottoms: wardrobe.bottoms.length, - footwear: wardrobe.footwear.length, - accessories: wardrobe.accessories.length, - }, + counts: categoryCounts, + categoryCounts, + isSparseWardrobe, + wardrobeGaps, inventory: { - total: - wardrobe.tops.length + - wardrobe.outerwear.length + - wardrobe.bottoms.length + - wardrobe.footwear.length + - wardrobe.accessories.length, - hasAnyItems: - wardrobe.tops.length + - wardrobe.outerwear.length + - wardrobe.bottoms.length + - wardrobe.footwear.length + - wardrobe.accessories.length > - 0, + total: totalItemCount, + totalItemCount, + hasAnyItems: totalItemCount > 0, + isSparseWardrobe, missingCoreCategories: [ ...(wardrobe.tops.length ? [] : ["tops"]), ...(wardrobe.bottoms.length ? [] : ["bottoms"]), ...(wardrobe.footwear.length ? [] : ["footwear"]), ], }, - wardrobeDebug: { - footwearAvailable: wardrobe.footwear, - bottomsAvailable: wardrobe.bottoms, - accessoriesAvailable: wardrobe.accessories, - excludedFootwear: [...excluded.drafts, ...excluded.laundry, ...excluded.uncategorized].filter( - (item) => mapCategory(item.subCategory || item.category || item.type) === "footwear" - ), - }, + ...(DEBUG_AURA_CONTEXT + ? { + wardrobeDebug: { + footwearAvailable: wardrobe.footwear, + bottomsAvailable: wardrobe.bottoms, + accessoriesAvailable: wardrobe.accessories, + excludedFootwear: [...excluded.drafts, ...excluded.laundry, ...excluded.uncategorized].filter( + (item) => mapCategory(item.subCategory || item.category || item.type) === "footwear" + ), + }, + } + : {}), stylingPolicy: { preferOwnedClosetItems: true, preferOwnedFootwear: true, preferOwnedBottoms: true, suggestMissingPiecesOnlyWhenNoReasonableOwnedOptionExists: true, }, + stylingIntelligenceV1: { + ruleBased: true, + engines: ["fit", "color", "style_identity"], + scoreRange: "0-100", + guidance: + "AURA may use wardrobe metadata for styling, but final deterministic styling scores are added after look selection.", + supportedColorFamilies: [ + "black", + "white", + "gray", + "navy", + "blue", + "brown", + "beige", + "cream", + "green", + "red", + "pink", + "purple", + "yellow", + "orange", + "metallic", + "multicolor", + "unknown", + ], + }, + userPreferences: compactUserPreferences(userProfile), + preferenceContext: memory ?? null, + stylistBrief: memory?.stylistBrief ?? "", }; } diff --git a/functions/src/shared/detectWardrobeGaps.ts b/functions/src/shared/detectWardrobeGaps.ts new file mode 100644 index 0000000..a991316 --- /dev/null +++ b/functions/src/shared/detectWardrobeGaps.ts @@ -0,0 +1,140 @@ +export type WardrobeCategoryCounts = { + tops: number; + bottoms: number; + footwear: number; + outerwear: number; + accessories: number; +}; + +export type WardrobeGapSuggestion = { + label: string; + reason: string; + searchQuery?: string; +}; + +export type WardrobeGap = { + key: string; + label: string; + reason: string; + severity: "critical" | "notice" | "soft"; + searchQuery?: string; +}; + +export type WardrobeGapDetection = { + missingCore: WardrobeGap[]; + weakAreas: WardrobeGap[]; + suggestions: WardrobeGapSuggestion[]; +}; + +function dedupeSuggestions(suggestions: WardrobeGapSuggestion[]) { + const seen = new Set(); + return suggestions.filter((suggestion) => { + const key = suggestion.label.trim().toLowerCase(); + if (!key || seen.has(key)) return false; + seen.add(key); + return true; + }); +} + +export function detectWardrobeGaps( + categoryCounts: WardrobeCategoryCounts +): WardrobeGapDetection { + const missingCore: WardrobeGap[] = []; + const weakAreas: WardrobeGap[] = []; + const suggestions: WardrobeGapSuggestion[] = []; + + if (categoryCounts.tops === 0) { + missingCore.push({ + key: "tops", + label: "Tops", + reason: "No tops means AURA has almost nothing to build around.", + severity: "critical", + searchQuery: "men's essential tops", + }); + } else if (categoryCounts.tops === 1) { + weakAreas.push({ + key: "top-variety", + label: "Top variety", + reason: "Only one top keeps outfit variation tight.", + severity: "notice", + searchQuery: "men's versatile tops", + }); + } + + if (categoryCounts.bottoms === 0) { + missingCore.push({ + key: "bottoms", + label: "Bottoms", + reason: "No bottoms is a hard wardrobe gap for outfit generation.", + severity: "critical", + searchQuery: "men's versatile pants", + }); + } else if (categoryCounts.bottoms === 1) { + weakAreas.push({ + key: "bottom-variety", + label: "Bottom variety", + reason: "One bottom limits how many distinct outfits AURA can build.", + severity: "notice", + searchQuery: "men's everyday pants", + }); + } + + if (categoryCounts.outerwear === 0) { + missingCore.push({ + key: "outerwear", + label: "Outerwear", + reason: "A layer adds polish and makes simple outfits feel more intentional.", + severity: "notice", + searchQuery: "lightweight denim jacket or overshirt", + }); + suggestions.push({ + label: "Lightweight jacket", + reason: "A lightweight jacket would instantly level up your outfits.", + searchQuery: "lightweight denim jacket or overshirt", + }); + } + + if (categoryCounts.footwear === 0) { + missingCore.push({ + key: "footwear", + label: "Shoes", + reason: "Without shoes in the closet, every outfit has to lean on a suggestion.", + severity: "critical", + searchQuery: "versatile men's sneakers", + }); + } else if (categoryCounts.footwear === 1) { + missingCore.push({ + key: "footwear-variety", + label: "Second pair of shoes", + reason: "A second pair opens up far more outfit range.", + severity: "notice", + searchQuery: "white sneakers versatile men's shoes", + }); + suggestions.push({ + label: "Second pair of shoes", + reason: "Adding a second pair of shoes would give you more versatility.", + searchQuery: "white sneakers versatile men's shoes", + }); + } + + if (categoryCounts.accessories === 0) { + weakAreas.push({ + key: "accessories", + label: "Accessories", + reason: "No accessories makes it harder to finish a look cleanly.", + severity: "soft", + searchQuery: "minimal men's watch", + }); + suggestions.push({ + label: "Minimal watch", + reason: "A watch or simple accessory would make your outfits feel more complete.", + searchQuery: "minimal men's watch", + }); + } + + return { + missingCore, + weakAreas, + suggestions: dedupeSuggestions(suggestions).slice(0, 3), + }; +} diff --git a/functions/src/shared/env.ts b/functions/src/shared/env.ts new file mode 100644 index 0000000..071e9ff --- /dev/null +++ b/functions/src/shared/env.ts @@ -0,0 +1,13 @@ +import { HttpsError } from "firebase-functions/v2/https"; + +export function requireOpenAiApiKey() { + const apiKey = String(process.env.OPENAI_API_KEY ?? "").trim(); + if (!apiKey) { + throw new HttpsError( + "failed-precondition", + "OPENAI_API_KEY is not configured for Firebase Functions.", + { code: "missing_openai_api_key" }, + ); + } + return apiKey; +} diff --git a/functions/src/shared/laundryIntent.ts b/functions/src/shared/laundryIntent.ts new file mode 100644 index 0000000..5097ff2 --- /dev/null +++ b/functions/src/shared/laundryIntent.ts @@ -0,0 +1,187 @@ +import { FieldValue, Firestore } from "firebase-admin/firestore"; + +export type LaundryStatus = "clean" | "needs_wash" | "in_laundry"; + +type WardrobeItem = Record & { id: string }; + +const LAUNDRY_RE = /\b(laundry|wash|washed|washing|dirty|clean|cleaned|needs?\s+wash(?:ing)?|in\s+the\s+wash|in\s+laundry)\b/i; +const CLEAN_RE = /\b(clean now|washed|cleaned|done washing|fresh|dry(?:er)? done)\b/i; +const IN_LAUNDRY_RE = /\b(in laundry|in the laundry|in the wash|washing|washer|laundry basket|put .* laundry|put .* wash)\b/i; +const NEEDS_WASH_RE = /\b(dirty|needs?\s+wash(?:ing)?|need to wash|needs?\s+clean(?:ing)?|worn|sweaty)\b/i; + +const STOP_WORDS = new Set([ + "my", + "the", + "a", + "an", + "these", + "those", + "this", + "that", + "clothes", + "items", + "laundry", + "wash", + "washed", + "washing", + "dirty", + "clean", + "cleaned", + "now", + "put", + "mark", + "move", + "to", + "in", + "as", + "are", + "is", + "need", + "needs", +]); + +function tokens(value: string) { + return String(value ?? "") + .toLowerCase() + .replace(/[^a-z0-9\s-]/g, " ") + .split(/\s+/) + .filter((token) => token.length > 1 && !STOP_WORDS.has(token)); +} + +function itemLabel(item: WardrobeItem) { + return String( + item.name || + [item.primaryColor, item.colorLabel, item.brand, item.subCategory, item.category] + .filter(Boolean) + .join(" ") + ).trim(); +} + +function itemTokens(item: WardrobeItem) { + return tokens( + [ + item.id, + item.name, + item.brand, + item.category, + item.subCategory, + item.primaryColor, + item.colorLabel, + Array.isArray(item.colors) ? item.colors.join(" ") : "", + ] + .filter(Boolean) + .join(" ") + ); +} + +function inferStatus(message: string): LaundryStatus | null { + if (!LAUNDRY_RE.test(message)) return null; + if (CLEAN_RE.test(message)) return "clean"; + if (IN_LAUNDRY_RE.test(message)) return "in_laundry"; + if (NEEDS_WASH_RE.test(message)) return "needs_wash"; + return null; +} + +function legacyStatus(status: LaundryStatus) { + if (status === "clean") return "AVAILABLE"; + if (status === "in_laundry") return "IN_LAUNDRY"; + return "WORN"; +} + +function rankItems(message: string, items: WardrobeItem[]) { + const messageTokens = tokens(message); + const query = new Set(messageTokens); + return items + .map((item) => { + const fields = itemTokens(item); + const overlap = fields.filter((token) => query.has(token)); + const phraseBoost = itemLabel(item).toLowerCase() && message.toLowerCase().includes(itemLabel(item).toLowerCase()) ? 3 : 0; + return { + item, + score: overlap.length + phraseBoost, + overlap, + }; + }) + .filter((match) => match.score > 0) + .sort((left, right) => right.score - left.score); +} + +function responseBase(reply: string) { + return { + presentation: "chat" as const, + title: "Laundry", + reply, + reason: "", + outfitItems: [], + ownedPieces: [], + recommendedAdditions: [], + swapSuggestion: "", + missingPieces: [], + upgradeSuggestions: [], + upgradeSuggestionItems: [], + chips: [], + look: null, + lookOptions: [], + }; +} + +function confirmationResponse(status: LaundryStatus, matches: ReturnType, reply: string) { + return { + ...responseBase(reply), + presentation: "laundry_confirmation" as const, + laundryAction: { + targetStatus: status, + matches: matches.slice(0, 6).map(({ item }) => ({ + itemId: item.id, + label: itemLabel(item) || "Wardrobe item", + subtitle: [item.brand, item.primaryColor || item.colorLabel, item.category].filter(Boolean).join(" · "), + })), + }, + }; +} + +export async function handleLaundryIntent(params: { + db: Firestore; + uid: string; + message: string; + items: WardrobeItem[]; +}) { + const targetStatus = inferStatus(params.message); + if (!targetStatus) return null; + + const matches = rankItems(params.message, params.items); + if (!matches.length) { + return responseBase("I could not find a matching closet item. Which piece should I update?"); + } + + const best = matches[0]; + const second = matches[1]; + const highConfidence = best.score >= 2 && (!second || best.score >= second.score + 2); + + if (!highConfidence) { + const label = targetStatus === "clean" ? "clean" : targetStatus === "in_laundry" ? "in laundry" : "needs wash"; + return confirmationResponse(targetStatus, matches, `I found ${matches.length === 1 ? "one possible match" : `${Math.min(matches.length, 6)} possible matches`}. Which one should be ${label}?`); + } + + await params.db.collection("users").doc(params.uid).collection("items").doc(best.item.id).update({ + status: legacyStatus(targetStatus), + laundryStatus: targetStatus, + laundryUpdatedAt: FieldValue.serverTimestamp(), + ...(targetStatus === "clean" + ? { + wearCountSinceWash: 0, + lastWashedDate: FieldValue.serverTimestamp(), + lastWashedAt: FieldValue.serverTimestamp(), + } + : {}), + }); + + const label = itemLabel(best.item).toLowerCase(); + const actionText = + targetStatus === "clean" + ? "marked as clean" + : targetStatus === "in_laundry" + ? "moved to laundry" + : "marked as needs wash"; + return responseBase(`Done — I ${actionText} ${label ? `your ${label}` : "that item"}.`); +} diff --git a/functions/src/shared/loadAuraUserProfile.ts b/functions/src/shared/loadAuraUserProfile.ts index 5978d5c..9b580b0 100644 --- a/functions/src/shared/loadAuraUserProfile.ts +++ b/functions/src/shared/loadAuraUserProfile.ts @@ -1,16 +1,242 @@ import { getFirestore } from "firebase-admin/firestore"; const db = getFirestore(); +const LIST_LIMIT = 10; + +type CompactStringMap = Record; +type AuraUserProfileListKey = + | "selectedCategories" + | "styleAesthetics" + | "favoriteColors" + | "avoidedColors" + | "accessoryPreferences" + | "occasionPriority" + | "goals"; + +export type AuraUserProfile = { + displayName?: string; + name?: string; + firstName?: string; + region?: string; + wardrobeMode?: "masculine" | "feminine" | "neutral" | "mixed" | "custom"; + selectedCategories?: string[]; + styleAesthetics?: string[]; + favoriteColors?: string[]; + avoidedColors?: string[]; + accessoryPreferences?: string[]; + occasionPriority?: string[]; + goals?: string[]; + preferredFit?: "slim" | "regular" | "relaxed" | "oversized"; + budgetPreference?: "budget" | "mid" | "premium"; + defaultSizes?: CompactStringMap; + fitPreferences?: CompactStringMap; + stylePreferences?: { + preferredStyles?: string[]; + favoriteColors?: string[]; + avoidedColors?: string[]; + preferredBrands?: string[]; + }; + closetPreferences?: { + prioritizeUnderused?: boolean; + hideLaundryByDefault?: boolean; + defaultSort?: string; + }; +}; function cleanString(value: unknown) { const text = String(value ?? "").trim(); return text || null; } -export async function loadAuraUserProfile(uid: string) { +function readRecord(value: unknown) { + return value && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : {}; +} + +function cleanStringList(value: unknown, limit = LIST_LIMIT) { + if (!Array.isArray(value)) return []; + const seen = new Set(); + const values: string[] = []; + for (const entry of value) { + const text = cleanString(entry); + if (!text) continue; + const key = text.toLowerCase(); + if (seen.has(key)) continue; + seen.add(key); + values.push(text); + if (values.length >= limit) break; + } + return values; +} + +function cleanWardrobeMode(value: unknown) { + const next = String(value ?? "").trim(); + if ( + next === "masculine" || + next === "feminine" || + next === "neutral" || + next === "mixed" || + next === "custom" + ) { + return next; + } + return null; +} + +function cleanPreferredFit(value: unknown) { + const next = String(value ?? "").trim(); + if ( + next === "slim" || + next === "regular" || + next === "relaxed" || + next === "oversized" + ) { + return next; + } + return null; +} + +function cleanBudgetPreference(value: unknown) { + const next = String(value ?? "").trim(); + if (next === "budget" || next === "mid" || next === "premium") { + return next; + } + return null; +} + +function cleanEnum(value: unknown, allowed: readonly string[]) { + const next = cleanString(value); + return next && allowed.includes(next) ? next : null; +} + +function cleanFitPreferences(value: unknown) { + const fitPreferences = readRecord(value); + const next: CompactStringMap = {}; + const tops = cleanEnum(fitPreferences.tops, ["slim", "regular", "relaxed", "oversized"]); + const outerwear = cleanEnum(fitPreferences.outerwear, ["slim", "regular", "roomy"]); + const bottomsRise = cleanEnum(fitPreferences.bottomsRise, ["low", "mid", "high"]); + const bottomsLeg = cleanEnum(fitPreferences.bottomsLeg, ["skinny", "slim", "straight", "tapered", "wide"]); + const shoes = cleanEnum(fitPreferences.shoes, ["true_to_size", "half_up", "half_down"]); + + if (tops) next.tops = tops; + if (outerwear) next.outerwear = outerwear; + if (bottomsRise) next.bottomsRise = bottomsRise; + if (bottomsLeg) next.bottomsLeg = bottomsLeg; + if (shoes) next.shoes = shoes; + + return Object.keys(next).length ? next : null; +} + +function cleanDefaultSizes(value: unknown) { + const defaultSizes = readRecord(value); + const next: CompactStringMap = {}; + const fields: [string, unknown][] = [ + ["tops", defaultSizes.tops ?? defaultSizes.top], + ["outerwear", defaultSizes.outerwear], + ["hoodie", defaultSizes.hoodie], + ["formalShirt", defaultSizes.formalShirt], + ["bottoms", defaultSizes.bottoms ?? defaultSizes.bottomWaist ?? defaultSizes.bottomsWaist], + ["bottomsLength", defaultSizes.bottomsLength ?? defaultSizes.bottomLength], + ["jeans", defaultSizes.jeans], + ["dresses", defaultSizes.dresses], + ["skirts", defaultSizes.skirts], + ["shoes", defaultSizes.shoes], + ]; + + fields.forEach(([key, value]) => { + const cleaned = cleanString(value); + if (cleaned) next[key] = cleaned; + }); + + return Object.keys(next).length ? next : null; +} + +function cleanClosetPreferences(value: unknown): AuraUserProfile["closetPreferences"] | null { + const closetPreferences = readRecord(value); + const next: NonNullable = {}; + if (closetPreferences.prioritizeUnderused === true) { + next.prioritizeUnderused = true; + } + if (closetPreferences.hideLaundryByDefault === true) { + next.hideLaundryByDefault = true; + } + const defaultSort = cleanString(closetPreferences.defaultSort); + if (defaultSort) next.defaultSort = defaultSort; + return Object.keys(next).length ? next : null; +} + +function sameList(a: string[], b: string[]) { + return a.length === b.length && a.every((value, index) => value === b[index]); +} + +function addStringList(target: AuraUserProfile, key: AuraUserProfileListKey, values: string[]) { + if (values.length) target[key] = values; +} + +export async function loadAuraUserProfile(uid: string): Promise { const snap = await db.collection("users").doc(uid).get(); const data = snap.data() ?? {}; - return { - name: cleanString(data.name), - }; + const profilePreferences = readRecord(data.profilePreferences); + const stylePreferences = readRecord(profilePreferences.stylePreferences); + + const preferredStyles = cleanStringList(stylePreferences.preferredStyles); + const styleAesthetics = cleanStringList(profilePreferences.styleAesthetics); + const effectiveStyleAesthetics = styleAesthetics.length ? styleAesthetics : preferredStyles; + + const nestedFavoriteColors = cleanStringList(stylePreferences.favoriteColors); + const favoriteColors = cleanStringList(profilePreferences.favoriteColors); + const effectiveFavoriteColors = favoriteColors.length ? favoriteColors : nestedFavoriteColors; + + const nestedAvoidedColors = cleanStringList(stylePreferences.avoidedColors); + const avoidedColors = cleanStringList(profilePreferences.avoidedColors); + const effectiveAvoidedColors = avoidedColors.length ? avoidedColors : nestedAvoidedColors; + + const compactStylePreferences: NonNullable = {}; + if (preferredStyles.length && !sameList(preferredStyles, effectiveStyleAesthetics)) { + compactStylePreferences.preferredStyles = preferredStyles; + } + if (nestedFavoriteColors.length && !sameList(nestedFavoriteColors, effectiveFavoriteColors)) { + compactStylePreferences.favoriteColors = nestedFavoriteColors; + } + if (nestedAvoidedColors.length && !sameList(nestedAvoidedColors, effectiveAvoidedColors)) { + compactStylePreferences.avoidedColors = nestedAvoidedColors; + } + const preferredBrands = cleanStringList(stylePreferences.preferredBrands); + if (preferredBrands.length) compactStylePreferences.preferredBrands = preferredBrands; + + const profile: AuraUserProfile = {}; + const name = cleanString(data.name); + const firstName = cleanString(profilePreferences.firstName); + const region = cleanString(profilePreferences.region); + const wardrobeMode = cleanWardrobeMode(profilePreferences.wardrobeMode); + const preferredFit = cleanPreferredFit(profilePreferences.preferredFit); + const defaultSizes = cleanDefaultSizes(profilePreferences.defaultSizes); + const fitPreferences = cleanFitPreferences(profilePreferences.fitPreferences); + const closetPreferences = cleanClosetPreferences(profilePreferences.closetPreferences); + const displayName = cleanString(profilePreferences.displayName ?? data.displayName ?? data.name); + const budgetPreference = cleanBudgetPreference(profilePreferences.budgetPreference); + + if (displayName) profile.displayName = displayName; + if (name) profile.name = name; + if (firstName) profile.firstName = firstName; + if (region) profile.region = region; + if (wardrobeMode) profile.wardrobeMode = wardrobeMode; + addStringList(profile, "selectedCategories", cleanStringList(profilePreferences.selectedCategories)); + addStringList(profile, "styleAesthetics", effectiveStyleAesthetics); + addStringList(profile, "favoriteColors", effectiveFavoriteColors); + addStringList(profile, "avoidedColors", effectiveAvoidedColors); + addStringList(profile, "accessoryPreferences", cleanStringList(profilePreferences.accessoryPreferences)); + addStringList(profile, "occasionPriority", cleanStringList(profilePreferences.occasionPriority)); + addStringList(profile, "goals", cleanStringList(profilePreferences.goals)); + if (preferredFit) profile.preferredFit = preferredFit; + if (budgetPreference) profile.budgetPreference = budgetPreference; + if (defaultSizes) profile.defaultSizes = defaultSizes; + if (fitPreferences) profile.fitPreferences = fitPreferences; + if (Object.keys(compactStylePreferences).length) { + profile.stylePreferences = compactStylePreferences; + } + if (closetPreferences) profile.closetPreferences = closetPreferences; + + return profile; } diff --git a/functions/src/shared/manualOutfitLook.ts b/functions/src/shared/manualOutfitLook.ts new file mode 100644 index 0000000..1877a02 --- /dev/null +++ b/functions/src/shared/manualOutfitLook.ts @@ -0,0 +1,271 @@ +import { + roleForStylingItem, + type StylingItem, +} from "./styling/types"; + +type ManualLookPieceRole = "top" | "bottom" | "shoes" | "outerwear" | "accessory"; + +type ManualLookPiece = { + role: ManualLookPieceRole; + itemName: string; + source: "closet" | "suggested"; + itemId: string | null; + imageUrl: string | null; +}; + +type ManualLook = { + id?: string | null; + lookTitle: string; + vibe: string; + shortExplanation: string; + stylingNote: string; + personalizationLabel?: string; + personalizationNote?: string; + pieces: ManualLookPiece[]; + fromCloset: string[]; + addToComplete: string[]; + alternates: string[]; + actions: ( + "saveLook" | "planForToday" | "likeLook" | "notMyVibe" | "showMoreLikeThis" | "lessLikeThis" | "shopMissingPieces" | "useOnlyMyCloset" | "makeItDressier" + )[]; +}; + +type ManualLookItem = StylingItem & { + id: string; + photoUrl?: string | null; + images?: { + originalUrl?: string | null; + cleanedUrl?: string | null; + isPrimary?: boolean; + }[] | null; + photos?: { + cleanedPhotoUrl?: string | null; + cleanedUrl?: string | null; + primaryUrl?: string | null; + thumbUrl?: string | null; + cleanedThumbUrl?: string | null; + croppedUrl?: string | null; + urls?: string[]; + } | null; +}; + +type ManualLookSourceResponse = { + reply?: string; + recommendedAdditions?: string[]; + missingPieces?: string[]; + upgradeSuggestions?: string[]; + swapSuggestion?: string; +}; + +const MANUAL_OUTFIT_RE = + /\b(improve|refine|fix|polish|style)\b[\s\S]{0,80}\b(manually built outfit|selected pieces|selected closet|from my closet)\b/i; + +function clean(value: unknown) { + return String(value ?? "").replace(/\s+/g, " ").trim(); +} + +function normalized(value: unknown) { + return clean(value).toLowerCase().replace(/[^a-z0-9]+/g, " ").trim(); +} + +function uniqueStrings(values: (string | null | undefined)[], max = 12) { + const seen = new Set(); + const out: string[] = []; + for (const value of values) { + const next = clean(value); + if (!next || seen.has(next)) continue; + seen.add(next); + out.push(next); + if (out.length >= max) break; + } + return out; +} + +function inferRoleFromPromptLine(prompt: string, itemId: string): ManualLookPieceRole | null { + const escapedId = itemId.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const line = prompt + .split(/\r?\n/g) + .find((entry) => new RegExp(`closet item id:\\s*${escapedId}\\b`, "i").test(entry)); + const role = normalized(line?.split(":")[0] ?? ""); + if (role === "top") return "top"; + if (role === "bottom") return "bottom"; + if (role === "footwear" || role === "shoe" || role === "shoes") return "shoes"; + if (role === "outerwear" || role === "layer") return "outerwear"; + if (role === "accessory" || role === "accessories") return "accessory"; + return null; +} + +function roleForItem(item: ManualLookItem, prompt: string): ManualLookPieceRole { + const promptRole = inferRoleFromPromptLine(prompt, item.id); + if (promptRole) return promptRole; + const role = roleForStylingItem(item); + if (role === "bottom") return "bottom"; + if (role === "footwear") return "shoes"; + if (role === "outerwear") return "outerwear"; + if (role === "accessory") return "accessory"; + return "top"; +} + +function itemLabel(item: ManualLookItem) { + return ( + clean(item.name) || + clean(item.subCategory) || + clean(item.type) || + clean(item.category) || + "Wardrobe piece" + ); +} + +function itemImageUrl(item: ManualLookItem) { + return ( + clean(item.images?.find((image) => image?.isPrimary)?.cleanedUrl) || + clean(item.images?.find((image) => image?.isPrimary)?.originalUrl) || + clean(item.images?.[0]?.cleanedUrl) || + clean(item.images?.[0]?.originalUrl) || + clean(item.photos?.cleanedPhotoUrl) || + clean(item.photos?.cleanedUrl) || + clean(item.photos?.primaryUrl) || + clean(item.photos?.cleanedThumbUrl) || + clean(item.photos?.thumbUrl) || + clean(item.photos?.croppedUrl) || + clean(item.photos?.urls?.[0]) || + clean(item.photoUrl) || + null + ); +} + +function selectedItemIdsFromPrompt(prompt: string) { + const ids: string[] = []; + for (const match of prompt.matchAll(/closet item id:\s*([^)\\\n;]+)/gi)) { + ids.push(clean(match[1]).replace(/[),.;]+$/g, "")); + } + const selectedIdsLine = prompt.match(/selected item ids?:\s*([^\n]+)/i)?.[1] ?? ""; + ids.push( + ...selectedIdsLine + .split(/[,|]/g) + .map((value) => clean(value).replace(/[.;]+$/g, "")) + .filter(Boolean), + ); + return uniqueStrings(ids, 12); +} + +function matchesLabel(item: ManualLookItem, label: string) { + const target = normalized(label); + if (target.length < 3) return false; + const candidates = [ + item.name, + item.subCategory, + item.type, + item.category, + item.brand, + ] + .map(normalized) + .filter((value) => value.length >= 3); + return candidates.some((candidate) => candidate === target || candidate.includes(target) || target.includes(candidate)); +} + +function closetItemForSuggestion( + label: string, + items: ManualLookItem[], + selectedIds: Set, +) { + if (!label || label.length > 90) return null; + return items.find((item) => !selectedIds.has(item.id) && matchesLabel(item, label)) ?? null; +} + +function suggestionLabels(response?: ManualLookSourceResponse | null) { + return uniqueStrings([ + ...(response?.recommendedAdditions ?? []), + ...(response?.missingPieces ?? []), + ...(response?.upgradeSuggestions ?? []), + ], 4); +} + +export function isManualOutfitImprovementPrompt(prompt: string) { + return MANUAL_OUTFIT_RE.test(prompt) && selectedItemIdsFromPrompt(prompt).length > 0; +} + +export function buildManualOutfitLookFromPrompt( + prompt: string, + closetItems: ManualLookItem[], + response?: ManualLookSourceResponse | null, +): ManualLook | null { + if (!isManualOutfitImprovementPrompt(prompt)) return null; + const selectedIds = selectedItemIdsFromPrompt(prompt); + const itemsById = new Map(closetItems.map((item) => [item.id, item])); + const selectedItems = selectedIds.flatMap((id) => { + const item = itemsById.get(id); + return item ? [item] : []; + }); + if (!selectedItems.length) return null; + + const selectedPieces = selectedItems.map((item) => ({ + role: roleForItem(item, prompt), + itemName: itemLabel(item), + source: "closet" as const, + itemId: item.id, + imageUrl: itemImageUrl(item), + })); + const selectedRoles = new Set(selectedPieces.map((piece) => piece.role)); + if (!selectedRoles.has("top") || !selectedRoles.has("bottom") || !selectedRoles.has("shoes")) { + return null; + } + + const selectedIdSet = new Set(selectedItems.map((item) => item.id)); + const ownedSuggestions: ManualLookPiece[] = []; + const missingSuggestions: string[] = []; + for (const label of suggestionLabels(response)) { + const closetMatch = closetItemForSuggestion(label, closetItems, selectedIdSet); + if (closetMatch) { + selectedIdSet.add(closetMatch.id); + ownedSuggestions.push({ + role: roleForItem(closetMatch, prompt), + itemName: itemLabel(closetMatch), + source: "closet", + itemId: closetMatch.id, + imageUrl: itemImageUrl(closetMatch), + }); + } else { + missingSuggestions.push(label); + } + } + + const pieces = [...selectedPieces, ...ownedSuggestions]; + const fromCloset = uniqueStrings(pieces.filter((piece) => piece.source === "closet").map((piece) => piece.itemName)); + const addToComplete = uniqueStrings(missingSuggestions, 3); + const top = selectedPieces.find((piece) => piece.role === "top")?.itemName; + const bottom = selectedPieces.find((piece) => piece.role === "bottom")?.itemName; + const shoes = selectedPieces.find((piece) => piece.role === "shoes")?.itemName; + const titleBase = [top, bottom].filter(Boolean).join(" + "); + const reply = clean(response?.reply); + + return { + id: `manual_${selectedIds.join("_").slice(0, 80) || "selected"}`, + lookTitle: titleBase || "Manual Outfit Edit", + vibe: "Closet refinement", + shortExplanation: + reply || + "AURA kept your selected closet pieces as the base and refined the styling around them.", + stylingNote: + clean(response?.swapSuggestion) || + (shoes + ? `Keep the core outfit intact and let ${shoes.toLowerCase()} finish the proportion.` + : "Keep the core outfit intact and adjust only one styling detail."), + personalizationLabel: "From your selection", + personalizationNote: "Built from the closet pieces you selected in Studio.", + pieces, + fromCloset, + addToComplete, + alternates: clean(response?.swapSuggestion) ? [clean(response?.swapSuggestion)] : [], + actions: [ + "saveLook", + "planForToday", + "likeLook", + "notMyVibe", + "showMoreLikeThis", + "lessLikeThis", + "shopMissingPieces", + "makeItDressier", + ], + }; +} diff --git a/functions/src/shared/outfitEngine.ts b/functions/src/shared/outfitEngine.ts index 2d5edf2..3875f8d 100644 --- a/functions/src/shared/outfitEngine.ts +++ b/functions/src/shared/outfitEngine.ts @@ -4,6 +4,17 @@ import { AllowedColor, Category, } from "./wardrobeTaxonomy"; +import type {CompactAuraMemoryContext} from "../../../shared/auraMemory"; +import { + ACCESSORY_SLOT_ORDER, + getAccessorySlot, + type AccessorySlot, +} from "../../../shared/accessorySlots"; +import { scoreOutfitStyling } from "./styling/stylingScore"; +import type { + StylingItem, + StylingScoreResult, +} from "./styling/types"; export const MODEL = "gpt-4.1-mini"; const ALLOWED_COLOR_SET = new Set(ALLOWED_COLORS); @@ -33,7 +44,7 @@ const WARMTH_HINTS: Array<{words: string[]; value: number}> = [ {words: ["spring", "fall", "autumn", "mild"], value: 0.5}, ]; -export type Slot = "top" | "bottom" | "footwear" | "outerwear"; +export type Slot = "top" | "bottom" | "footwear" | "outerwear" | "accessory"; export type OutfitIntentV1 = { occasion: @@ -54,6 +65,7 @@ export type OutfitIntentV1 = { colorsAvoid: AllowedColor[]; avoidLogos: boolean; excludeLaundry: boolean; + requireOuterwear?: boolean; }; export type OutfitChatConstraints = { @@ -95,13 +107,35 @@ export type WardrobeItem = { id: string; category?: string; subCategory?: string; + fit?: string | null; + rise?: string | null; + legShape?: string | null; colors?: string[]; + aiColors?: string[]; primaryColor?: string; + displayColor?: string | null; + displayColors?: string[] | null; + aiColorLabel?: string | null; status?: string; + laundryStatus?: string | null; isDraft?: boolean; draftState?: string | null; brand?: string | null; name?: string | null; + type?: string | null; + style?: string | null; + formality?: string | null; + wearSlot?: "core" | "accessory" | null; + material?: string | null; + materials?: string[] | null; + pattern?: string | null; + sleeveLength?: string | null; + neckline?: string | null; + collar?: string | null; + closure?: string | null; + length?: string | null; + visualWeight?: string | null; + aestheticTags?: string[]; colorLabel?: string | null; ingestion?: {status?: string}; formalityScore?: number; @@ -128,6 +162,8 @@ export type OutfitResult = { picks: Array<{slot: Slot; itemId: string}>; score: number; reason: string; + stylingScore?: number; + stylingIntelligence?: StylingScoreResult; }; type ScoredItem = { @@ -146,6 +182,31 @@ type OutfitCandidate = { score: number; reason: string; itemIds: string[]; + stylingScore?: number; + stylingIntelligence?: StylingScoreResult; +}; + +export type OutfitDiversityOptions = { + recentItemIds?: string[]; + previousLookItemIds?: string[]; + previousLookSignatures?: string[]; + excludedLookIds?: string[]; + maxOverlap?: number; +}; + +type PreferenceBiasContext = { + explicitFavoriteColors: AllowedColor[]; + explicitAvoidColors: AllowedColor[]; + learnedFavoriteColors: AllowedColor[]; + learnedAvoidColors: AllowedColor[]; + explicitFavoriteCategories: string[]; + explicitAvoidCategories: string[]; + learnedFavoriteCategories: string[]; + learnedAvoidCategories: string[]; + preferredFits: string[]; + learnedFits: string[]; + learnedConfidence: number; + experimentationLevel: "low" | "medium" | "high"; }; function clamp01(value: unknown, fallback = 0.5): number { @@ -223,19 +284,52 @@ export function inferWarmth(prompt: string): number { return 0.5; } +function wantsOuterwear(prompt: string): boolean { + const normalized = normalizedText(prompt); + if (!normalized) return false; + const outerwearHints = [ + "with jacket", + "with jackets", + "jacket look", + "jacket looks", + "outerwear", + "with outerwear", + "layered", + "layers", + "layer up", + "hoodie", + "blazer", + "coat", + "coats", + "cardigan", + "cardigans", + "overshirt", + "overshirts", + "shacket", + "shackets", + "parka", + "parkas", + "bomber", + "bombers", + ]; + return outerwearHints.some((hint) => normalized.includes(hint)); +} + export function fallbackIntent(prompt: string): OutfitIntentV1 { const normalized = prompt.toLowerCase(); const occasion = ( ["casual", "formal", "gym", "date", "work", "party", "travel"] as const ).find((value) => normalized.includes(value)) ?? "unknown"; const warmthTarget = inferWarmth(prompt); + const needsOuterwear = wantsOuterwear(prompt); return { occasion, formalityTarget: FORMALITY_BY_OCCASION[occasion] ?? 0.5, warmthTarget, needs: ["top", "bottom", "footwear"], - niceToHave: warmthTarget > 0.65 ? ["outerwear"] : [], + niceToHave: + warmthTarget > 0.65 || needsOuterwear ? ["outerwear"] : [], colorsWanted: normalizeColorList( ALLOWED_COLORS.filter((color) => normalized.includes(color)), 2 @@ -243,6 +337,7 @@ export function fallbackIntent(prompt: string): OutfitIntentV1 { colorsAvoid: [], avoidLogos: normalized.includes("no logo") || normalized.includes("avoid logo"), excludeLaundry: true, + requireOuterwear: needsOuterwear, }; } @@ -278,6 +373,11 @@ export function normalizeParsedIntent( value === "outerwear" || value === "accessory" ) : []; + const needsOuterwear = wantsOuterwear(sourceText); + const enrichedNiceToHave: Array<"outerwear" | "accessory"> = + needsOuterwear && !niceToHave.includes("outerwear") + ? [...niceToHave, "outerwear"] + : niceToHave; return { occasion, @@ -287,11 +387,12 @@ export function normalizeParsedIntent( ), warmthTarget: clamp01(parsed.warmthTarget, inferWarmth(sourceText)), needs: needs.length > 0 ? needs : ["top", "bottom", "footwear"], - niceToHave, + niceToHave: enrichedNiceToHave, colorsWanted: normalizeColorList(parsed.colorsWanted, 2), colorsAvoid: normalizeColorList(parsed.colorsAvoid, 2), avoidLogos: !!parsed.avoidLogos, excludeLaundry: parsed.excludeLaundry !== false, + requireOuterwear: needsOuterwear, }; } @@ -304,6 +405,66 @@ function toMillis(value: WardrobeItem["lastWornDate"]): number | null { return null; } +function normalizeIdList(values: unknown, maxLength: number): string[] { + if (!Array.isArray(values)) return []; + const seen = new Set(); + const out: string[] = []; + for (const value of values) { + const id = String(value ?? "").trim(); + if (!id || seen.has(id)) continue; + seen.add(id); + out.push(id); + if (out.length >= maxLength) break; + } + return out; +} + +function outfitSignature(itemIds: string[]) { + return normalizeIdList(itemIds, 12).sort().join("|"); +} + +function overlapCount(itemIds: string[], compareIds: Set) { + if (!compareIds.size) return 0; + return itemIds.filter((itemId) => compareIds.has(itemId)).length; +} + +function underusedWearableBonus(items: WardrobeItem[]) { + const now = Date.now(); + return items.reduce((sum, item) => { + const worn = toMillis(item.lastWornDate); + if (!worn) return sum + 0.018; + const days = (now - worn) / (24 * 60 * 60 * 1000); + if (days >= 30) return sum + 0.02; + if (days >= 14) return sum + 0.012; + return sum; + }, 0); +} + +function diversityAdjustment( + candidate: OutfitCandidate, + diversity?: OutfitDiversityOptions | null +) { + if (!diversity) return 0; + const previousIds = new Set(normalizeIdList(diversity.previousLookItemIds, 12)); + const recentIds = new Set(normalizeIdList(diversity.recentItemIds, 36)); + const previousSignatures = new Set([ + ...normalizeIdList(diversity.previousLookSignatures, 12), + ...normalizeIdList(diversity.excludedLookIds, 12), + ]); + const signature = outfitSignature(candidate.itemIds); + const previousOverlap = overlapCount(candidate.itemIds, previousIds); + const recentOverlap = overlapCount(candidate.itemIds, recentIds); + const maxOverlap = Math.max(0, Math.min(3, Number(diversity.maxOverlap ?? 2))); + let adjustment = 0; + if (previousSignatures.has(signature)) adjustment -= 999; + adjustment -= previousOverlap * 0.18; + adjustment -= Math.max(0, previousOverlap - maxOverlap) * 0.36; + adjustment -= Math.max(0, recentOverlap - previousOverlap) * 0.055; + const roleCoverage = new Set(candidate.picks.map((pick) => pick.slot)).size; + adjustment += Math.max(0, roleCoverage - 3) * 0.018; + return adjustment; +} + function normalizeItemColors(item: WardrobeItem): AllowedColor[] { const values = [ ...(Array.isArray(item.colors) ? item.colors : []), @@ -319,12 +480,56 @@ function normalizeItemColors(item: WardrobeItem): AllowedColor[] { return out; } +function normalizeCategoryToken(value: unknown): string { + return String(value ?? "") + .trim() + .toLowerCase() + .replace(/[_-]+/g, " ") + .replace(/\s+/g, " "); +} + +function normalizeTokenList(values: unknown, limit: number): string[] { + if (!Array.isArray(values)) return []; + const seen = new Set(); + const next: string[] = []; + for (const value of values) { + const token = normalizeCategoryToken(value); + if (!token || seen.has(token)) continue; + seen.add(token); + next.push(token); + if (next.length >= limit) break; + } + return next; +} + function bucketForItem(item: WardrobeItem): Slot | null { const category = String(item.category ?? "").trim().toLowerCase(); + const joined = normalizedText( + [ + item.category, + item.subCategory, + item.type, + item.name, + ] + .filter(Boolean) + .join(" "), + ); + if ( + category === Category.OUTERWEAR || + /\b(jacket|coat|outerwear|overshirt|blazer|trench|parka|bomber|shacket|cardigan)\b/.test(joined) + ) { + return "outerwear"; + } if (category === Category.TOP || category === Category.ONE_PIECE) return "top"; if (category === Category.BOTTOM) return "bottom"; if (category === Category.FOOTWEAR || category === "shoes") return "footwear"; - if (category === Category.OUTERWEAR) return "outerwear"; + if ( + category === Category.ACCESSORY || + item.wearSlot === "accessory" || + getAccessorySlot(item) + ) { + return "accessory"; + } return null; } @@ -376,6 +581,98 @@ function itemHintScore(item: WardrobeItem, hint: string): number { return score; } +function buildPreferenceBiasContext( + memory?: CompactAuraMemoryContext | null +): PreferenceBiasContext { + const explicit = memory?.explicitProfile; + const learned = memory?.learnedProfile; + + return { + explicitFavoriteColors: normalizeColorList(explicit?.favoriteColors ?? [], 3), + explicitAvoidColors: normalizeColorList(explicit?.avoidColors ?? [], 3), + learnedFavoriteColors: + (learned?.confidence ?? 0) >= 0.35 + ? normalizeColorList(learned?.inferredFavoriteColors ?? [], 3) + : [], + learnedAvoidColors: + (learned?.confidence ?? 0) >= 0.35 + ? normalizeColorList(learned?.inferredAvoidColors ?? [], 2) + : [], + explicitFavoriteCategories: normalizeTokenList(explicit?.favoriteCategories ?? [], 4), + explicitAvoidCategories: normalizeTokenList(explicit?.avoidCategories ?? [], 3), + learnedFavoriteCategories: + (learned?.confidence ?? 0) >= 0.45 + ? normalizeTokenList(learned?.inferredFavoriteCategories ?? [], 4) + : [], + learnedAvoidCategories: + (learned?.confidence ?? 0) >= 0.45 + ? normalizeTokenList(learned?.inferredAvoidCategories ?? [], 3) + : [], + preferredFits: normalizeTokenList(explicit?.preferredFits ?? [], 3), + learnedFits: + (learned?.confidence ?? 0) >= 0.45 + ? normalizeTokenList(learned?.inferredFits ?? [], 3) + : [], + learnedConfidence: clamp01(learned?.confidence, 0), + experimentationLevel: explicit?.experimentationLevel ?? "medium", + }; +} + +function itemCategoryTokens(item: WardrobeItem): string[] { + return [ + normalizeCategoryToken(item.category), + normalizeCategoryToken(item.subCategory), + normalizeCategoryToken(bucketForItem(item)), + normalizeCategoryToken(getAccessorySlot(item)), + ].filter(Boolean); +} + +function categoryPreferenceScore( + item: WardrobeItem, + preferenceBias: PreferenceBiasContext +): number { + const tokens = itemCategoryTokens(item); + const learnedWeight = 0.04 + 0.05 * preferenceBias.learnedConfidence; + let score = 0; + if (tokens.some((token) => preferenceBias.explicitFavoriteCategories.includes(token))) score += 0.12; + if (tokens.some((token) => preferenceBias.learnedFavoriteCategories.includes(token))) score += learnedWeight; + if (tokens.some((token) => preferenceBias.explicitAvoidCategories.includes(token))) score -= 0.14; + if (tokens.some((token) => preferenceBias.learnedAvoidCategories.includes(token))) score -= learnedWeight; + return score; +} + +function fitPreferenceScore( + item: WardrobeItem, + preferenceBias: PreferenceBiasContext +): number { + const tokens = [ + normalizeCategoryToken(item.fit), + normalizeCategoryToken(item.rise), + normalizeCategoryToken(item.legShape), + ].filter(Boolean); + let score = 0; + if (tokens.some((token) => preferenceBias.preferredFits.includes(token))) score += 0.09; + if (tokens.some((token) => preferenceBias.learnedFits.includes(token))) { + score += 0.03 + 0.04 * preferenceBias.learnedConfidence; + } + return score; +} + +function experimentationBias( + item: WardrobeItem, + preferenceBias: PreferenceBiasContext +): number { + const colors = normalizeItemColors(item); + const nonNeutralCount = colors.filter((color) => !NEUTRAL_COLORS.has(color)).length; + if (preferenceBias.experimentationLevel === "low") { + return nonNeutralCount > 1 ? -0.08 : 0.03; + } + if (preferenceBias.experimentationLevel === "high") { + return nonNeutralCount > 1 ? 0.06 : nonNeutralCount > 0 ? 0.03 : 0; + } + return nonNeutralCount > 1 ? -0.01 : 0.01; +} + export function resolveItemHints( items: WardrobeItem[], hints: Array<{slot?: Slot | null; itemHint?: string | null}> | undefined @@ -494,13 +791,30 @@ function recencyPenalty(item: WardrobeItem): number { return 0; } -function scoreItem(item: WardrobeItem, intent: OutfitIntentV1): number { +function scoreItem( + item: WardrobeItem, + intent: OutfitIntentV1, + preferenceBias: PreferenceBiasContext +): number { const formalityScore = clamp01(item.formalityScore, 0.5); const warmthScore = clamp01(item.warmthScore, 0.5); const itemColors = normalizeItemColors(item); const logoPenalty = intent.avoidLogos && item.hasLogo ? 0.5 : 0; const missingPenalty = item.formalityScore == null || item.warmthScore == null ? 0.05 : 0; + const explicitColorBias = colorMatchScore( + itemColors, + preferenceBias.explicitFavoriteColors, + preferenceBias.explicitAvoidColors + ); + const learnedColorBias = colorMatchScore( + itemColors, + preferenceBias.learnedFavoriteColors, + preferenceBias.learnedAvoidColors + ); + const categoryBias = categoryPreferenceScore(item, preferenceBias); + const fitBias = fitPreferenceScore(item, preferenceBias); + const experimentationScore = experimentationBias(item, preferenceBias); const score = 0.35 * closeness(formalityScore, intent.formalityTarget) + @@ -509,16 +823,241 @@ function scoreItem(item: WardrobeItem, intent: OutfitIntentV1): number { 0.1 * tagMatchScore(item, intent) - 0.05 * recencyPenalty(item) - logoPenalty - - missingPenalty; + missingPenalty + + 0.18 * explicitColorBias + + 0.12 * learnedColorBias + + categoryBias + + fitBias + + experimentationScore; + + return score; +} + +const DEBUG_OUTFIT_ACCESSORIES = + process.env.AURA_DEBUG === "1" || + process.env.EXPO_PUBLIC_AURA_DEBUG === "1" || + process.env.FUNCTIONS_EMULATOR === "true"; +const accessoryDiscardDebugKeys = new Set(); + +type AccessoryMood = "safe" | "balanced" | "bold"; + +function itemAccessoryText(item: WardrobeItem): string { + return normalizedText([ + item.category, + item.subCategory, + item.type, + item.name, + item.brand, + item.style, + item.formality, + item.material, + item.pattern, + item.visualWeight, + ...(item.aestheticTags ?? []), + ...(item.occasionTags ?? []), + ...(item.seasonTags ?? []), + ].filter(Boolean).join(" ")); +} + +function inferAccessoryMood( + intent: OutfitIntentV1, + preferenceBias: PreferenceBiasContext +): AccessoryMood { + const occasionText = normalizedText(intent.occasion); + if ( + intent.formalityTarget >= 0.72 || + /\b(formal|work|office|interview|smart casual)\b/.test(occasionText) || + preferenceBias.experimentationLevel === "low" + ) { + return "safe"; + } + if ( + preferenceBias.experimentationLevel === "high" || + /\b(party|date)\b/.test(occasionText) + ) { + return "bold"; + } + return "balanced"; +} + +function debugAccessoryDiscard( + slot: AccessorySlot, + discarded: WardrobeItem, + winner: WardrobeItem +) { + if (!DEBUG_OUTFIT_ACCESSORIES) return; + const key = `${slot}:${discarded.id}:${winner.id}`; + if (accessoryDiscardDebugKeys.has(key) || accessoryDiscardDebugKeys.size > 80) return; + accessoryDiscardDebugKeys.add(key); + console.info( + `[AURA_ACCESSORY_SLOT] Discarded accessory "${discarded.name ?? discarded.subCategory ?? discarded.id}" because ${slot} slot already has "${winner.name ?? winner.subCategory ?? winner.id}" with better color/vibe score.` + ); +} + +function scoreAccessoryForOutfit( + accessory: ScoredItem, + baseItems: ScoredItem[], + intent: OutfitIntentV1, + preferenceBias: PreferenceBiasContext +): number { + const slot = getAccessorySlot(accessory.item); + const mood = inferAccessoryMood(intent, preferenceBias); + const text = itemAccessoryText(accessory.item); + const accessoryColors = normalizeItemColors(accessory.item); + const baseColors = baseItems.flatMap((entry) => normalizeItemColors(entry.item)); + const uniqueBaseColors = new Set(baseColors); + const hasNeutral = accessoryColors.some((color) => NEUTRAL_COLORS.has(color)); + const hasNonNeutral = accessoryColors.some((color) => !NEUTRAL_COLORS.has(color)); + const sharesBaseColor = accessoryColors.some((color) => uniqueBaseColors.has(color)); + const isMinimal = /\b(minimal|simple|classic|clean|plain|solid|thin|slim|subtle)\b/.test(text); + const isStatement = /\b(statement|bold|chunky|logo|graphic|bright|colorful|oversized|monogram)\b/.test(text); + const isSporty = /\b(cap|baseball|snapback|beanie|sport|athletic|gym|backpack)\b/.test(text); + const isStreetwear = /\b(streetwear|street|skate|sneaker|casual|hoodie|cargo)\b/.test(text); + const isFormal = + intent.formalityTarget >= 0.72 || + intent.occasion === "formal" || + intent.occasion === "work" || + intent.occasion === "smart_casual"; + const preferredColors = [ + ...intent.colorsWanted, + ...preferenceBias.explicitFavoriteColors, + ...preferenceBias.learnedFavoriteColors, + ]; + + let score = accessory.score; + if (sharesBaseColor) score += 0.1; + if (hasNeutral) score += 0.06; + if (preferredColors.some((color) => accessoryColors.includes(color))) score += 0.12; + + if (mood === "safe") { + if (isMinimal || hasNeutral) score += 0.14; + if (isStatement || (hasNonNeutral && !sharesBaseColor)) score -= 0.14; + } else if (mood === "balanced") { + if (sharesBaseColor || hasNeutral) score += 0.09; + if (hasNonNeutral && uniqueBaseColors.size <= 3) score += 0.05; + if (isStatement) score -= 0.02; + } else { + if (isStatement || hasNonNeutral) score += 0.14; + if (hasNeutral) score += 0.03; + } + + if (isFormal) { + if (slot === "wrist" || slot === "neck") score += 0.1; + if (slot === "bag" && /\bhandbag\b/.test(text)) score += 0.07; + if (isSporty && !isStreetwear) score -= 0.28; + } + + if (isStreetwear || intent.occasion === "casual") { + if (slot === "headwear") score += 0.13; + if (slot === "neck" || slot === "bag") score += 0.04; + } + + if (intent.warmthTarget > 0.65) { + if (/\bbeanie\b/.test(text)) score += 0.12; + if (slot === "eyewear") score -= 0.06; + } else if (intent.warmthTarget < 0.35) { + if (slot === "eyewear") score += 0.12; + if (/\bbeanie\b/.test(text)) score -= 0.12; + } + + if (intent.occasion === "travel" && slot === "bag") { + score += 0.09; + } + + if (uniqueBaseColors.size >= 4 && hasNonNeutral && !sharesBaseColor) { + score -= 0.1; + } return score; } -function compatibilityBonus(items: WardrobeItem[]): number { +function buildAccessoryVariants( + accessoryItems: ScoredItem[], + baseItems: ScoredItem[], + intent: OutfitIntentV1, + preferenceBias: PreferenceBiasContext +): Array<{chosen: ScoredItem[]; bonus: number}> { + if (!intent.niceToHave.includes("accessory") || accessoryItems.length === 0) { + return [{chosen: [], bonus: 0}]; + } + + const baseIds = new Set(baseItems.map((entry) => entry.item.id)); + const grouped: Partial>> = {}; + accessoryItems.forEach((value, index) => { + if (baseIds.has(value.item.id)) return; + const slot = getAccessorySlot(value.item); + if (!slot) return; + grouped[slot] = [ + ...(grouped[slot] ?? []), + { + value, + index, + score: scoreAccessoryForOutfit(value, baseItems, intent, preferenceBias), + }, + ]; + }); + + const winners = ACCESSORY_SLOT_ORDER.flatMap((slot) => { + const group = grouped[slot] ?? []; + if (!group.length) return []; + const ranked = [...group].sort((a, b) => b.score - a.score || a.index - b.index); + const winner = ranked[0]; + for (const discarded of ranked.slice(1)) { + debugAccessoryDiscard(slot, discarded.value.item, winner.value.item); + } + return [{...winner, slot}]; + }).sort((a, b) => b.score - a.score || a.index - b.index); + + const variants: Array<{chosen: ScoredItem[]; bonus: number}> = [{chosen: [], bonus: 0}]; + const mood = inferAccessoryMood(intent, preferenceBias); + const avoidClutter = + mood === "safe" || + intent.formalityTarget >= 0.72 || + intent.occasion === "formal" || + intent.occasion === "work"; + + for (const winner of winners.slice(0, 5)) { + variants.push({ + chosen: [winner.value], + bonus: 0.03 + Math.max(0, winner.score) * 0.04, + }); + } + + if (!avoidClutter) { + const pairable = winners.slice(0, 4); + for (let i = 0; i < pairable.length; i += 1) { + for (let j = i + 1; j < pairable.length; j += 1) { + variants.push({ + chosen: [pairable[i].value, pairable[j].value], + bonus: + 0.04 + + Math.max(0, pairable[i].score + pairable[j].score) * 0.025, + }); + } + } + } + + return variants; +} + +function compatibilityBonus( + items: WardrobeItem[], + preferenceBias: PreferenceBiasContext +): number { const colors = items.flatMap((item) => normalizeItemColors(item)); if (colors.length === 0) return 0; const unique = new Set(colors); const neutralCount = colors.filter((color) => NEUTRAL_COLORS.has(color)).length; + if (preferenceBias.experimentationLevel === "low") { + if (neutralCount >= 2) return 0.07; + if (unique.size >= 4) return -0.08; + return 0; + } + if (preferenceBias.experimentationLevel === "high") { + if (unique.size >= 3) return 0.04; + if (neutralCount >= 2) return 0.03; + return 0; + } if (neutralCount >= 2) return 0.05; if (unique.size >= 4) return -0.05; return 0; @@ -529,11 +1068,15 @@ function buildReason( picks: Array<{slot: Slot; item: WardrobeItem}> ): string { const reasons: string[] = []; + const hasOuterwear = picks.some((pick) => pick.slot === "outerwear"); if (intent.warmthTarget > 0.65) { reasons.push("Warm enough for colder weather"); } else if (intent.warmthTarget < 0.35) { reasons.push("Light enough for warmer weather"); } + if ((intent.requireOuterwear || intent.niceToHave.includes("outerwear")) && hasOuterwear) { + reasons.push("layered with outerwear"); + } const occasionLabel = intent.occasion === "unknown" @@ -557,14 +1100,35 @@ function buildReason( return `${reasons.join(", ")}.`; } +function stylingItemsForOutfit(chosen: ScoredItem[]): StylingItem[] { + return chosen.map((entry) => ({ + ...entry.item, + role: entry.slot, + source: "closet", + })); +} + +function stylingIntentForOutfit(intent: OutfitIntentV1) { + return { + occasion: intent.occasion, + formalityTarget: intent.formalityTarget, + requestText: [ + intent.occasion, + intent.colorsWanted.length ? `wanted colors ${intent.colorsWanted.join(" ")}` : "", + intent.avoidLogos ? "avoid logos" : "", + ].filter(Boolean).join(" "), + }; +} + function selectTopCandidates( items: WardrobeItem[], slot: Slot, intent: OutfitIntentV1, - limit: number + limit: number, + preferenceBias: PreferenceBiasContext ): ScoredItem[] { return items - .map((item) => ({item, slot, score: scoreItem(item, intent)})) + .map((item) => ({item, slot, score: scoreItem(item, intent, preferenceBias)})) .sort((a, b) => b.score - a.score) .slice(0, limit); } @@ -574,9 +1138,12 @@ function assembleOutfits( bottomItems: ScoredItem[], footwearItems: ScoredItem[], outerwearItems: ScoredItem[], + accessoryItems: ScoredItem[], intent: OutfitIntentV1, + preferenceBias: PreferenceBiasContext, count: number, - lockedBySlot?: Partial> + lockedBySlot?: Partial>, + diversity?: OutfitDiversityOptions | null ): OutfitCandidate[] { const topCandidates = lockedBySlot?.top ? topItems.filter((item) => item.item.id === lockedBySlot.top?.id).slice(0, 1) @@ -590,79 +1157,190 @@ function assembleOutfits( const outerwearCandidates = lockedBySlot?.outerwear ? outerwearItems.filter((item) => item.item.id === lockedBySlot.outerwear?.id).slice(0, 1) : outerwearItems.slice(0, 5); + const accessoryCandidates = lockedBySlot?.accessory + ? accessoryItems.filter((item) => item.item.id === lockedBySlot.accessory?.id).slice(0, 1) + : accessoryItems.slice(0, 14); + const shouldIncludeOuterwear = + intent.warmthTarget > 0.65 || intent.niceToHave.includes("outerwear") || !!lockedBySlot?.outerwear; + const requiresOuterwear = intent.requireOuterwear === true; + const accessoryIntent = + lockedBySlot?.accessory && !intent.niceToHave.includes("accessory") + ? {...intent, niceToHave: [...intent.niceToHave, "accessory" as const]} + : intent; + + if (requiresOuterwear && outerwearCandidates.length === 0) { + return []; + } const combos: OutfitCandidate[] = []; for (const top of topCandidates) { for (const bottom of bottomCandidates) { for (const footwear of footwearCandidates) { const base = [top, bottom, footwear]; - let chosen = base; - let bonus = compatibilityBonus(base.map((value) => value.item)); - - if (intent.warmthTarget > 0.65 && outerwearCandidates.length > 0) { - const outerwear = outerwearCandidates.find( - (candidate) => - candidate.item.id !== top.item.id && - candidate.item.id !== bottom.item.id && - candidate.item.id !== footwear.item.id + const outfitOuterwearCandidates = + (shouldIncludeOuterwear || requiresOuterwear) && outerwearCandidates.length > 0 + ? outerwearCandidates.filter( + (candidate) => + candidate.item.id !== top.item.id && + candidate.item.id !== bottom.item.id && + candidate.item.id !== footwear.item.id + ) + : []; + + const variantChoices = outfitOuterwearCandidates.length + ? outfitOuterwearCandidates.map((outerwear) => ({ + chosen: [...base, outerwear], + bonus: intent.niceToHave.includes("outerwear") || requiresOuterwear ? 0.09 : 0.03, + })) + : requiresOuterwear + ? [] + : [{chosen: base, bonus: 0}]; + + for (const variant of variantChoices) { + const accessoryVariants = buildAccessoryVariants( + accessoryCandidates, + variant.chosen, + accessoryIntent, + preferenceBias ); - if (outerwear) { - chosen = [...base, outerwear]; - bonus += 0.03; + for (const accessoryVariant of accessoryVariants) { + const chosen = [...variant.chosen, ...accessoryVariant.chosen]; + const itemScores = chosen.map((value) => value.score); + const baseOutfitScore = + itemScores.reduce((sum, value) => sum + value, 0) / itemScores.length + + compatibilityBonus( + chosen.map((value) => value.item), + preferenceBias + ) + + variant.bonus + + accessoryVariant.bonus + + underusedWearableBonus(chosen.map((value) => value.item)); + const stylingIntelligence = scoreOutfitStyling( + stylingItemsForOutfit(chosen), + stylingIntentForOutfit(intent), + ); + // Keep the legacy item score shape, but let deterministic styling + // quality break ties and lift outfits with better fit/color/identity. + const outfitScore = + baseOutfitScore + ((stylingIntelligence.overallScore - 72) / 100) * 0.22; + const picks = chosen.map((value) => ({ + slot: value.slot, + itemId: value.item.id, + })); + combos.push({ + picks, + score: outfitScore, + reason: buildReason( + intent, + chosen.map((value) => ({slot: value.slot, item: value.item})) + ), + itemIds: picks.map((pick) => pick.itemId), + stylingScore: stylingIntelligence.overallScore, + stylingIntelligence, + }); } } - - const itemScores = chosen.map((value) => value.score); - const outfitScore = - itemScores.reduce((sum, value) => sum + value, 0) / itemScores.length + bonus; - const picks = chosen.map((value) => ({ - slot: value.slot, - itemId: value.item.id, - })); - combos.push({ - picks, - score: outfitScore, - reason: buildReason( - intent, - chosen.map((value) => ({slot: value.slot, item: value.item})) - ), - itemIds: picks.map((pick) => pick.itemId), - }); } } } - combos.sort((a, b) => b.score - a.score); + const dedupedCombos = Array.from( + new Map(combos.map((combo) => [combo.itemIds.slice().sort().join("|"), combo])).values() + ).sort((a, b) => b.score - a.score); const selected: OutfitCandidate[] = []; const usedTops = new Set(); const usedBottoms = new Set(); const usedFootwear = new Set(); - for (const combo of combos) { + const usedOuterwear = new Set(); + const usedAccessories = new Set(); + const usedSignatures = new Set(); + + const candidatePool = dedupedCombos + .slice(0, Math.max(count * 6, 18)) + .map((combo) => ({ + combo, + score: combo.score + diversityAdjustment(combo, diversity), + randomSeed: Math.random(), + })); + + while (selected.length < count && candidatePool.length > 0) { + const rankedPool = [...candidatePool].sort((a, b) => { + const aCombo = a.combo; + const bCombo = b.combo; + const aTopId = aCombo.picks.find((pick) => pick.slot === "top")?.itemId ?? ""; + const bTopId = bCombo.picks.find((pick) => pick.slot === "top")?.itemId ?? ""; + const aBottomId = aCombo.picks.find((pick) => pick.slot === "bottom")?.itemId ?? ""; + const bBottomId = bCombo.picks.find((pick) => pick.slot === "bottom")?.itemId ?? ""; + const aFootwearId = aCombo.picks.find((pick) => pick.slot === "footwear")?.itemId ?? ""; + const bFootwearId = bCombo.picks.find((pick) => pick.slot === "footwear")?.itemId ?? ""; + const aOuterwearId = aCombo.picks.find((pick) => pick.slot === "outerwear")?.itemId ?? ""; + const bOuterwearId = bCombo.picks.find((pick) => pick.slot === "outerwear")?.itemId ?? ""; + const aAccessoryId = aCombo.picks.find((pick) => pick.slot === "accessory")?.itemId ?? ""; + const bAccessoryId = bCombo.picks.find((pick) => pick.slot === "accessory")?.itemId ?? ""; + const aAdjusted = + a.score + + (!aTopId || !usedTops.has(aTopId) ? 0.07 : 0) + + (!aBottomId || !usedBottoms.has(aBottomId) ? 0.06 : 0) + + (!aFootwearId || !usedFootwear.has(aFootwearId) ? 0.04 : 0) + + (!aOuterwearId || !usedOuterwear.has(aOuterwearId) ? 0.05 : 0) + + (!aAccessoryId || !usedAccessories.has(aAccessoryId) ? 0.03 : 0) + + a.randomSeed * 0.035; + const bAdjusted = + b.score + + (!bTopId || !usedTops.has(bTopId) ? 0.07 : 0) + + (!bBottomId || !usedBottoms.has(bBottomId) ? 0.06 : 0) + + (!bFootwearId || !usedFootwear.has(bFootwearId) ? 0.04 : 0) + + (!bOuterwearId || !usedOuterwear.has(bOuterwearId) ? 0.05 : 0) + + (!bAccessoryId || !usedAccessories.has(bAccessoryId) ? 0.03 : 0) + + b.randomSeed * 0.035; + return bAdjusted - aAdjusted; + }); + + const choice = rankedPool[0]; + const combo = choice.combo; + const signature = combo.itemIds.slice().sort().join("|"); + if (usedSignatures.has(signature)) continue; const topId = combo.picks.find((pick) => pick.slot === "top")?.itemId ?? ""; const bottomId = combo.picks.find((pick) => pick.slot === "bottom")?.itemId ?? ""; const footwearId = combo.picks.find((pick) => pick.slot === "footwear")?.itemId ?? ""; + const outerwearId = combo.picks.find((pick) => pick.slot === "outerwear")?.itemId ?? ""; + const accessoryId = combo.picks.find((pick) => pick.slot === "accessory")?.itemId ?? ""; const canUseFresh = - (!usedTops.has(topId) || selected.length >= combos.length - 1) && - (!usedBottoms.has(bottomId) || selected.length >= combos.length - 1) && - (!usedFootwear.has(footwearId) || selected.length >= combos.length - 1); + (!topId || !usedTops.has(topId) || selected.length >= dedupedCombos.length - 1) && + (!bottomId || !usedBottoms.has(bottomId) || selected.length >= dedupedCombos.length - 1) && + (!footwearId || !usedFootwear.has(footwearId) || selected.length >= dedupedCombos.length - 1) && + (!outerwearId || !usedOuterwear.has(outerwearId) || selected.length >= dedupedCombos.length - 1) && + (!accessoryId || !usedAccessories.has(accessoryId) || selected.length >= dedupedCombos.length - 1); if (!canUseFresh && selected.length < count - 1) { + candidatePool.splice( + candidatePool.findIndex((entry) => entry.combo === combo), + 1, + ); continue; } selected.push(combo); + usedSignatures.add(signature); usedTops.add(topId); usedBottoms.add(bottomId); usedFootwear.add(footwearId); - if (selected.length >= count) break; + if (outerwearId) usedOuterwear.add(outerwearId); + if (accessoryId) usedAccessories.add(accessoryId); + candidatePool.splice( + candidatePool.findIndex((entry) => entry.combo === combo), + 1, + ); } if (selected.length < count) { - for (const combo of combos) { - if (selected.find((value) => value.itemIds.join("|") === combo.itemIds.join("|"))) { + for (const combo of dedupedCombos) { + const signature = combo.itemIds.slice().sort().join("|"); + if (usedSignatures.has(signature)) { continue; } selected.push(combo); + usedSignatures.add(signature); if (selected.length >= count) break; } } @@ -742,72 +1420,114 @@ export function filterEligibleItems( ): WardrobeItem[] { return items.filter((item) => { const ingestionStatus = getIngestionStatus(item); + const laundryStatus = String(item.laundryStatus ?? "").trim().toLowerCase(); const status = String(item.status ?? "").trim().toUpperCase(); const draftState = String(item.draftState ?? "").trim().toLowerCase(); if (item.isDraft === true) return false; if (draftState && draftState !== "ready") return false; if (ingestionStatus !== "done") return false; if (intent.excludeLaundry) { - return status === "AVAILABLE"; + return (laundryStatus ? laundryStatus === "clean" : status === "AVAILABLE"); } - return status !== "IN_LAUNDRY"; + return laundryStatus ? laundryStatus !== "in_laundry" : status !== "IN_LAUNDRY"; }); } -export function generateOutfitCandidates( - allItems: WardrobeItem[], +function filterEligibleItemsRelaxed(items: WardrobeItem[]): WardrobeItem[] { + return items.filter((item) => { + const draftState = String(item.draftState ?? "").trim().toLowerCase(); + if (item.isDraft === true) return false; + if (draftState === "cancelled" || draftState === "failed") return false; + return !!bucketForItem(item); + }); +} + +function slotCountsForItems(items: WardrobeItem[]) { + return { + top: items.filter((item) => bucketForItem(item) === "top").length, + bottom: items.filter((item) => bucketForItem(item) === "bottom").length, + footwear: items.filter((item) => bucketForItem(item) === "footwear").length, + outerwear: items.filter((item) => bucketForItem(item) === "outerwear").length, + accessory: items.filter((item) => bucketForItem(item) === "accessory").length, + }; +} + +function hasRequiredCoreSlots(slotCounts: Record) { + return slotCounts.top > 0 && slotCounts.bottom > 0 && slotCounts.footwear > 0; +} + +function buildCandidatesFromPool( + pool: WardrobeItem[], baseIntent: OutfitIntentV1, - options?: { - numOutfits?: number; + options: { constraints?: OutfitChatConstraints; - preferredSlot?: Slot | null; excludeItemIds?: string[]; + memory?: CompactAuraMemoryContext | null; + numOutfits: number; + preferredSlot?: Slot | null; lockedItemsBySlot?: Partial>; - } -): { - outfits: OutfitCandidate[]; - slotCounts: Record; - eligibleCount: number; - intent: OutfitIntentV1; -} { - const count = clampNumOutfits(options?.numOutfits ?? 3, 3); - const eligible = filterEligibleItems(allItems, baseIntent); - const constrained = applyConstraints(eligible, baseIntent, options?.constraints); - const explicitExclude = new Set(options?.excludeItemIds ?? []); + diversity?: OutfitDiversityOptions | null; + }, +) { + const memory = options.memory ?? null; + const memoryConstraints: OutfitChatConstraints = { + ...(options.constraints ?? {}), + ...(options.constraints?.occasion ? {} : memory?.session?.currentOccasion ? {occasion: memory.session.currentOccasion} : {}), + ...(Array.isArray(options.constraints?.colorsWanted) && options.constraints?.colorsWanted.length + ? {} + : memory?.explicitProfile?.favoriteColors?.length + ? {colorsWanted: memory.explicitProfile.favoriteColors.slice(0, 2)} + : (memory?.learnedProfile?.confidence ?? 0) >= 0.62 && memory?.learnedProfile?.inferredFavoriteColors?.length + ? {colorsWanted: memory.learnedProfile.inferredFavoriteColors.slice(0, 2)} + : {}), + ...(Array.isArray(options.constraints?.colorsAvoid) && options.constraints?.colorsAvoid.length + ? {} + : memory?.explicitProfile?.avoidColors?.length + ? {colorsAvoid: memory.explicitProfile.avoidColors.slice(0, 2)} + : (memory?.learnedProfile?.confidence ?? 0) >= 0.62 && memory?.learnedProfile?.inferredAvoidColors?.length + ? {colorsAvoid: memory.learnedProfile.inferredAvoidColors.slice(0, 2)} + : {}), + }; + const constrained = applyConstraints(pool, baseIntent, memoryConstraints); + const preferenceBias = buildPreferenceBiasContext(memory); + const explicitExclude = new Set(options.excludeItemIds ?? []); const finalItems = constrained.items.filter((item) => !explicitExclude.has(item.id)); - const topBucket = finalItems.filter((item) => bucketForItem(item) === "top"); const bottomBucket = finalItems.filter((item) => bucketForItem(item) === "bottom"); const footwearBucket = finalItems.filter((item) => bucketForItem(item) === "footwear"); const outerwearBucket = finalItems.filter((item) => bucketForItem(item) === "outerwear"); + const accessoryBucket = finalItems.filter((item) => bucketForItem(item) === "accessory"); + const slotCounts = slotCountsForItems(finalItems); - const slotCounts = { - top: topBucket.length, - bottom: bottomBucket.length, - footwear: footwearBucket.length, - outerwear: outerwearBucket.length, - }; - - if (topBucket.length === 0 || bottomBucket.length === 0 || footwearBucket.length === 0) { - return {outfits: [], slotCounts, eligibleCount: finalItems.length, intent: constrained.intent}; + if (!hasRequiredCoreSlots(slotCounts)) { + return { + outfits: [] as OutfitCandidate[], + slotCounts, + eligibleCount: finalItems.length, + intent: constrained.intent, + }; } - const scoredTop = selectTopCandidates(topBucket, "top", constrained.intent, 25); - const scoredBottom = selectTopCandidates(bottomBucket, "bottom", constrained.intent, 25); - const scoredFootwear = selectTopCandidates(footwearBucket, "footwear", constrained.intent, 25); - const scoredOuterwear = selectTopCandidates(outerwearBucket, "outerwear", constrained.intent, 5); + const scoredTop = selectTopCandidates(topBucket, "top", constrained.intent, 25, preferenceBias); + const scoredBottom = selectTopCandidates(bottomBucket, "bottom", constrained.intent, 25, preferenceBias); + const scoredFootwear = selectTopCandidates(footwearBucket, "footwear", constrained.intent, 25, preferenceBias); + const scoredOuterwear = selectTopCandidates(outerwearBucket, "outerwear", constrained.intent, 5, preferenceBias); + const scoredAccessories = selectTopCandidates(accessoryBucket, "accessory", constrained.intent, 18, preferenceBias); let outfits = assembleOutfits( scoredTop, scoredBottom, scoredFootwear, scoredOuterwear, + scoredAccessories, constrained.intent, - count, - options?.lockedItemsBySlot + preferenceBias, + options.numOutfits, + options.lockedItemsBySlot, + options.diversity, ); - if (options?.preferredSlot) { + if (options.preferredSlot) { outfits = outfits.sort((a, b) => { const aHas = a.picks.some((pick) => pick.slot === options.preferredSlot); const bHas = b.picks.some((pick) => pick.slot === options.preferredSlot); @@ -816,7 +1536,82 @@ export function generateOutfitCandidates( }); } - return {outfits, slotCounts, eligibleCount: finalItems.length, intent: constrained.intent}; + return { + outfits, + slotCounts, + eligibleCount: finalItems.length, + intent: constrained.intent, + }; +} + +export function generateOutfitCandidates( + allItems: WardrobeItem[], + baseIntent: OutfitIntentV1, + options?: { + numOutfits?: number; + constraints?: OutfitChatConstraints; + preferredSlot?: Slot | null; + excludeItemIds?: string[]; + lockedItemsBySlot?: Partial>; + memory?: CompactAuraMemoryContext | null; + diversity?: OutfitDiversityOptions | null; + } +): { + outfits: OutfitCandidate[]; + slotCounts: Record; + eligibleCount: number; + intent: OutfitIntentV1; + fallbackMode?: "relaxed_filters" | "relaxed_filters_no_constraints"; +} { + const count = clampNumOutfits(options?.numOutfits ?? 3, 3); + const eligible = filterEligibleItems(allItems, baseIntent); + const strictResult = buildCandidatesFromPool(eligible, baseIntent, { + constraints: options?.constraints, + excludeItemIds: options?.excludeItemIds, + memory: options?.memory, + numOutfits: count, + preferredSlot: options?.preferredSlot, + lockedItemsBySlot: options?.lockedItemsBySlot, + diversity: options?.diversity, + }); + if (strictResult.outfits.length > 0) { + return strictResult; + } + + const relaxedPool = filterEligibleItemsRelaxed(allItems); + const relaxedResult = buildCandidatesFromPool(relaxedPool, baseIntent, { + constraints: options?.constraints, + excludeItemIds: options?.excludeItemIds, + memory: options?.memory, + numOutfits: count, + preferredSlot: options?.preferredSlot, + lockedItemsBySlot: options?.lockedItemsBySlot, + diversity: options?.diversity, + }); + if (relaxedResult.outfits.length > 0) { + return { + ...relaxedResult, + fallbackMode: "relaxed_filters", + }; + } + + const unconstrainedRelaxedResult = buildCandidatesFromPool(relaxedPool, baseIntent, { + constraints: undefined, + excludeItemIds: options?.excludeItemIds, + memory: options?.memory, + numOutfits: count, + preferredSlot: options?.preferredSlot, + lockedItemsBySlot: options?.lockedItemsBySlot, + diversity: options?.diversity, + }); + if (unconstrainedRelaxedResult.outfits.length > 0) { + return { + ...unconstrainedRelaxedResult, + fallbackMode: "relaxed_filters_no_constraints", + }; + } + + return strictResult; } export function swapOutfitSlot( @@ -872,6 +1667,8 @@ export async function persistGeneratedOutfits(params: { itemIds: outfit.itemIds, planned: true, score: outfit.score, + stylingScore: outfit.stylingScore, + stylingIntelligence: outfit.stylingIntelligence, reason: outfit.reason, version: "v1", }); @@ -881,6 +1678,8 @@ export async function persistGeneratedOutfits(params: { picks: outfit.picks, score: outfit.score, reason: outfit.reason, + stylingScore: outfit.stylingScore, + stylingIntelligence: outfit.stylingIntelligence, }; }); diff --git a/functions/src/shared/productLinkExtractor.hmKodak.fixture.ts b/functions/src/shared/productLinkExtractor.hmKodak.fixture.ts new file mode 100644 index 0000000..8db0d37 --- /dev/null +++ b/functions/src/shared/productLinkExtractor.hmKodak.fixture.ts @@ -0,0 +1,61 @@ +import { extractProductMetadataFromHtml } from "./productLinkExtractor"; + +export const hmKodakProductFixture = { + url: "https://www2.hm.com/en_us/productpage.1234567001.html", + html: ` + + + Light blue/Kodak Loose Fit Printed football shirt | H&M US + + + + + + + + +
+

Light blue/Kodak Loose Fit Printed football shirt

+
Sizes XS S M L XL
+
Care instructions: Machine wash cold.
+
+ + + `, + expected: { + brand: "H&M", + title: "Light blue loose striped football shirt", + graphicText: "Kodak", + color: "Light blue", + fit: "loose", + pattern: "striped", + material: "cotton", + priceAmount: 19.99, + priceCurrency: "USD", + }, +}; + +export function runHmKodakProductFixture() { + const metadata = extractProductMetadataFromHtml(hmKodakProductFixture.url, hmKodakProductFixture.html); + return { + metadata, + passed: + metadata.brand === hmKodakProductFixture.expected.brand && + metadata.title === hmKodakProductFixture.expected.title && + !metadata.title?.includes("/Kodak") && + metadata.graphicText === hmKodakProductFixture.expected.graphicText && + metadata.material === hmKodakProductFixture.expected.material && + metadata.priceAmount === hmKodakProductFixture.expected.priceAmount, + }; +} diff --git a/functions/src/shared/productLinkExtractor.ts b/functions/src/shared/productLinkExtractor.ts new file mode 100644 index 0000000..385cbf7 --- /dev/null +++ b/functions/src/shared/productLinkExtractor.ts @@ -0,0 +1,2557 @@ +import { createHash } from "node:crypto"; +import { FieldValue, getFirestore } from "firebase-admin/firestore"; +import { logger } from "firebase-functions/v2"; +import { + SafeFetchError, + redactUrlForLogs, + safeFetch, + validateSafeUrlForFetch, +} from "./safeFetch"; +import { redactUid } from "./rateLimit"; + +export type ProductMetadata = { + sourceUrl: string; + domain: string; + retailer: string; + merchantBrand?: string | null; + brand?: string | null; + brandSource?: "json_ld" | "merchant" | "site_name" | "meta" | "title" | null; + title?: string | null; + price?: string | null; + currency?: string | null; + priceAmount?: number | null; + priceCurrency?: string | null; + priceDisplay?: string | null; + salePrice?: number | null; + originalPrice?: number | null; + priceExtractionSource?: "json_ld" | "meta" | "visible_text" | null; + color?: string | null; + displayColor?: string | null; + displayColors?: string[] | null; + description?: string | null; + productDescription?: string | null; + categoryHints?: string[]; + material?: string | null; + materials?: string[]; + fit?: string | null; + sleeveLength?: string | null; + collar?: string | null; + length?: string | null; + pattern?: string | null; + sizeOptions?: string[]; + availableSizes?: string[]; + careInstructions?: string[]; + graphicText?: string | null; + motif?: string | null; + collaborationName?: string | null; + sizeHints?: string[]; + sku?: string | null; +}; + +export type ProductExtraction = { + metadata: ProductMetadata; + imageUrls: string[]; + imageExtractionSource?: ProductImageExtractionSource | null; + imageCandidateCount?: number | null; + confidence?: number | null; + status?: "ready" | "needs_review"; + partialData?: { + title?: string | null; + imageUrls?: string[]; + }; +}; + +export type ProductExtractionOverride = { + metadata?: Partial; + imageUrls?: string[]; +}; + +type JsonObject = Record; + +type ProductLinkAdapter = { + hostMatches: (host: string) => boolean; + extract?: (params: { + url: URL; + html: string; + metadata: ProductMetadata; + imageUrls: string[]; + }) => Partial; +}; + +const MAX_HTML_BYTES = 3 * 1024 * 1024; +const MAX_HTML_CHARS = 1_500_000; +const USER_AGENT = + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) " + + "AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1"; +export const BLOCKED_STORE_MESSAGE = "This store blocked automatic reading. Paste another link or add from screenshot/photo."; +const BLOCKED_STORE_SUBTEXT = "You can try again, paste another link, or add the item from a screenshot."; +const DEBUG_PRODUCT_LINK_LOGS = + process.env.DEBUG_AURA_CANDIDATE_LOGS === "1" || + process.env.DEBUG_AURA_CANDIDATE_LOGS === "true" || + process.env.AURA_DEBUG === "1" || + process.env.AURA_DEBUG === "true"; + +export type ProductImageExtractionSource = + | "json_ld" + | "og_image" + | "twitter" + | "html_image" + | "fallback"; + +type ProductImageExtraction = { + urls: string[]; + source: ProductImageExtractionSource | null; + candidateCount: number; +}; + +const adapters: ProductLinkAdapter[] = [ + { + hostMatches: (host) => + host.endsWith("hm.com") || + host.endsWith("zara.com") || + host.endsWith("nike.com"), + }, +]; + +type FetchedProductPage = { + html: string; + finalUrl: URL; +}; + +type AmazonExtractionResult = { + metadata: ProductMetadata; + imageUrls: string[]; + confidence: number; + status: "ready" | "needs_review"; + partialData?: { + title?: string | null; + imageUrls?: string[]; + }; +}; + +export class ProductLinkError extends Error { + constructor( + message: string, + public readonly code: + | "invalid_url" + | "unsafe_url" + | "fetch_failed" + | "blocked_store" + | "no_metadata" + | "no_images" + | "draft_failed", + public readonly details?: Record, + ) { + super(message); + } +} + +function debugProductLinkInfo(message: string, data: Record) { + if (DEBUG_PRODUCT_LINK_LOGS) { + logger.info(message, data); + } +} + +function cleanText(value: unknown, maxLength = 240): string | null { + if (typeof value !== "string" && typeof value !== "number") return null; + const normalized = String(value) + .replace(/<[^>]*>/g, " ") + .replace(/\s+/g, " ") + .trim(); + if (!normalized) return null; + return normalized.slice(0, maxLength); +} + +function cleanList(values: unknown, maxItems = 6): string[] { + if (!Array.isArray(values)) return []; + return values + .map((value) => cleanText(value, 80)) + .filter((value): value is string => !!value) + .slice(0, maxItems); +} + +const RETAILER_BRAND_BY_HOST: Array<[RegExp, string]> = [ + [/(^|\.)hm\.com$/i, "H&M"], + [/(^|\.)zara\.com$/i, "Zara"], + [/(^|\.)uniqlo\.com$/i, "Uniqlo"], + [/(^|\.)nike\.com$/i, "Nike"], + [/(^|\.)adidas\.com$/i, "Adidas"], +]; + +const LICENSED_GRAPHIC_TERMS = [ + "kodak", + "barbie", + "disney", + "ferrari", + "proshots", + "camera club", + "nasa", + "warner bros", + "marvel", + "dc comics", + "star wars", + "pokemon", + "hello kitty", + "snoopy", + "peanuts", + "coca-cola", + "coca cola", +]; + +const MATERIAL_ALIASES: Array<[RegExp, string]> = [ + [/\bcotton\b/i, "cotton"], + [/\bpolyester\b/i, "polyester"], + [/\belastane\b|\bspandex\b|\blycra\b/i, "elastane"], + [/\bviscose\b|\brayon\b/i, "viscose"], + [/\blinen\b/i, "linen"], + [/\bleather\b|\bsuede\b/i, "leather"], + [/\bwool\b|\bmerino\b|\bcashmere\b/i, "wool"], + [/\bnylon\b|\bpolyamide\b/i, "nylon"], + [/\bacrylic\b/i, "acrylic"], + [/\bmodal\b/i, "modal"], + [/\blyocell\b|\btencel\b/i, "lyocell"], + [/\bsilk\b/i, "silk"], + [/\bdenim\b/i, "denim"], +]; + +function normalizedHost(host: string) { + return host.toLowerCase().replace(/^www\d*\./, ""); +} + +function normalizedDomain(host: string) { + return normalizedHost(host); +} + +function merchantBrandFromUrl(url: URL): string | null { + const host = url.hostname.toLowerCase(); + for (const [pattern, brand] of RETAILER_BRAND_BY_HOST) { + if (pattern.test(host)) return brand; + } + return null; +} + +function merchantLabelFromUrl(url: URL) { + const mapped = merchantBrandFromUrl(url); + if (mapped) return mapped; + const domain = normalizedDomain(url.hostname); + const first = domain.split(".")[0] ?? ""; + return first ? first.charAt(0).toUpperCase() + first.slice(1) : domain; +} + +function sameBrand(a?: string | null, b?: string | null) { + const normalize = (value?: string | null) => String(value ?? "").replace(/[^a-z0-9]/gi, "").toLowerCase(); + const left = normalize(a); + const right = normalize(b); + return !!left && !!right && left === right; +} + +function isLicensedGraphicText(value?: string | null) { + const text = String(value ?? "").toLowerCase(); + if (!text) return false; + return LICENSED_GRAPHIC_TERMS.some((term) => new RegExp(`\\b${term.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\b`, "i").test(text)); +} + +function licensedGraphicFromText(...values: Array) { + const text = values.filter(Boolean).join(" ").toLowerCase(); + for (const term of LICENSED_GRAPHIC_TERMS) { + if (new RegExp(`\\b${term.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\b`, "i").test(text)) { + return term + .split(" ") + .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) + .join(" "); + } + } + return null; +} + +function reliableJsonLdBrand(url: URL, brand?: string | null) { + const cleaned = cleanText(brand, 120); + if (!cleaned) return null; + if (isHmProductUrl(url)) { + return sameBrand(cleaned, "H&M") ? "H&M" : null; + } + if (isLicensedGraphicText(cleaned)) return null; + return cleaned; +} + +type ExtractedProductPrice = { + amount: number; + currency: string | null; + display: string; + source: "json_ld" | "meta" | "visible_text"; +}; + +const CURRENCY_SYMBOLS: Record = { + "$": "USD", + "US$": "USD", + "CA$": "CAD", + "C$": "CAD", + "AU$": "AUD", + "A$": "AUD", + "₹": "INR", + "€": "EUR", + "£": "GBP", + "د.إ": "AED", +}; + +const ISO_CURRENCY_ALIASES: Record = { + USD: "USD", + INR: "INR", + EUR: "EUR", + GBP: "GBP", + CAD: "CAD", + AUD: "AUD", + AED: "AED", +}; + +function normalizeCurrencyCode(value: unknown): string | null { + const raw = cleanText(value, 24); + if (!raw) return null; + const symbol = CURRENCY_SYMBOLS[raw] ?? CURRENCY_SYMBOLS[raw.toUpperCase()]; + if (symbol) return symbol; + const normalized = raw.replace(/[^a-z]/gi, "").toUpperCase(); + if (ISO_CURRENCY_ALIASES[normalized]) return ISO_CURRENCY_ALIASES[normalized]; + return /^[A-Z]{3}$/.test(normalized) ? normalized : null; +} + +function currencyFromPriceText(text: string): string | null { + const compact = text.replace(/\s+/g, " ").trim(); + const upper = compact.toUpperCase(); + for (const [symbol, currency] of Object.entries(CURRENCY_SYMBOLS).sort((a, b) => b[0].length - a[0].length)) { + if (compact.includes(symbol)) return currency; + } + const isoMatch = upper.match(/\b(USD|INR|EUR|GBP|CAD|AUD|AED)\b/); + if (isoMatch?.[1]) return isoMatch[1]; + if (/\bRS\.?\s*\d/i.test(compact)) return "INR"; + return null; +} + +function parsePriceAmount(value: unknown): number | null { + if (typeof value === "number") { + return Number.isFinite(value) && value > 0 ? Math.round(value * 100) / 100 : null; + } + const text = cleanText(value, 120); + if (!text) return null; + const match = text.match(/\d[\d,.]*/); + if (!match) return null; + let numeric = match[0]; + const lastComma = numeric.lastIndexOf(","); + const lastDot = numeric.lastIndexOf("."); + if (lastComma >= 0 && lastDot >= 0) { + numeric = + lastComma > lastDot + ? numeric.replace(/\./g, "").replace(",", ".") + : numeric.replace(/,/g, ""); + } else if (lastComma >= 0) { + const decimalDigits = numeric.length - lastComma - 1; + numeric = decimalDigits === 2 ? numeric.replace(",", ".") : numeric.replace(/,/g, ""); + } + numeric = numeric.replace(/[^0-9.]/g, ""); + const amount = Number(numeric); + if (!Number.isFinite(amount) || amount <= 0 || amount > 1_000_000) return null; + return Math.round(amount * 100) / 100; +} + +function normalizePriceCandidate(params: { + amount: unknown; + currency?: unknown; + source: ExtractedProductPrice["source"]; + display?: string | null; +}): ExtractedProductPrice | null { + const amount = parsePriceAmount(params.amount); + if (amount == null) return null; + const amountText = cleanText(params.amount, 120) ?? String(amount); + const currency = + normalizeCurrencyCode(params.currency) ?? + currencyFromPriceText(amountText) ?? + null; + const display = + cleanText(params.display, 120) ?? + (currencyFromPriceText(amountText) ? amountText : currency ? `${currency} ${amountText}` : amountText); + return { + amount, + currency, + display, + source: params.source, + }; +} + +function decodeHtmlEntities(value: string): string { + return value + .replace(/&#x([0-9a-f]+);/gi, (_, hex: string) => { + const codePoint = Number.parseInt(hex, 16); + return Number.isFinite(codePoint) ? String.fromCodePoint(codePoint) : ""; + }) + .replace(/&#(\d+);/g, (_, decimal: string) => { + const codePoint = Number.parseInt(decimal, 10); + return Number.isFinite(codePoint) ? String.fromCodePoint(codePoint) : ""; + }) + .replace(/&/g, "&") + .replace(/"/g, "\"") + .replace(/'/g, "'") + .replace(/'/g, "'") + .replace(/</g, "<") + .replace(/>/g, ">") + .replace(/ /g, " "); +} + +function extractMeta(html: string, key: string): string | null { + const escaped = key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const patterns = [ + new RegExp( + `]+property=["']${escaped}["'][^>]+content=["']([^"']+)["'][^>]*>`, + "i", + ), + new RegExp( + `]+name=["']${escaped}["'][^>]+content=["']([^"']+)["'][^>]*>`, + "i", + ), + new RegExp( + `]+content=["']([^"']+)["'][^>]+property=["']${escaped}["'][^>]*>`, + "i", + ), + new RegExp( + `]+content=["']([^"']+)["'][^>]+name=["']${escaped}["'][^>]*>`, + "i", + ), + ]; + for (const pattern of patterns) { + const match = html.match(pattern)?.[1]; + if (match) return cleanText(decodeHtmlEntities(match), 500); + } + return null; +} + +function extractTitle(html: string): string | null { + const match = html.match(/]*>([\s\S]*?)<\/title>/i)?.[1]; + return match ? cleanText(decodeHtmlEntities(match), 180) : null; +} + +function parseJsonLdBlocks(html: string): unknown[] { + const blocks: unknown[] = []; + const scriptRe = + /]+type=["']application\/ld\+json["'][^>]*>([\s\S]*?)<\/script>/gi; + let match: RegExpExecArray | null; + while ((match = scriptRe.exec(html))) { + const body = decodeHtmlEntities(match[1] ?? "").trim(); + if (!body) continue; + try { + blocks.push(JSON.parse(body)); + } catch { + const compact = body.replace(/,\s*}/g, "}").replace(/,\s*]/g, "]"); + try { + blocks.push(JSON.parse(compact)); + } catch { + // Ignore malformed structured data; meta fallback still runs. + } + } + } + return blocks; +} + +function flattenJsonLd(value: unknown): JsonObject[] { + if (!value) return []; + if (Array.isArray(value)) return value.flatMap(flattenJsonLd); + if (typeof value !== "object") return []; + const object = value as JsonObject; + const graph = object["@graph"]; + return [object, ...flattenJsonLd(graph)]; +} + +function isProductNode(node: JsonObject): boolean { + const type = node["@type"]; + const values = Array.isArray(type) ? type : [type]; + return values.some((entry) => String(entry).toLowerCase() === "product"); +} + +function getOffer(node: JsonObject): JsonObject | null { + const offers = node.offers; + if (Array.isArray(offers)) { + return (offers.find((offer) => offer && typeof offer === "object") ?? + null) as JsonObject | null; + } + return offers && typeof offers === "object" ? (offers as JsonObject) : null; +} + +function getOfferCandidates(node: JsonObject): JsonObject[] { + const offers = node.offers; + const values = Array.isArray(offers) ? offers : [offers]; + return values + .map((offer) => (offer && typeof offer === "object" ? (offer as JsonObject) : null)) + .filter((offer): offer is JsonObject => !!offer); +} + +function extractPriceFromJsonLdProduct(product: JsonObject): ExtractedProductPrice | null { + const offers = getOfferCandidates(product); + const offerCandidates = offers.length ? offers : [product]; + for (const offer of offerCandidates) { + const currency = offer.priceCurrency ?? product.priceCurrency; + const direct = normalizePriceCandidate({ + amount: offer.price ?? product.price, + currency, + source: "json_ld", + }); + if (direct) return direct; + + const lowPrice = normalizePriceCandidate({ + amount: offer.lowPrice, + currency, + source: "json_ld", + display: + offer.lowPrice != null && offer.highPrice != null + ? `${cleanText(offer.lowPrice, 40) ?? ""}-${cleanText(offer.highPrice, 40) ?? ""}` + : null, + }); + if (lowPrice) return lowPrice; + + const highPrice = normalizePriceCandidate({ + amount: offer.highPrice, + currency, + source: "json_ld", + }); + if (highPrice) return highPrice; + } + return normalizePriceCandidate({ + amount: product.price, + currency: product.priceCurrency, + source: "json_ld", + }); +} + +function getBrand(node: JsonObject): string | null { + const brand = node.brand; + if (typeof brand === "object" && brand) { + return cleanText((brand as JsonObject).name, 120); + } + return cleanText(brand, 120); +} + +function extractJsonLdProduct(html: string): Partial { + const nodes = parseJsonLdBlocks(html).flatMap(flattenJsonLd); + const product = nodes.find(isProductNode); + if (!product) return {}; + const offer = getOffer(product); + const productPrice = extractPriceFromJsonLdProduct(product); + const additional = additionalPropertiesFromJsonLd(product); + const materialValues = normalizeMaterials([ + cleanText(product.material, 240), + propertyValueFromAdditionalProperties(additional, ["material", "composition", "fabric"]), + ]); + const sizeOptions = extractSizesFromValues([ + product.size, + propertyValueFromAdditionalProperties(additional, ["size", "available size", "sizes"]), + ]); + const careInstructions = cleanList([ + product.careInstructions, + propertyValueFromAdditionalProperties(additional, ["care", "care instructions", "washing instructions"]), + ], 6); + return { + brand: getBrand(product), + title: cleanText(product.name, 180), + description: cleanText(product.description, 700), + productDescription: cleanText(product.description, 700), + price: productPrice?.display ?? cleanText(offer?.price ?? product.price, 60), + currency: productPrice?.currency ?? cleanText(offer?.priceCurrency ?? product.priceCurrency, 12), + priceAmount: productPrice?.amount ?? null, + priceCurrency: productPrice?.currency ?? null, + priceDisplay: productPrice?.display ?? null, + priceExtractionSource: productPrice?.source ?? null, + color: cleanText(product.color, 80), + displayColor: cleanText(product.color, 80), + displayColors: cleanList(Array.isArray(product.color) ? product.color : [product.color], 6), + categoryHints: cleanList( + [ + product.category, + product.audience, + product.additionalType, + product.itemCondition, + ].filter(Boolean), + ), + material: materialValues[0] ?? cleanText(product.material, 160), + materials: materialValues, + fit: normalizeFit(propertyValueFromAdditionalProperties(additional, ["fit"])), + sleeveLength: normalizeSleeveLength(propertyValueFromAdditionalProperties(additional, ["sleeve", "sleeve length"])), + collar: normalizeCollar(propertyValueFromAdditionalProperties(additional, ["collar", "neckline"])), + length: normalizeLength(propertyValueFromAdditionalProperties(additional, ["length"])), + pattern: normalizePattern(propertyValueFromAdditionalProperties(additional, ["pattern", "print"])), + sizeHints: sizeOptions, + sizeOptions, + availableSizes: sizeOptions, + careInstructions, + sku: cleanText(product.sku ?? product.mpn ?? product.productID, 120), + }; +} + +function parseJsonPayload(text: string): unknown | null { + const trimmed = text.trim(); + if (!trimmed.startsWith("{") && !trimmed.startsWith("[")) return null; + try { + return JSON.parse(trimmed); + } catch { + return null; + } +} + +function flattenObjects(value: unknown): JsonObject[] { + if (!value || typeof value !== "object") return []; + if (Array.isArray(value)) return value.flatMap(flattenObjects); + const object = value as JsonObject; + return [ + object, + ...Object.values(object).flatMap((entry) => flattenObjects(entry)), + ]; +} + +function additionalPropertiesFromJsonLd(product: JsonObject): JsonObject[] { + const values = [ + product.additionalProperty, + product.additionalProperties, + product.properties, + ]; + return values.flatMap((value) => asObjectArrayLoose(value)); +} + +function asObjectArrayLoose(value: unknown): JsonObject[] { + if (Array.isArray(value)) { + return value + .map((entry) => (entry && typeof entry === "object" ? (entry as JsonObject) : null)) + .filter((entry): entry is JsonObject => !!entry); + } + return value && typeof value === "object" ? [value as JsonObject] : []; +} + +function propertyValueFromAdditionalProperties(properties: JsonObject[], names: string[]) { + const normalizedNames = names.map((name) => name.toLowerCase()); + for (const property of properties) { + const name = cleanText(property.name ?? property.propertyID ?? property.label, 120)?.toLowerCase(); + if (!name) continue; + if (!normalizedNames.some((candidate) => name.includes(candidate))) continue; + const value = cleanText(property.value ?? property.valueReference ?? property.description, 240); + if (value) return value; + } + return null; +} + +function firstStringFromKeys(object: JsonObject, keys: string[], maxLength = 220) { + for (const key of keys) { + const value = object[key]; + const text = cleanText(value, maxLength); + if (text) return text; + } + return null; +} + +function objectContainsText(object: JsonObject, needle: string) { + if (!needle) return false; + return Object.values(object).some((value) => String(value ?? "").includes(needle)); +} + +function extractHmJsonMetadata(url: URL, html: string): Partial { + if (!isHmProductUrl(url)) return {}; + const parsed = parseJsonPayload(html); + if (!parsed) return {}; + const articleId = hmArticleIdFromUrl(url); + const objects = flattenObjects(parsed); + const candidates = [ + ...objects.filter((object) => !!articleId && objectContainsText(object, articleId)), + ...objects, + ]; + const selected = candidates.find((object) => + firstStringFromKeys(object, ["productName", "name", "title", "formattedName"]) || + firstStringFromKeys(object, ["articleCode", "sku", "code", "id"]), + ); + if (!selected) return {}; + const flattenedText = JSON.stringify(selected); + const materialValues = normalizeMaterials([ + firstStringFromKeys(selected, ["material", "composition", "materials", "compositionText"], 500), + flattenedText, + ]); + const sizes = extractSizesFromValues([ + selected.sizes, + selected.availableSizes, + selected.variants, + flattenedText, + ]); + const rawTitle = firstStringFromKeys(selected, ["productName", "name", "title", "formattedName"], 180); + const rawDescription = firstStringFromKeys(selected, ["description", "productDescription", "shortDescription"], 700); + const rawColor = firstStringFromKeys(selected, ["colorName", "colourName", "color", "colour"], 80); + const hints = extractAttributeHintsFromText(`${rawTitle ?? ""} ${rawDescription ?? ""} ${flattenedText}`); + return { + title: rawTitle, + brand: firstStringFromKeys(selected, ["brandName", "brand"], 120) ?? "H&M", + color: rawColor, + displayColor: rawColor, + displayColors: rawColor ? [rawColor] : [], + description: rawDescription, + productDescription: rawDescription, + material: materialValues[0] ?? hints.material ?? null, + materials: materialValues.length ? materialValues : hints.materials, + fit: hints.fit, + sleeveLength: hints.sleeveLength, + collar: hints.collar, + length: hints.length, + pattern: hints.pattern, + sizeHints: sizes.length ? sizes : hints.sizeHints, + sizeOptions: sizes.length ? sizes : hints.sizeOptions, + availableSizes: sizes.length ? sizes : hints.availableSizes, + careInstructions: hints.careInstructions, + sku: firstStringFromKeys(selected, ["articleCode", "sku", "code", "id"], 120) ?? articleId, + }; +} + +function extractJsonLdPrice(html: string): ExtractedProductPrice | null { + const nodes = parseJsonLdBlocks(html).flatMap(flattenJsonLd); + for (const product of nodes.filter(isProductNode)) { + const price = extractPriceFromJsonLdProduct(product); + if (price) return price; + } + return null; +} + +function extractMetaPrice(html: string): ExtractedProductPrice | null { + const amount = + extractMeta(html, "product:price:amount") ?? + extractMeta(html, "og:price:amount"); + if (!amount) return null; + return normalizePriceCandidate({ + amount, + currency: + extractMeta(html, "product:price:currency") ?? + extractMeta(html, "og:price:currency"), + source: "meta", + }); +} + +function extractMetaPriceByKeys(html: string, amountKeys: string[], currencyKeys: string[]): ExtractedProductPrice | null { + const amount = amountKeys.map((key) => extractMeta(html, key)).find(Boolean); + if (!amount) return null; + return normalizePriceCandidate({ + amount, + currency: currencyKeys.map((key) => extractMeta(html, key)).find(Boolean), + source: "meta", + }); +} + +function stripVisibleText(html: string): string { + return decodeHtmlEntities( + html + .replace(//gi, " ") + .replace(//gi, " ") + .replace(//gi, " ") + .replace(//gi, " ") + .replace(/<(?:header|footer|nav|aside)[\s\S]*?<\/(?:header|footer|nav|aside)>/gi, " ") + .replace(/<[^>]+>/g, " "), + ) + .replace(/\s+/g, " ") + .trim(); +} + +function extractOriginalPriceFromVisibleText(html: string, currentAmount?: number | null): ExtractedProductPrice | null { + const text = stripVisibleText(html).slice(0, 80_000); + const match = text.match(/\b(?:regular|original|was|list)\s+price\s*[:-]?\s*((?:USD|INR|EUR|GBP|CAD|AUD|AED)\s+|(?:US\$|CA\$|C\$|AU\$|A\$|\$|₹|€|£|د\.إ)\s*)\d[\d,.]*/i); + const display = cleanText(match?.[1] ? match[0].replace(/^[^$€£₹A-Z]*(?=(?:USD|INR|EUR|GBP|CAD|AUD|AED|US\$|CA\$|C\$|AU\$|A\$|\$|₹|€|£|د\.إ))/i, "") : null, 80); + if (!display) return null; + const candidate = normalizePriceCandidate({ + amount: display, + currency: currencyFromPriceText(display), + source: "visible_text", + display, + }); + if (!candidate) return null; + if (typeof currentAmount === "number" && candidate.amount <= currentAmount) return null; + return candidate; +} + +function normalizeMaterials(values: Array): string[] { + const text = values + .flatMap((value) => (Array.isArray(value) ? value : [value])) + .map((value) => cleanText(value, 700)) + .filter((value): value is string => !!value) + .join(" "); + const found: string[] = []; + for (const [pattern, material] of MATERIAL_ALIASES) { + if (pattern.test(text) && !found.includes(material)) found.push(material); + } + return found.slice(0, 8); +} + +function normalizeFit(value?: string | null) { + const text = String(value ?? "").toLowerCase(); + if (/\bloose\s+fit\b|\bloose\b/.test(text)) return "loose"; + if (/\brelaxed\s+fit\b|\brelaxed\b/.test(text)) return "relaxed"; + if (/\boversized\b/.test(text)) return "oversized"; + if (/\bslim\s+fit\b|\bslim\b/.test(text)) return "slim"; + if (/\bregular\s+fit\b|\bregular\b/.test(text)) return "regular"; + if (/\bstraight\s+fit\b/.test(text)) return "straight"; + return null; +} + +function normalizeSleeveLength(value?: string | null) { + const text = String(value ?? "").toLowerCase(); + if (/\blong[-\s]?sleeved?\b|\blong sleeves?\b/.test(text)) return "long sleeve"; + if (/\bshort[-\s]?sleeved?\b|\bshort sleeves?\b/.test(text)) return "short sleeve"; + if (/\bsleeveless\b/.test(text)) return "sleeveless"; + if (/\b3\/4\b|\bthree-quarter\b/.test(text)) return "three-quarter sleeve"; + return null; +} + +function normalizeCollar(value?: string | null) { + const text = String(value ?? "").toLowerCase(); + if (/\bpolo collar\b|\bturn-down collar\b|\bcollar\b/.test(text)) return "collared"; + if (/\bcrew neck\b|\bcrewneck\b/.test(text)) return "crew neck"; + if (/\bv-neck\b|\bv neck\b/.test(text)) return "v-neck"; + if (/\bmock neck\b/.test(text)) return "mock neck"; + return null; +} + +function normalizeLength(value?: string | null) { + const text = String(value ?? "").toLowerCase(); + if (/\bcropped\b/.test(text)) return "cropped"; + if (/\blongline\b|\blong length\b/.test(text)) return "long"; + if (/\bregular length\b|\bregular\b/.test(text)) return "regular"; + if (/\bshort length\b|\bshort\b/.test(text)) return "short"; + return null; +} + +function normalizePattern(value?: string | null) { + const text = String(value ?? "").toLowerCase(); + if (/\bstriped?\b|\bstripe\b/.test(text)) return "striped"; + if (/\bplaid\b|\bchecked?\b|\bcheck\b/.test(text)) return "checked"; + if (/\bgraphic\b|\bmotif\b|\bprinted graphic\b/.test(text)) return "graphic"; + if (/\bprint(?:ed)?\b/.test(text)) return "printed"; + if (/\bfloral\b/.test(text)) return "floral"; + if (/\bsolid\b|\bplain\b/.test(text)) return "solid"; + return null; +} + +function extractSizesFromValues(values: unknown[]) { + const text = values + .flatMap((value) => (Array.isArray(value) ? value : [value])) + .map((value) => cleanText(value, 500)) + .filter((value): value is string => !!value) + .join(" "); + const ordered = ["XXS", "XS", "S", "M", "L", "XL", "XXL", "2XL", "3XL"]; + const found = ordered.filter((size) => new RegExp(`\\b${size}\\b`, "i").test(text)); + const numeric = Array.from(text.matchAll(/\b(?:US\s*)?(\d{1,2}(?:\.\d)?)\b/g)) + .map((match) => match[1]) + .filter((value): value is string => !!value) + .slice(0, 12); + return Array.from(new Set([...found, ...numeric])).slice(0, 16); +} + +function extractColorFromText(value?: string | null) { + const text = String(value ?? "").trim(); + const slashColor = text.match(/^([A-Za-z]+(?:\s+[A-Za-z]+){0,2})\s*\//)?.[1]; + if (slashColor) return cleanText(slashColor.toLowerCase(), 80); + const known = text.match(/\b(light blue|dark blue|navy blue|sky blue|cream|ecru|beige|black|white|gray|grey|red|green|blue|yellow|pink|purple|orange|brown|khaki|olive|stone|ivory|silver|gold)\b/i)?.[1]; + return known ? known.toLowerCase() : null; +} + +function extractAttributeHintsFromText(text: string): Partial { + const source = cleanText(text, 5000) ?? ""; + const materials = normalizeMaterials([source]); + const sizes = extractSizesFromValues([source]); + const care = Array.from(source.matchAll(/\b(?:machine wash|wash with|do not bleach|tumble dry|line dry|dry clean|iron on)[^.]{0,100}/gi)) + .map((match) => cleanText(match[0], 140)) + .filter((value): value is string => !!value) + .slice(0, 6); + return { + material: materials[0] ?? null, + materials, + fit: normalizeFit(source), + sleeveLength: normalizeSleeveLength(source), + collar: normalizeCollar(source), + length: normalizeLength(source), + pattern: normalizePattern(source), + sizeOptions: sizes, + availableSizes: sizes, + sizeHints: sizes, + careInstructions: care, + }; +} + +function inferCategoryHintsFromTitle(title?: string | null): string[] { + const text = String(title ?? "").toLowerCase(); + if (/\b(air\s+jordan|jordan\s+\d+|new\s+balance|foot\s*locker|jd\s+sports|men[’']?s\s+shoes?|women[’']?s\s+shoes?|running\s+shoes?|basketball\s+shoes?|sneakers?|trainers?|shoes?)\b/.test(text)) { + return ["footwear", "sneakers"]; + } + if (/\bboots?\b/.test(text)) return ["footwear", "boots"]; + if (/\bloafers?\b/.test(text)) return ["footwear", "loafers"]; + if (/\bsandals?\b/.test(text)) return ["footwear", "sandals"]; + if (/\bfootball shirt\b/.test(text)) return ["top", "football shirt"]; + if (/\bpolo\b/.test(text)) return ["top", "polo"]; + if (/\bt-?shirt\b|\btee\b/.test(text)) return ["top", "tshirt"]; + if (/\bshirt\b/.test(text)) return ["top", "shirt"]; + if (/\bhoodie\b/.test(text)) return ["top", "hoodie"]; + if (/\bsweater|jumper|knit\b/.test(text)) return ["top", "sweater"]; + if (/\bracer jacket\b/.test(text)) return ["outerwear", "racer jacket"]; + if (/\bjacket\b/.test(text)) return ["outerwear", "jacket"]; + if (/\bcoat\b/.test(text)) return ["outerwear", "coat"]; + if (/\bblazer\b/.test(text)) return ["outerwear", "blazer"]; + if (/\bjeans?\b/.test(text)) return ["bottom", "jeans"]; + if (/\btrousers?\b|\bpants?\b/.test(text)) return ["bottom", "trousers"]; + if (/\bshorts?\b/.test(text)) return ["bottom", "shorts"]; + if (/\bskirt\b/.test(text)) return ["bottom", "skirt"]; + if (/\bdress\b/.test(text)) return ["one_piece", "dress"]; + return []; +} + +function productNounFromHints(hints: string[], fallbackTitle?: string | null) { + const sub = hints[1]; + if (sub && !/^(tshirt|shirt|top|bottom|outerwear|footwear)$/i.test(sub)) return sub; + const title = String(fallbackTitle ?? "").toLowerCase(); + return ( + title.match(/\bfootball shirt\b/)?.[0] ?? + title.match(/\bracer jacket\b/)?.[0] ?? + title.match(/\b(?:polo|t-?shirt|shirt|hoodie|sweater|jacket|coat|blazer|jeans|trousers|pants|shorts|skirt|sneakers|trainers|boots|loafers|dress)\b/)?.[0] ?? + sub ?? + "item" + ); +} + +function removeBrandWords(title: string, brand?: string | null) { + let next = title; + const brands = [brand, "H&M", "Zara", "Uniqlo", "Nike", "Adidas", "Aritzia", "Amazon", "Foot Locker", "JD Sports", "Macy's", "Macy’s", "New Balance"] + .map((value) => cleanText(value, 80)) + .filter((value): value is string => !!value); + for (const candidate of brands) { + const escaped = candidate.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + next = next + .replace(new RegExp(`^${escaped}\\s+`, "i"), "") + .replace(new RegExp(`\\s+[|–-]\\s*${escaped}\\s*$`, "i"), "") + .replace(new RegExp(`\\b${escaped}\\b`, "i"), ""); + } + return next.replace(/\s+/g, " ").trim(); +} + +function titleDerivedBrand(title?: string | null) { + const text = cleanText(title, 220); + if (!text) return null; + const firstSegment = text.split(/\s+[|–-]\s+|:/)[0]?.trim(); + if (!firstSegment) return null; + if (isLicensedGraphicText(firstSegment)) return null; + const words = firstSegment.split(/\s+/).filter(Boolean); + if (!words.length || words.length > 3) return null; + if (/\b(light|dark|blue|black|white|cream|loose|regular|slim|fit|shirt|jacket|dress|pants|trousers|sneakers?)\b/i.test(firstSegment)) { + return null; + } + return cleanText(firstSegment, 120); +} + +function cleanRetailProductTitle(params: { + rawTitle?: string | null; + brand?: string | null; + color?: string | null; + fit?: string | null; + pattern?: string | null; + categoryHints?: string[]; + graphicText?: string | null; +}) { + const raw = cleanText(params.rawTitle, 260); + if (!raw) return null; + let title = raw + .replace(/\s*\|\s*H\s*&\s*M(?:\s+[A-Z]{2})?\s*$/i, "") + .replace(/\s*\|\s*Zara\s*$/i, "") + .replace(/\s*\|\s*Nike\s*$/i, "") + .replace(/\s*(?:\||-|–)\s*(?:Aritzia|Amazon(?:\.[A-Za-z.]+)?|Foot\s*Locker|JD\s*Sports|Macy[’']?s|New\s*Balance)\s*$/i, "") + .replace(/\s*(?:\||-|–)\s*(?:Men[’']s|Women[’']s)\s*(?:\||-|–)\s*(?:Foot\s*Locker|JD\s*Sports|Macy[’']?s)\s*$/i, "") + .replace(/\s*(?:\||-|–)\s*(?:Men[’']s|Women[’']s)\s*$/i, "") + .replace(/^Men[’']s\s+/i, "") + .replace(/^Women[’']s\s+/i, "") + .replace(/^Ladies[’']?\s+/i, "") + .trim(); + + title = title.replace(/^([^/]{2,36})\/([A-Za-z0-9&'.-]+)\s+/i, (_match, colorPart: string, slashPart: string) => { + if (isLicensedGraphicText(slashPart)) return `${colorPart} `; + return `${colorPart} ${slashPart} `; + }); + + const graphicText = params.graphicText ?? licensedGraphicFromText(title); + if (graphicText) { + title = title.replace(new RegExp(`\\b${graphicText.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\b`, "i"), ""); + } + title = removeBrandWords(title, params.brand) + .replace(/\s*\/\s*/g, " ") + .replace(/\s{2,}/g, " ") + .trim(); + + const color = cleanText(params.color ?? extractColorFromText(raw), 80)?.toLowerCase() ?? null; + const fit = params.fit ?? normalizeFit(title); + const pattern = params.pattern ?? normalizePattern(title); + const noun = productNounFromHints(params.categoryHints ?? inferCategoryHintsFromTitle(title), title); + const lowerTitle = title.toLowerCase(); + const shouldRebuild = + isLicensedGraphicText(raw) || + /\/[A-Za-z0-9&'.-]+/.test(raw) || + (color && fit && !lowerTitle.includes(`${color} ${fit}`)); + if (!shouldRebuild) { + return title.charAt(0).toUpperCase() + title.slice(1); + } + + const parts = [color, fit, pattern && pattern !== "solid" ? pattern : null, noun] + .filter((value): value is string => !!value) + .filter((value, index, array) => array.findIndex((entry) => sameBrand(entry, value)) === index); + const rebuilt = parts.join(" ").replace(/\s+/g, " ").trim(); + return rebuilt ? rebuilt.charAt(0).toUpperCase() + rebuilt.slice(1) : title || raw; +} + +function hasUnsafePriceContext(context: string): boolean { + return /\b(?:shipping|delivery|returns?|tax|subtotal|cart|bag|checkout|installments?|instalments?|payments?|per\s+month|monthly|\/mo|klarna|afterpay|affirm|sezzle|zip|reward|rewards|points?|cash\s*back|coupon|promo|newsletter|gift\s*card|save|discount|off|minimum|free|was|original|regular|compare\s+at|list\s+price|msrp)\b/i.test(context); +} + +function extractVisibleTextPrice(html: string): ExtractedProductPrice | null { + const text = stripVisibleText(html).slice(0, 80_000); + if (!text) return null; + const priceRe = + /(?:\b(?:USD|INR|EUR|GBP|CAD|AUD|AED)\s+|(?:US\$|CA\$|C\$|AU\$|A\$|\$|₹|€|£|د\.إ)\s*)\d[\d,.]*(?:\.\d{1,2})?/gi; + const matches: ExtractedProductPrice[] = []; + let match: RegExpExecArray | null; + while ((match = priceRe.exec(text))) { + const display = cleanText(match[0], 80); + if (!display) continue; + const context = text.slice(Math.max(0, match.index - 70), match.index + display.length + 70); + if (hasUnsafePriceContext(context)) continue; + const candidate = normalizePriceCandidate({ + amount: display, + currency: currencyFromPriceText(display), + source: "visible_text", + display, + }); + if (!candidate) continue; + if (candidate.amount < 3) continue; + matches.push(candidate); + if (matches.length > 8) break; + } + const unique = new Map(); + for (const candidate of matches) { + unique.set(`${candidate.currency ?? "UNK"}:${candidate.amount.toFixed(2)}`, candidate); + } + if (unique.size !== 1) return null; + return Array.from(unique.values())[0] ?? null; +} + +export function extractProductPriceFromHtml(_url: string, html: string): ExtractedProductPrice | null { + return extractJsonLdPrice(html) ?? extractMetaPrice(html) ?? extractVisibleTextPrice(html); +} + +function extractProductPriceSetFromHtml(url: string, html: string) { + const current = extractProductPriceFromHtml(url, html); + const sale = + extractMetaPriceByKeys( + html, + ["product:sale_price:amount", "og:sale_price:amount", "sale_price", "salePrice"], + ["product:sale_price:currency", "og:sale_price:currency", "product:price:currency", "og:price:currency"], + ) ?? null; + const original = + extractMetaPriceByKeys( + html, + [ + "product:original_price:amount", + "product:price:original_amount", + "og:price:standard_amount", + "original_price", + "regular_price", + ], + ["product:original_price:currency", "product:price:currency", "og:price:currency"], + ) ?? extractOriginalPriceFromVisibleText(html, current?.amount ?? sale?.amount ?? null); + const salePrice = + sale?.amount ?? + (original?.amount && current?.amount && current.amount < original.amount ? current.amount : null); + return { + current, + salePrice, + originalPrice: original?.amount ?? null, + }; +} + +function normalizeUrl(baseUrl: URL, value: string): string | null { + const cleaned = decodeHtmlEntities(value).trim(); + if (!cleaned || cleaned.startsWith("data:")) return null; + try { + const parsed = new URL(cleaned, baseUrl); + if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { + return null; + } + parsed.hash = ""; + return parsed.toString(); + } catch { + return null; + } +} + +function imageDedupKey(url: string) { + return url.replace(/([?&])(imwidth|width|height|w|h|sw|sh|q|quality)=\d+/gi, "$1").toLowerCase(); +} + +function productIdHintsFromUrl(url: URL) { + const values = [ + ...url.pathname.matchAll(/[A-Z0-9]{5,}-\d{2,}|\b\d{5,}\b/gi), + ...url.search.matchAll(/[A-Z0-9]{5,}-\d{2,}|\b\d{5,}\b/gi), + ] + .map((match) => match[0]?.toLowerCase()) + .filter((value): value is string => !!value && value.length >= 5); + return Array.from(new Set(values)).slice(0, 6); +} + +function imageSourceWeight(source: ProductImageExtractionSource) { + switch (source) { + case "json_ld": + return 42; + case "og_image": + return 30; + case "twitter": + return 22; + case "html_image": + return 8; + case "fallback": + default: + return 0; + } +} + +function contextualImageScore(url: string, source: ProductImageExtractionSource, sourceUrl: URL) { + const lower = url.toLowerCase(); + const productIdBoost = productIdHintsFromUrl(sourceUrl).some((id) => lower.includes(id)) ? 20 : 0; + return scoreImageUrl(url) + imageSourceWeight(source) + productIdBoost; +} + +function imageDimensionHints(url: string) { + const lower = url.toLowerCase(); + const values: number[] = []; + try { + const parsed = new URL(url); + for (const key of ["imwidth", "width", "w", "sw", "height", "h", "sh"]) { + const value = Number(parsed.searchParams.get(key) ?? 0); + if (Number.isFinite(value) && value > 0) values.push(value); + } + } catch { + // Fall back to path parsing below. + } + for (const dim of lower.matchAll(/(?:_|-|\/)(\d{2,4})(?:x|_|-)(\d{2,4})(?:[._/?-]|$)/g)) { + values.push(Number(dim[1]), Number(dim[2])); + } + return values.filter((value) => Number.isFinite(value) && value > 0); +} + +function scoreImageUrl(url: string): number { + const lower = url.toLowerCase(); + let score = 0; + if (/\.(jpe?g|png|webp)(\?|$)/i.test(lower)) score += 8; + if (/(product|pdp|gallery|image|photo|model|main|packshot|studio)/i.test(lower)) score += 12; + if (/(clean|cutout|transparent|isolated|packshot|studio)/i.test(lower)) score += 18; + if (/^image\.hm\.com$/i.test(safeHost(url))) score += 10; + const dims = imageDimensionHints(url); + const largestDim = dims.length ? Math.max(...dims) : 0; + if (largestDim >= 1000) score += 18; + else if (largestDim >= 700) score += 12; + else if (largestDim > 0 && largestDim < 220) score -= 35; + else if (largestDim > 0 && largestDim < 420) score -= 12; + if (/(detail|close[-_ ]?up|zoom|macro|fabric|texture|material|crop|cropped|graphic|logo[-_ ]?shot|chest[-_ ]?graphic|print[-_ ]?detail)/i.test(lower)) { + score -= 55; + } + if (/(screenshot|screen[-_ ]?shot|share|social|swatch|colorchip|variant|thumb|thumbnail|icon|sprite|logo|banner|header|footer|ui|nav)/i.test(lower)) { + score -= 45; + } + if (/(logo|icon|sprite|favicon|placeholder|badge|payment|loader)/i.test(lower)) { + score -= 70; + } + return score; +} + +function safeHost(url: string) { + try { + return new URL(url).hostname.toLowerCase(); + } catch { + return ""; + } +} + +function imageScoreReasons(url: string): string[] { + const lower = url.toLowerCase(); + const reasons: string[] = []; + const dims = imageDimensionHints(url); + const largestDim = dims.length ? Math.max(...dims) : 0; + if (/(product|pdp|gallery|image|photo|model|main|packshot|studio)/i.test(lower)) { + reasons.push("product/gallery URL hint"); + } + if (/^image\.hm\.com$/i.test(safeHost(url))) reasons.push("H&M product image host"); + if (largestDim >= 700) reasons.push(`large image hint ${largestDim}px`); + if (/(detail|close[-_ ]?up|zoom|macro|fabric|texture|material|crop|cropped|graphic|logo[-_ ]?shot|chest[-_ ]?graphic|print[-_ ]?detail)/i.test(lower)) { + reasons.push("detail/close-up URL penalty"); + } + if (/(share|social|swatch|thumb|thumbnail|icon|sprite|logo|banner|header|footer|ui|nav)/i.test(lower)) { + reasons.push("thumbnail/social URL penalty"); + } + return reasons.length ? reasons : ["generic URL and resolution heuristic"]; +} + +function dedupeStableImageUrls(urls: string[]): string[] { + const byKey = new Map(); + urls.forEach((url, index) => { + if (/\s/.test(url)) return; + const key = imageDedupKey(url); + const score = scoreImageUrl(url); + if (score < -2) return; + const existing = byKey.get(key); + if (!existing || score > existing.score || (score === existing.score && url.localeCompare(existing.url) < 0)) { + byKey.set(key, { + url, + firstIndex: existing?.firstIndex ?? index, + score, + }); + } + }); + return Array.from(byKey.values()) + .sort((a, b) => a.firstIndex - b.firstIndex || a.url.localeCompare(b.url)) + .map((entry) => entry.url); +} + +function isHmProductUrl(url: URL) { + const host = url.hostname.toLowerCase(); + return ( + (host === "hm.com" || host.endsWith(".hm.com")) && + /\/productpage\.\d+\.html$/i.test(url.pathname) + ); +} + +function isNikeProductUrl(url: URL) { + return url.hostname.toLowerCase().endsWith("nike.com"); +} + +export function isAmazonProductUrl(url: URL) { + const host = url.hostname.toLowerCase(); + return host.includes("amazon."); +} + +function nikeStyleColorFromUrl(url: URL) { + return url.pathname.match(/\/([A-Z0-9]{6,}-\d{3})(?:[/?]|$)/i)?.[1]?.toUpperCase() ?? null; +} + +function hmArticleIdFromUrl(url: URL) { + return url.pathname.match(/\/productpage\.(\d+)\.html$/i)?.[1] ?? null; +} + +function hmContentFallbackUrls(url: URL) { + if (!isHmProductUrl(url)) return []; + return [ + new URL(`${url.pathname}/_jcr_content.product.json`, url).toString(), + new URL(`${url.pathname}/_jcr_content/product.json`, url).toString(), + ]; +} + +function imageValuesFromJsonLd(value: unknown, baseUrl: URL) { + const values = Array.isArray(value) ? value : [value]; + return values + .flatMap((entry) => { + if (typeof entry === "object" && entry) { + const object = entry as JsonObject; + return [object.url, object.contentUrl]; + } + return [entry]; + }) + .map((entry) => (typeof entry === "string" ? normalizeUrl(baseUrl, entry) : null)) + .filter((entry): entry is string => !!entry); +} + +function productNodeMatchesArticle(product: JsonObject, articleId: string) { + const offer = getOffer(product); + const candidates = [ + product.sku, + product.mpn, + product.productID, + product.productId, + product.url, + offer?.url, + ].map((value) => String(value ?? "")); + return candidates.some((value) => value.includes(articleId)); +} + +function rankImageUrlsByUrlHeuristic(urls: string[], sourceUrl: string, label: string) { + const ranked = urls + .map((url, index) => ({ url, index, score: scoreImageUrl(url), reasons: imageScoreReasons(url) })) + .sort((a, b) => b.score - a.score || a.index - b.index || a.url.localeCompare(b.url)); + ranked.forEach((entry, rank) => { + logger.info("[LINK_IMAGE_SCORE] URL heuristic score", { + sourceUrl: redactUrlForLogs(sourceUrl), + source: label, + sourceIndex: entry.index, + rank, + url: redactUrlForLogs(entry.url), + score: entry.score, + reasons: entry.reasons, + }); + }); + return ranked.map((entry) => entry.url); +} + +function extractHmScopedProductImages(html: string, baseUrl: URL): string[] | null { + if (!isHmProductUrl(baseUrl)) return null; + const articleId = hmArticleIdFromUrl(baseUrl); + const products = parseJsonLdBlocks(html).flatMap(flattenJsonLd).filter(isProductNode); + const scopedProduct = articleId + ? products.find((product) => productNodeMatchesArticle(product, articleId)) + : products[0]; + const rawImages = scopedProduct ? imageValuesFromJsonLd(scopedProduct.image, baseUrl) : []; + const scopedImages = rankImageUrlsByUrlHeuristic( + dedupeStableImageUrls(rawImages), + baseUrl.toString(), + "hm_scoped_json_ld", + ).slice(0, 8); + logger.info("[LINK_PRODUCT_SCOPE]", { + sourceUrl: redactUrlForLogs(baseUrl.toString()), + retailer: "hm", + articleId, + productNodeCount: products.length, + matchedSku: scopedProduct ? cleanText(scopedProduct.sku, 120) : null, + matchedTitleLength: scopedProduct ? String(cleanText(scopedProduct.name, 180) ?? "").length : 0, + scopedImageCount: scopedImages.length, + }); + logger.info("[LINK_IMAGE_CANDIDATES_SCOPED]", { + sourceUrl: redactUrlForLogs(baseUrl.toString()), + retailer: "hm", + articleId, + candidateCount: scopedImages.length, + urls: scopedImages.map((url) => redactUrlForLogs(url)), + }); + return scopedImages.length ? scopedImages : null; +} + +function extractJsonLdImages(html: string, baseUrl: URL): string[] { + const nodes = parseJsonLdBlocks(html).flatMap(flattenJsonLd); + const product = nodes.find(isProductNode); + if (!product) return []; + return imageValuesFromJsonLd(product.image, baseUrl); +} + +function extractMarkupImages(html: string, baseUrl: URL): string[] { + const out: string[] = []; + const imageRe = + /<(?:img|source)[^>]+(?:src|data-src|data-original|data-zoom-image|srcset)=["']([^"']+)["'][^>]*>/gi; + let match: RegExpExecArray | null; + while ((match = imageRe.exec(html))) { + const attr = match[1] ?? ""; + const candidates = attr + .split(",") + .map((part) => part.trim().split(/\s+/)[0]) + .filter(Boolean); + for (const candidate of candidates) { + const normalized = normalizeUrl(baseUrl, candidate); + if (normalized) out.push(normalized); + } + } + + const quotedUrlRe = + /["']((?:https?:)?\/\/[^\s"']+\.(?:jpg|jpeg|png|webp)(?:\?[^\s"']*)?)["']/gi; + while ((match = quotedUrlRe.exec(html))) { + const normalized = normalizeUrl(baseUrl, match[1] ?? ""); + if (normalized) out.push(normalized); + } + return out; +} + +function rankImageCandidatesBySource( + candidates: { url: string; source: ProductImageExtractionSource; index: number }[], + sourceUrl: URL, +) { + const byKey = new Map(); + for (const candidate of candidates) { + if (/\s/.test(candidate.url)) continue; + const score = contextualImageScore(candidate.url, candidate.source, sourceUrl); + if (score < 8) continue; + const key = imageDedupKey(candidate.url); + const existing = byKey.get(key); + if (!existing || score > existing.score || (score === existing.score && candidate.index < existing.index)) { + byKey.set(key, { ...candidate, index: existing?.index ?? candidate.index, score }); + } + } + return Array.from(byKey.values()) + .sort((a, b) => b.score - a.score || a.index - b.index || a.url.localeCompare(b.url)); +} + +function extractProductImagesWithSource(url: string, html: string): ProductImageExtraction { + const baseUrl = new URL(url); + if (isAmazonProductUrl(baseUrl)) { + return { urls: [], source: null, candidateCount: 0 }; + } + const scopedHmImages = extractHmScopedProductImages(html, baseUrl); + if (scopedHmImages) { + return { + urls: scopedHmImages, + source: "json_ld", + candidateCount: scopedHmImages.length, + }; + } + const nikeVariantImages = extractNikeSelectedVariantData(baseUrl.toString(), html)?.imageUrls; + if (nikeVariantImages?.length) { + return { + urls: nikeVariantImages, + source: "fallback", + candidateCount: nikeVariantImages.length, + }; + } + + let sourceIndex = 0; + const withSource = ( + source: ProductImageExtractionSource, + urls: Array, + ) => + urls + .filter((entry): entry is string => !!entry) + .map((entry) => ({ + url: entry, + source, + index: sourceIndex++, + })); + const candidates = [ + ...withSource("json_ld", extractJsonLdImages(html, baseUrl)), + ...withSource( + "og_image", + [extractMeta(html, "og:image"), extractMeta(html, "og:image:secure_url")] + .map((value) => (value ? normalizeUrl(baseUrl, value) : null)), + ), + ...withSource( + "twitter", + [extractMeta(html, "twitter:image"), extractMeta(html, "twitter:image:src")] + .map((value) => (value ? normalizeUrl(baseUrl, value) : null)), + ), + ...withSource("html_image", extractMarkupImages(html, baseUrl)), + ]; + const ranked = rankImageCandidatesBySource(candidates, baseUrl); + const urls = ranked.slice(0, 12).map((entry) => entry.url); + const source = ranked[0]?.source ?? null; + debugProductLinkInfo("[AURA_LINK_DEBUG] image extraction source", { + host: baseUrl.hostname, + path: baseUrl.pathname, + extractionSource: source, + candidateCount: ranked.length, + selectedImageHost: urls[0] ? safeHost(urls[0]) : null, + }); + logger.info("[LINK_IMAGE_CANDIDATES] extracted product image candidates", { + sourceUrl: redactUrlForLogs(url), + extractionSource: source, + candidateCount: ranked.length, + urls: urls.slice(0, 20).map((entry) => redactUrlForLogs(entry)), + }); + ranked.slice(0, 12).forEach((entry, rank) => { + logger.info("[LINK_IMAGE_SCORE] URL heuristic score", { + sourceUrl: redactUrlForLogs(url), + source: entry.source, + sourceIndex: entry.index, + rank, + url: redactUrlForLogs(entry.url), + score: entry.score, + reasons: imageScoreReasons(entry.url), + }); + }); + logger.info("[LINK_IMAGE_PRIMARY] generic primary image selected", { + sourceUrl: redactUrlForLogs(url), + extractionSource: source, + primaryImageUrl: redactUrlForLogs(urls[0]), + }); + logger.info("[LINK_IMAGE_SECONDARY] generic secondary images selected", { + sourceUrl: redactUrlForLogs(url), + secondaryImageUrls: urls.slice(1).map((entry) => redactUrlForLogs(entry)), + }); + return { + urls, + source, + candidateCount: ranked.length, + }; +} + +function parseNextDataPageProps(html: string): JsonObject | null { + const match = html.match( + /